Skip to content

Add an opt-in safe JSON throwable response factory #7

Closed

Description

@coisa

Delivery track and current status

The owner approved an additive 2.x minor with integer HTTP error codes 400–599, explicit development diagnostics and optional Interop/FastForward providers. PR92 now publishes 0e94e76272bed05b5091ad2820fb8723072f35cd, based on master f06e8ca7ffbe4f3b1fe7daaf63d5fa1de719c664. It is OPEN for owner review; the agent has not merged the PR, published a release or deployed anything.

The existing 2.x middleware delegates caught throwables to its response factory without first reporting. This delivery adds a pure JSON renderer, a separate reporting decorator and an optional JSON provider that composes those two response services. Each provider returns only its own map; applications select the base and JSON providers explicitly. Legacy middleware signatures, container/ConfigProvider/Laminas paths and capture defaults remain intact. Applications using the optional provider install FastForward at runtime on PHP 8.3+; the core retains PHP 7.4.

The staged roadmap, remaining breaking changes and major integration of these adapters remain separate. The major's report-before-render middleware must select the pure renderer.

Summary and approved public contract

Both final classes implement the existing ThrowableResponseFactoryInterface under CoiSA\ErrorHandler\Http\Message and preserve PHP 7.4-compatible syntax:

JsonThrowableResponseFactory::__construct(
    ResponseFactoryInterface $responseFactory,
    StreamFactoryInterface $streamFactory,
    bool $debug = false
);

ReportingThrowableResponseFactory::__construct(
    ThrowableHandlerInterface $throwableHandler,
    ThrowableResponseFactoryInterface $responseFactory
);

These are signature summaries, not standalone class declarations or proof of an already released API.

The JSON factory returns Content-Type: application/json with the default body {"error":"Request failed"}. Integer codes 400–599 are preserved; strings, including numeric-looking codes/SQLSTATE, and all other integers use 500. It calls the PSR-17 response factory with the status only, so that implementation supplies its default reason phrase. It never uses the diagnostic message as the HTTP reason phrase.

The third constructor boolean defaults to false. Trusted application configuration may explicitly enable debug; no environment, header, remote-address or request-global detection enables it. Debug adds exception with class, message, file, line and trace, and sends Cache-Control: no-store. Frames retain only the native file, line, class, type and function metadata keys present. Arguments, objects, previous chains and arbitrary properties are excluded; neither the Throwable nor application objects are serialized. Messages and paths can still contain sensitive diagnostic information, so this is not a complete redaction policy.

Debug uses controlled native metadata with invalid UTF-8 substitution and throwing JSON encoding; malformed message/path bytes become U+FFFD without another diagnostic report. JSON is an optional requirement for debug on PHP 7.4 and is included by PHP 8. Missing encoder capability is rejected during explicit debug construction, before a Throwable is being handled. Generic rendering and unrelated core consumers acquire no extension requirement.

The decorator reports the original once per invocation, then renders that same object and returns the delegate's exact response. A reporter failure prevents rendering; reporter and renderer failures propagate unchanged. It performs no retry, output suppression, callback registration or response emission. The reporter still owns any output it produces; the executable JSON recipe uses a private reporter that does not print. Do not wrap a renderer that already reports, such as legacy capture, or add this decorator behind the major's own report-before-render middleware.

Approved optional Interop provider contract

The owner separately approved this composition for the 2.x delivery. Provider names below are under CoiSA\ErrorHandler:

Public class Contract
ServiceProvider\ErrorHandlerServiceProvider No constructor arguments; lifecycle aliases, cached process services and legacy capture defaults. Requires the application's reporter; no implicit empty aggregate or JSON/Whoops definitions.
ServiceProvider\JsonErrorHandlerServiceProvider __construct(bool $debug = false); declares only JSON, its report-before-render composition and the response alias, without importing lifecycle definitions.
ServiceProvider\Factory\ErrorHandlerMiddlewareFactory __invoke(ContainerInterface $container): ErrorHandlerMiddleware; invokes the existing core factory for a fresh handler and wrapper per HTTP scope.

Both providers implement Interop\Container\ServiceProviderInterface. The application supplies PSR-17 response/stream factories and a private ThrowableHandlerInterface reporter. Select the providers explicitly in this order:

$services = container(
    JsonErrorHandlerServiceProvider::class,
    ErrorHandlerServiceProvider::class,
    $application,
);

Separate providers prioritize the first successful response alias: JSON must precede the base, whose alias selects legacy output capture. Resolve and invoke the reusable middleware factory for distinct scopes. Lookup does not install callbacks. Cached direct middleware/process services and PHP's existing shutdown-queue limits remain visible.

Config supplies a real bool to the JSON profile before container assembly, using normal caller-dependent PHP strictness. It does not hydrate properties or inject constructor scalars. A strict entrypoint rejects a string such as "false"; weak PHP calls may coerce it, and casting that string to bool is true. Required overrides of library-defined services use one AggregateServiceProvider in base, JSON, application order, where the last definition wins. Separate providers prioritize the first successful resolution and may fall back after PSR container failures.

The small container-interop/service-provider:^0.4 contract is a core runtime dependency compatible with PHP 7.4. fast-forward/container:^1.9 is optional for library consumers and a development dependency for real PHPUnit/PHPStan checks. A consuming application choosing these providers installs FastForward in its own runtime require, not only require-dev, and needs PHP 8.3+. The Whoops profile is delivered in the separate stack PR96.

The complete provider recipe has public/debug modes, readable application definitions and fresh scope construction. Defaults, trusted debug choice and response emission stay explicit; no request header selects diagnostics or content negotiation.

Findings and compatibility boundaries

