Skip to content

Fix chained exceptions serializing as empty JSON objects#164

Open
jamisonbryant wants to merge 3 commits intomixerapi:masterfrom
jamisonbryant:bugfix/chained-ex-serialize-as-empty-objects
Open

Fix chained exceptions serializing as empty JSON objects#164
jamisonbryant wants to merge 3 commits intomixerapi:masterfrom
jamisonbryant:bugfix/chained-ex-serialize-as-empty-objects

Conversation

@jamisonbryant
Copy link
Copy Markdown

@jamisonbryant jamisonbryant commented Apr 29, 2026

Closes #163

Summary

The exception chain collector in MixerApiExceptionRenderer stored raw Throwable objects in the exceptions viewVar. Since PHP's Exception class has only protected properties and does not implement JsonSerializable, json_encode produced {} for each entry. This wraps each exception in a SerializableException that extends Exception and implements JsonSerializable, preserving the Throwable contract for event listeners while producing structured JSON output.

Major Changes

  • Add SerializableException class that extends Exception and implements JsonSerializable, wrapping each exception in the chain to produce structured JSON while remaining a Throwable
  • Include file and line when debug is enabled, consistent with how the renderer already handles trace/origin data for the primary exception
  • Cap exception chain traversal at 10 iterations to guard against circular chains

Minor Changes

  • Remove unused CakeException import from MixerApiExceptionRenderTest
  • Use strict comparison (!==) in the chain-walking loop

Backwards Compatibility Notes (superseded)

This changes the type of the exceptions viewVar from Throwable[] to array[] (array of associative arrays). Two considerations:

  • JSON output: Changes from [{}, {}] to [{"class": "...", "message": "...", "code": ...}, ...]. The previous output was a serialization bug (empty objects), so no consumer could have been meaningfully depending on it.
  • Event listeners: Any listener on MixerApi.ExceptionRender.beforeRender that accesses $viewVars['exceptions'] and calls Throwable methods on the entries (e.g., $e->getMessage()) would need to update to array access (e.g., $e['message']). The ErrorDecorator itself is a passive container and is unaffected.
  • Production mode: debugViewVars() unsets the exceptions key entirely when debug = false, so production output is unchanged.

Backwards Compatibility Notes (updated)

The exceptions viewVar entries are now SerializableException instances instead of raw Throwable objects. SerializableException extends Exception (and therefore implements Throwable), so:

  • Event listeners: Any listener on MixerApi.ExceptionRender.beforeRender that calls Throwable methods (getMessage(), getCode(), getFile(), getLine()) on chain entries continues to work unchanged. These methods return the original exception's values.
  • JSON output: Changes from [{}, {}] to structured objects with class, message, code (plus file and line in debug mode). The previous output was a serialization bug -- json_encode on Throwable produces empty objects because Exception properties are protected.
  • instanceof checks: $e instanceof \Throwable and $e instanceof \Exception still pass. Checks for specific exception types (e.g., $e instanceof RuntimeException) would not match, since entries are now SerializableException wrappers. This is unlikely to affect real consumers.
  • Production mode: debugViewVars() unsets exceptions entirely when debug = false, so production output is unchanged.

Work Remaining

None.

Test Plan

  • Run vendor/bin/phpunit plugins/exception-render/tests/ -- all 21 tests pass
  • Run full suite vendor/bin/phpunit -- all 102 tests pass
  • Run vendor/bin/phpstan analyse plugins/exception-render/src/ -- no errors

The exception chain collector stored raw Throwable objects in the
viewVars. Since Exception properties are protected and the class
does not implement JsonSerializable, json_encode produced {} for
each entry. Extract class, message, code (and file/line in debug
mode) into plain arrays so the chain is visible in JSON responses.
@cnizzardini
Copy link
Copy Markdown
Member

@jamisonbryant the PR description is bigger than the code change (not counting test coverage) ....Can you you give me the tweet version?

Comment thread plugins/exception-render/src/MixerApiExceptionRenderer.php Outdated
@cnizzardini
Copy link
Copy Markdown
Member

Hard for me to tell reading the AI description in here exactly how breaking of a change this is... @jamisonbryant

Is your AI agent saying this is only breaking with debug enabled?

@jamisonbryant
Copy link
Copy Markdown
Author

@jamisonbryant the PR description is bigger than the code change (not counting test coverage) ....Can you you give me the tweet version?

Tweet version (no AI): MixerAPI's exception renderer passes raw Throwable objects to JsonView for chained exceptions, but PHP's Exception class doesn't implement JsonSerializable so they all serialize as {}. Suggested fix is to extract the fields into plain arrays before serialization.

@jamisonbryant

This comment was marked as outdated.

@cnizzardini
Copy link
Copy Markdown
Member

Hard for me to tell reading the AI description in here exactly how breaking of a change this is... @jamisonbryant
Is your AI agent saying this is only breaking with debug enabled?

You're absolutely right! (lol jk) But seriously - I argued a bit with the agent before submitting this PR to really understand the B.C. consequences. Here is my understanding, summarized:

1. The `exceptions` viewVar changes from `Throwable[]` to an array of associative arrays. The JSON output changes from `[{}, {}]` to structured objects with class, message, and code keys (plus file and line when debug is enabled).

2. The previous output was a **serialization bug** (emphasis mine). `json_encode` on `Throwable` objects produces empty objects because Exception properties are protected. No consumer could have been meaningfully depending on `{}` entries. If I'm wrong, please lmk!

3. The one potential impact is event listeners on `MixerApi.ExceptionRender.beforeRender` that access the `exceptions` viewVar and call methods e.g., `$e->getMessage()` would need to switch to array access instead.

P.S. I know B.C. is important, so I added a new section to my PR templates at the bottom to address exactly this question. If that information is unclear, can you let me know so I can tweak it?

  1. Is maybe breaking.
  2. Not worried about this
  3. Seems breaking.

Comment on lines +90 to +91
$exceptions[] = $exceptionData;
$current = $current->getPrevious();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just brainstorming here and this may be one of or both of a misuse and overkill, but what if a class was created that implements Throwable and JsonSerializable. Wouldn't this become backwards compatible?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PHP won't let you implement Throwable:

Fatal error: Class Foo cannot implement interface Throwable, extend Exception or Error instead

I got around this by doing exactly that: extending Exception.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. I had never tried myself either. Good work. This looks promising I will hopefully have time this week to pull it down and validate things myself.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Chris! Any chance you can give me minimal privileges to approve workflows (GitHub Actions) on your repos? So I can at least verify pipelines are green?

I run the tests as I write code but sometimes CI behaves funny so it would be nice to know :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think once you are contributor (i.e. this PR is merged) it will auto-run them. It's just restrictive for first-time contributors.

Replace plain array serialization with a SerializableException class
that extends Exception and implements JsonSerializable. This preserves
the Throwable contract for event listeners on beforeRender while
producing structured JSON output. Also caps exception chain traversal
at 10 iterations.
@jamisonbryant
Copy link
Copy Markdown
Author

I can clean up those style errors lickety split in just a bit.

- Avoid count() in loop condition (use depth counter)
- Add doc comments for __construct and jsonSerialize
- Remove space after (int) cast
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chained exceptions serialize as empty objects in JSON error responses

2 participants