Skip to content

Give CommonProblemDetailsExceptionTrait typed properties with safe defaults#82

Open
iloveyouinc wants to merge 1 commit into
mezzio:1.20.xfrom
iloveyouinc:fix/common-exception-trait-typed-defaults
Open

Give CommonProblemDetailsExceptionTrait typed properties with safe defaults#82
iloveyouinc wants to merge 1 commit into
mezzio:1.20.xfrom
iloveyouinc:fix/common-exception-trait-typed-defaults

Conversation

@iloveyouinc

Copy link
Copy Markdown

Problem

CommonProblemDetailsExceptionTrait declares its private properties untyped with no defaults, while exposing typed getters (getTitle(): string, getType(): string, …). The trait's docblock says composing classes are required to set the properties — but when a composing class constructs an instance without setting all of them (a very easy thing to do in a consumer's own __construct, never routed through a ::create()-style factory), the contract violation surfaces as a confusing TypeError deep inside response generation:

$e = new class extends Exception implements ProblemDetailsExceptionInterface {
    use CommonProblemDetailsExceptionTrait;
};

$e->getTitle();
// TypeError: getTitle(): Return value must be of type string, null returned

In practice, ProblemDetailsResponseFactory::createResponseFromThrowable() calls these getters, so an exception that was meant to become a clean RFC 7807 4xx response instead becomes an unhandled 500 — the failure appears in the response pipeline, far from the actual mistake.

Fix

Type the properties and give them safe defaults:

private int $status = 500;
private string $detail = '';
private string $title = '';
private string $type = '';
/** @var array<string, mixed> */
private array $additional = [];

'' for title/type is exactly the sentinel ProblemDetailsResponseFactory already treats as "derive from status" (createResponse() fills the title from the status map and builds the httpstatus.es type URI), so a partially-initialized exception now renders as a well-formed problem-details response instead of fataling. The trait docblock is updated to document the defaults.

BC considerations

  • The properties are private to the trait — only trait code and composing classes write them, so adding native types cannot break external writers.
  • The only observable behavior change is on the previously-fatal path: code that would have thrown TypeError now returns the documented defaults.
  • Composing classes that already initialize everything (including all ::create() users) are byte-for-byte unaffected.

Testing

Adds a regression test constructing a composing class that sets nothing and asserting all getters + toArray() return the defaults. composer test (82 tests / 451 assertions), composer cs-check, and composer static-analysis all pass locally on PHP 8.4.


Found while maintaining a downstream fork where ~200 new SomeProblemException($detail) call sites each needed a hand-written constructor purely to avoid this TypeError; this change makes the trait fail-safe by construction.

🤖 Generated with Claude Code

…faults

An exception composing the trait but constructed without setting every
property (e.g. a plain `new CustomException(...)` in a consumer's own
constructor, never routed through a ::create() static factory) currently
holds null in the untyped private props, so the typed getters explode:

    // TypeError: Mezzio\ProblemDetails\Exception\...::getTitle():
    // Return value must be of type string, null returned
    (new class extends Exception implements ProblemDetailsExceptionInterface {
        use CommonProblemDetailsExceptionTrait;
    })->getTitle();

In practice this turns an intended 4xx problem-details response into an
unhandled 500 inside ProblemDetailsResponseFactory.

Typing the properties with safe defaults (500 / '' / []) makes any composed
instance well-formed: '' for title/type is exactly the sentinel
ProblemDetailsResponseFactory already treats as 'derive from status', and
the properties are private to the trait, so only trait code and composing
classes write them — no BC surface beyond the previously-fatal path now
returning defaults. Includes a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gsteel

gsteel commented Jul 17, 2026

Copy link
Copy Markdown
Member

The properties are private to the trait — only trait code and composing classes write them, so adding native types cannot break external writers.

This is a library that ships a trait. All private properties of the trait are effectively part of the public API, so this is a BC break for anyone that uses it: https://3v4l.org/Zr2aL

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.

2 participants