Verified fact Consequence
The existing response-factory interface already accepts a Throwable and returns a PSR response The renderer/decorator are opt-in additions; no signature change is needed.
The legacy 2.x response factory maps integer 400–500 and uses a throwable-derived reason phrase Leave it intact; the new JSON policy is separately approved rather than extending legacy behavior by accident.
PSR-17 owns response/body construction and default reason handling Use application factories and omit the reason argument; passing an explicit empty reason is a different Nyholm behavior.
The 2.x middleware does not independently report caught downstream throwables Compose the reporting decorator explicitly; keep the JSON renderer pure.
JSON native strings can contain invalid UTF-8 Substitute invalid bytes while retaining the original Throwable and private reporting identity.
The response contract receives no request Keep content negotiation and trusted debug selection application-owned.

Alternatives and trade-offs

Alternative Complexity Compatibility Maintenance Decision
Retain application-only response/reporting glue Low No change Repeated JSON/header/reporting composition Valid status quo, but does not deliver the requested built-in adapter.
Pure JSON renderer plus reporting decorator Low Additive 2.x minor Separate reusable responsibilities Approved and implemented in PR 92.
Attach a reporter directly to the JSON renderer Low initially Can be additive Couples diagnostics to representation and complicates major migration Not selected.
Return strings/streams from diagnostic reporters or change middleware/defaults Higher Changes existing contracts Cross-cutting change Excluded from this minor.
Build a request-aware negotiated rendering framework Higher Requires another API decision Larger policy surface Separate from JSON and the HTML investigation.

The initial payload has no custom public-message/schema extension or generic object serializer. Debug is one explicit boolean implemented with private methods; it does not add another strategy hierarchy.

Validation and review evidence

Current source candidate: 0e94e76272bed05b5091ad2820fb8723072f35cd. Previous 2f47be1 hosted coverage/reviews are not proof of this new head.

  • Local PHP 8.5.11, Composer 2.10.3 and PHPUnit 9.6.38: official composer --no-plugins check PASS, 323 tests / 1,467 assertions, formatter 0/104, PHPCS and maximum PHPStan over 88 files. Runtime/test bytes are unchanged after fully checked e255192; final 0e94e76 only wraps docs/recipe calls. Scoped formatter, real provider focus (15 / 112 assertions) and parity pass after the layout adjustment.
  • Units use actual FastForward 1.9.1 / Config 1.6.0 / Interop 0.4.1. A red/green case proves JSON alone does not register lifecycle. Valid-dependency cases prove JSON-first yields the public JSON response and reversed base-first selects legacy capture; aggregate application overrides remain deterministic. Existing scopes, status/privacy, configuration, reporting/failure identity and callback restoration remain covered.
  • PHP 7.4 syntax and site/source parity on 7.4/8.5 pass: 11 excerpts / 63 links / 28 anchors / 12 recipes. The existing bounded verifier passes 24 scenarios on the equivalent recipe before the last line wrapping. No source exclusion, native-function fake or coverage-gate change is introduced.
  • The exact committed 56-file archive passes and contains embedded SHA 0e94e76272bed05b5091ad2820fb8723072f35cd. Its SHA-256 is a3868fd09a021b853e88307c35df336b7c207646a5e8a11b8b42f2eeb0a92569. Copies of the prior no-dev dependency graphs keep byte-identical locks; all installed library files were refreshed and verified against this new archive. Core/provider loading and aliases pass on actual PHP 7.4.33 without FastForward; explicit JSON/base/application, public/debug output, one report and fresh construction pass on PHP 8.5.11. This is source refresh, not a new Composer installation.
  • The unchanged consumer locks were audited successfully on 2026-10-16 with no known advisories/abandoned packages in those exact graphs. No repeated advisory query is claimed for this source-only follow-up.
  • Hosted PHP CI38985168201 passes all 14 jobs on this head, including actual Quality 8.3–8.5, highest/lowest Production graphs and coverage/report. Documentation, style, Changesets and DCO pass: 22 SUCCESS / 15 SKIPPED. The coverage note names head 0e94e76 / tested merge d15cb6380f06ed3da82178c7099154cb5c5df1f8, reports 96.64% → 97.06% and all three provider/factory classes at 100%. Native code/security completion remains pending on this exact head.
  • C/root reviewed the final snapshot without findings before the normal push. Native Codex code/security summaries are running on the exact new SHA, automatically triggered by new commits. They are not owner approval. No owner merge, release, tag or deployment was performed.

Acceptance criteria

  • Approve the public schema, explicit debug/extension policy and status behavior before implementation.
  • Implement the opt-in renderer and reporting decorator in the existing contract, preserving legacy signatures, PHP range, discovery and defaults.
  • Add the approved optional Interop base/JSON profiles and fresh scope factory with real FastForward tests, preserving PHP 7.4 core and requiring application PHP 8.3+ only for this integration.
  • Include real unit coverage for status/Content-Type, default privacy, malformed UTF-8, trace exclusions, original reporting count, collaborator failures and native callback restoration; retain the unavailable-encoder coverage limit honestly.
  • Keep response emission, transport, negotiation and trusted debug configuration application-owned.
  • Include English PHPDoc, complete runnable renderer/provider public/debug examples, README/container/HTTP/testing/cookbook/static-site guidance, major composition note and source-linked coisa-error-handler skill in the same PR.
  • Include a minor Changeset for the real @coisa/error-handler release-tooling workspace while retaining the coisa/error-handler Composer identity.
  • Confirm final-SHA hosted PHP 8.3–8.5 checks, supported production dependency bounds and coverage floor after the explicit-composition follow-up; local archive/consumer/parity evidence is complete.
  • Finish the native code/security review activity for this head; both are currently running.
  • Complete owner review and merge of PR 92. Version/release publication remains a separate owner-controlled action.

Related diagnostic adapters: PSR-3, filtering, best effort.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions