From ab42397c0210fcfbc3686cec521dc5cee74e0bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Tue, 15 Sep 2026 20:18:04 -0300 Subject: [PATCH 01/27] Modernize PHP support and verify real error handling failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .dockerignore | 10 +- .github/workflows/ci.yml | 62 + .github/workflows/pages.yml | 31 + .gitignore | 1 + .php-cs-fixer.dist.php | 25 +- .php_cs | 236 -- .scrutinizer.yml | 28 - CHANGELOG.md | 25 + Dockerfile | 41 +- Makefile | 31 +- README.md | 66 +- composer.json | 31 +- composer.lock | 3302 ++++++++++------- docker-compose.override.yml | 9 - docker-compose.yml | 18 +- docs/.nojekyll | 0 docs/api.md | 62 + docs/index.html | 81 + docs/migration.md | 14 + docs/testing.md | 78 + examples/cli.php | 17 + phpcs.xml | 10 +- phpstan.neon | 10 +- phpunit.xml | 25 +- src/Container/ConfigProvider.php | 10 +- src/Container/ErrorHandlerContainer.php | 14 +- src/ErrorHandler.php | 73 +- src/ErrorHandlerInterface.php | 2 +- .../ErrorEventListenerProvider.php | 2 +- .../ThrowableListenerProvider.php | 2 +- src/Handler/PhpLastErrorShutdownHandler.php | 6 +- src/Http/Message/ThrowableResponseFactory.php | 16 +- src/Http/Message/ThrowableStreamFactory.php | 17 +- tests/Fire/bootstrap.php | 16 + tests/Fire/compile-fatal.inc | 3 + tests/Fire/parse-error.inc | 2 + tests/Fire/run.php | 101 + tests/Fire/scenario.php | 171 + .../Container/ConfigProviderTest.php | 25 +- .../Container/ErrorHandlerContainerTest.php | 20 +- .../Middleware/ErrorHandlerCleanupTest.php | 62 + .../Middleware/ErrorHandlerMiddlewareTest.php | 2 +- tests/Http/router.php | 40 + tests/Http/run.php | 67 + .../Container/ErrorHandlerContainerTest.php | 20 +- .../Message/ThrowableResponseFactoryTest.php | 88 + 46 files changed, 3165 insertions(+), 1807 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml delete mode 100644 .php_cs delete mode 100644 .scrutinizer.yml create mode 100644 CHANGELOG.md delete mode 100644 docker-compose.override.yml create mode 100644 docs/.nojekyll create mode 100644 docs/api.md create mode 100644 docs/index.html create mode 100644 docs/migration.md create mode 100644 docs/testing.md create mode 100644 examples/cli.php create mode 100644 tests/Fire/bootstrap.php create mode 100644 tests/Fire/compile-fatal.inc create mode 100644 tests/Fire/parse-error.inc create mode 100644 tests/Fire/run.php create mode 100644 tests/Fire/scenario.php create mode 100644 tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php create mode 100644 tests/Http/router.php create mode 100644 tests/Http/run.php create mode 100644 tests/Unit/Http/Message/ThrowableResponseFactoryTest.php diff --git a/.dockerignore b/.dockerignore index 75e5dd5..4d8ad36 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,7 @@ .composer/ -.git* +.git/ +.github/ .idea/ vendor/ -.php_cs +tmp/ *.cache -*.yml -phpcs.xml -phpstan.neon -Dockerfile -README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d66e826 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - php: '8.3' + dependencies: lowest + - php: '8.3' + dependencies: highest + - php: '8.4' + dependencies: highest + - php: '8.5' + dependencies: locked + - php: '8.5' + dependencies: highest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, xml, xmlwriter, pdo + coverage: none + tools: composer:v2 + - run: composer validate --strict --no-plugins + - if: matrix.dependencies == 'locked' + run: composer install --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + - if: matrix.dependencies == 'highest' + run: composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + - if: matrix.dependencies == 'lowest' + run: composer update --prefer-lowest --prefer-stable --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + - run: composer --no-plugins check + - name: Verify the documented CLI example + run: | + python3 - <<'PY' + import subprocess + result = subprocess.run(['php', 'examples/cli.php'], capture_output=True, text=True, timeout=5) + assert result.returncode == 1 and result.stdout == '' + assert 'Example warning' in result.stderr and 'Example uncaught exception' in result.stderr + PY + - name: Verify production-only autoload + run: | + composer install --no-dev --no-plugins --no-scripts --no-interaction --no-progress + php -r 'require "vendor/autoload.php"; new CoiSA\ErrorHandler\Container\ErrorHandlerContainer();' diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..9a85376 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,31 @@ +name: Documentation + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: docs + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/.gitignore b/.gitignore index de94220..fbd4ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tests/html/ tests/clover.xml tests/testdox.html .*.cache +tmp/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 6ee3752..75f1c3d 100755 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -14,25 +14,6 @@ * @license https://opensource.org/licenses/MIT MIT License */ -use CoiSA\PhpCsFixer\PhpCsFixer; - -/** - * This file is part of coisa/logger. - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - * - * @see https://github.com/coisa/factory - * @see https://12factor.net/logs - * - * @copyright Copyright (c) 2022 Felipe Sayão Lobato Abreu - * @license https://opensource.org/licenses/MIT MIT License - */ -$paths = [ - __FILE__, - __DIR__, -]; - -$header = file_get_contents(__DIR__ . '/.docheader'); - -return PhpCsFixer::create($paths, $header); +return (new PhpCsFixer\Config()) + ->setRules(['@PSR12' => true]) + ->setFinder(PhpCsFixer\Finder::create()->in([__DIR__ . '/src', __DIR__ . '/tests'])->name('*.php')); diff --git a/.php_cs b/.php_cs deleted file mode 100644 index 0da66c8..0000000 --- a/.php_cs +++ /dev/null @@ -1,236 +0,0 @@ - - * - * This source file is subject to the license that is bundled - * with this source code in the file LICENSE. - */ - -declare(strict_types=1); - -$header = <<<'EOF' -This file is part of coisa/error-handler. - -(c) Felipe Sayão Lobato Abreu - -This source file is subject to the license that is bundled -with this source code in the file LICENSE. -EOF; - -$finder = PhpCsFixer\Finder::create() - ->files() - ->in([__DIR__]) - ->name('*.php') - ->append([__FILE__]) - ->notPath('vendor/'); - -return PhpCsFixer\Config::create() - ->setRiskyAllowed(true) - ->setRules([ - 'align_multiline_comment' => true, - 'array_syntax' => [ - 'syntax' => 'short' - ], - 'binary_operator_spaces' => [ - 'align_double_arrow' => true, - 'align_equals' => true - ], - 'blank_line_after_namespace' => true, - 'blank_line_after_opening_tag' => true, - 'blank_line_before_statement' => [ - 'statements' => [ - 'break', - 'continue', - 'declare', - 'return', - 'throw', - 'try', - 'yield', - ], - ], - 'braces' => true, - 'cast_spaces' => true, - 'class_attributes_separation' => [ - 'elements' => [ - 'const', - 'method', - 'property' - ] - ], - 'class_definition' => [ - 'multi_line_extends_each_single_line' => false, - 'single_item_single_line' => false - ], - 'class_keyword_remove' => false, - 'combine_consecutive_issets' => true, - 'combine_consecutive_unsets' => true, - 'combine_nested_dirname' => true, - 'compact_nullable_typehint' => true, - 'concat_space' => [ - 'spacing' => 'one' - ], - 'date_time_immutable' => false, - 'declare_equal_normalize' => [ - 'space' => 'none' - ], - 'dir_constant' => true, - 'declare_strict_types' => true, - 'elseif' => true, - 'encoding' => true, - 'ereg_to_preg' => true, - 'error_suppression' => [ - 'mute_deprecation_error' => false, - 'noise_remaining_usages' => false, - 'noise_remaining_usages_exclude' => [] - ], - 'escape_implicit_backslashes' => [ - 'double_quoted' => true, - 'heredoc_syntax' => true, - 'single_quoted' => false, - ], - 'explicit_indirect_variable' => true, - 'explicit_string_variable' => true, - 'final_internal_class' => false, - 'fopen_flag_order' => true, - 'fopen_flags' => [ - 'b_mode' => true, - ], - 'full_opening_tag' => true, - 'fully_qualified_strict_types' => true, - 'function_declaration' => [ - 'closure_function_spacing' => 'one' - ], - 'function_to_constant' => [ - 'functions' => [ - 'get_called_class', - 'get_class', - 'php_sapi_name', - 'phpversion', - 'pi' - ] - ], - 'function_typehint_space' => true, - 'general_phpdoc_annotation_remove' => [ - 'annotations' => [ - 'author' - ] - ], - 'header_comment' => [ - 'commentType' => 'PHPDoc', - 'header' => $header, - 'location' => 'after_open', - 'separate' => 'both' - ], - 'heredoc_indentation' => false, - 'heredoc_to_nowdoc' => true, - 'indentation_type' => true, - 'line_ending' => true, - 'list_syntax' => [ - 'syntax' => 'short' - ], - 'lowercase_cast' => true, - 'lowercase_constants' => true, - 'lowercase_keywords' => true, - 'magic_constant_casing' => true, - 'method_argument_space' => [ - 'ensure_fully_multiline' => true - ], - 'modernize_types_casting' => true, - 'native_function_casing' => true, - 'native_function_invocation' => true, - 'no_alias_functions' => true, - 'no_blank_lines_after_class_opening' => true, - 'no_blank_lines_after_phpdoc' => true, - 'no_closing_tag' => true, - 'no_empty_comment' => true, - 'no_empty_phpdoc' => true, - 'no_empty_statement' => true, - 'no_extra_consecutive_blank_lines' => true, - 'no_homoglyph_names' => true, - 'no_leading_import_slash' => true, - 'no_leading_namespace_whitespace' => true, - 'no_mixed_echo_print' => [ - 'use' => 'echo' - ], - 'no_null_property_initialization' => true, - 'no_short_bool_cast' => true, - 'no_short_echo_tag' => true, - 'no_singleline_whitespace_before_semicolons' => true, - 'no_spaces_after_function_name' => true, - 'no_spaces_inside_parenthesis' => true, - 'no_superfluous_elseif' => true, - 'no_trailing_comma_in_list_call' => true, - 'no_trailing_comma_in_singleline_array' => true, - 'no_trailing_whitespace' => true, - 'no_trailing_whitespace_in_comment' => true, - 'no_unneeded_control_parentheses' => true, - 'no_unneeded_curly_braces' => true, - 'no_unneeded_final_method' => true, - 'no_unreachable_default_argument_value' => true, - 'no_unused_imports' => true, - 'no_useless_else' => true, - 'no_whitespace_before_comma_in_array' => true, - 'no_whitespace_in_blank_line' => true, - 'non_printable_character' => true, - 'normalize_index_brace' => true, - 'object_operator_without_whitespace' => true, - 'ordered_class_elements' => [ - 'order' => [ - 'use_trait', - 'constant_public', - 'constant_protected', - 'constant_private', - 'property_public', - 'property_protected', - 'property_private', - 'construct', - 'destruct', - 'magic', - 'phpunit', - 'method_public', - 'method_protected', - 'method_private', - ], - ], - 'ordered_imports' => true, - 'phpdoc_add_missing_param_annotation' => true, - 'phpdoc_align' => true, - 'phpdoc_annotation_without_dot' => true, - 'phpdoc_indent' => true, - 'phpdoc_no_access' => true, - 'phpdoc_no_empty_return' => true, - 'phpdoc_no_package' => false, - 'phpdoc_order' => true, - 'phpdoc_return_self_reference' => true, - 'phpdoc_scalar' => true, - 'phpdoc_separation' => true, - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_to_comment' => true, - 'phpdoc_trim' => true, - 'phpdoc_types' => true, - 'phpdoc_types_order' => true, - 'phpdoc_var_without_name' => true, - 'pow_to_exponentiation' => true, - 'protected_to_private' => true, - 'return_type_declaration' => [ - 'space_before' => 'none' - ], - 'self_accessor' => true, - 'short_scalar_cast' => true, - 'simplified_null_return' => true, - 'single_blank_line_at_eof' => true, - 'single_import_per_statement' => true, - 'single_line_after_imports' => true, - 'single_quote' => true, - 'standardize_not_equals' => true, - 'ternary_to_null_coalescing' => true, - 'trim_array_spaces' => true, - 'unary_operator_spaces' => true, - 'visibility_required' => true, - 'void_return' => true, - 'whitespace_after_comma_in_array' => true, - ]) - ->setFinder($finder); diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 46cd6b4..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,28 +0,0 @@ -build: - environment: - php: - version: '7.2' - - nodes: - analysis: - project_setup: - override: true - tests: - override: - - php-scrutinizer-run --enable-security-analysis - - phpcs-run - - coverage: - tests: - override: - - command: vendor/bin/phpunit - coverage: - file: tests/clover.xml - format: clover - -checks: - php: true - -filter: - excluded_paths: - - tests/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d7e486e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## Unreleased — next major + +### Changed + +- Require PHP 8.3 or newer and test PHP 8.3–8.5. PHP 7.x and PHP 8.0–8.2 are outside the support contract. +- Exit with status 1 when the registered uncaught-exception handler completes, preserving failure signals in CLI jobs. Direct `handleThrowable()` calls still return normally. +- Reject out-of-order `unregister()` calls without removing another handler. Only the active registration handles shutdown events. +- Return a generic HTTP body when a reporter emits nothing; use standard reason phrases instead of exception messages. Preserve valid error status codes through 599. +- Update PHPUnit, PHPStan, PHP CS Fixer, PHP_CodeSniffer, Docker and CI. Declare PSR-11 as a runtime dependency. + +### Fixed + +- Re-registering an instance no longer adds duplicate shutdown callbacks. +- Failing or recursive reporters are not invoked repeatedly during exception/shutdown handling. +- Throwable rendering cleans its output buffers even when the renderer throws, preserves the body `0`, and accepts SQLSTATE exception codes without raising a secondary type error. +- Shutdown reporting excludes nonfatal core and compile warnings. + +### Added + +- Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests. +- English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment. + +The historical 2.0.0 and 1.0.0 releases are available in [GitHub Releases](https://github.com/coisa/error-handler/releases). diff --git a/Dockerfile b/Dockerfile index 667fead..3ceeecd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,10 @@ -FROM php:7.2-alpine - -ARG APP_DIR="/app" -ARG APP_VERSION="latest" - -ENV APP_DIR="${APP_DIR}" \ - APP_VERSION="${APP_VERSION}" - -RUN apk add --update \ - make \ - git \ - zip \ - unzip \ - ; - -RUN apk add --no-cache $PHPIZE_DEPS && \ - pecl install xdebug-2.6.0 && \ - docker-php-ext-enable xdebug - -COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer - -RUN addgroup app && \ - adduser -D -h ${APP_DIR} -G app app && \ - mkdir -p ${APP_DIR}/.composer - -WORKDIR ${APP_DIR} -USER app - -COPY --chown=app . ${APP_DIR}/ - -RUN make install +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli-alpine + +RUN apk add --no-cache git unzip +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +WORKDIR /app +COPY . . +# Resolve for this PHP version; the checked-in lock is the PHP 8.5 reference set. +RUN composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress +CMD ["composer", "--no-plugins", "check"] diff --git a/Makefile b/Makefile index 20b2c34..4ca7434 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,18 @@ -COMPOSER_BIN := composer +COMPOSER_BIN ?= composer -COMPOSER_FLAGS := --prefer-dist \ - --no-scripts \ - --no-progress \ - --no-suggest \ - --optimize-autoloader \ - --no-ansi \ - --no-interaction \ - --no-plugins +.PHONY: install check test fire-test http-test -.PHONY: install uninstall reinstall clean vendor +install: + $(COMPOSER_BIN) install --no-plugins --no-scripts --no-interaction --prefer-dist -install: vendor -uninstall: clean -reinstall: clean vendor +check: + $(COMPOSER_BIN) --no-plugins check -clean: - rm -rf vendor +test: + $(COMPOSER_BIN) --no-plugins test -/usr/local/bin/composer: - curl -s https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin --filename=composer +fire-test: + php tests/Fire/run.php -vendor: /usr/local/bin/composer - $(COMPOSER_BIN) install $(COMPOSER_FLAGS) +http-test: + php tests/Http/run.php diff --git a/README.md b/README.md index c3de0a7..8bc1ff7 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,67 @@ [![PHP Version][ico-php]][link-packagist] [![Latest Version on Packagist][ico-version]][link-packagist] [![Software License][ico-license]](LICENSE) -[![Build Status][ico-build]][link-build] -[![Coverage Status][ico-scrutinizer]][link-scrutinizer] -[![Quality Score][ico-code-quality]][link-code-quality] +[![CI](https://github.com/coisa/error-handler/actions/workflows/ci.yml/badge.svg)](https://github.com/coisa/error-handler/actions/workflows/ci.yml) [![Total Downloads][ico-downloads]][link-downloads] -## Install +Small, composable PHP error handling with PSR-11 containers, PSR-14 events and PSR-15 middleware. Report warnings, handle uncaught throwables and inspect fatal errors at shutdown through the same reporter interface. + +**Development documentation for the next major release.** This branch requires PHP 8.3–8.5 and changes lifecycle, CLI exit and HTTP response behavior. These changes are not part of the published 2.0.0 release. Read the [migration guide](docs/migration.md) before adopting this branch. + +## Install and run + +To reproduce this branch with PHP 8.5 and Composer 2: ```sh -$ composer require coisa/error-handler +git clone https://github.com/coisa/error-handler.git +cd error-handler +composer install --no-plugins --no-scripts --no-interaction --prefer-dist +composer --no-plugins check +php examples/cli.php +``` + +While reviewing an unmerged PR, check out its branch before installing. On PHP 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible development tools. The CLI example deliberately exits **1** after reporting a warning and an uncaught exception. + +For the currently published release, `composer require coisa/error-handler` installs the version available on Packagist; it does not select this unreleased work. + +## Quick start + +```php +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; + +$reporter = new CallableThrowableHandler(static function (Throwable $error): void { + // Route diagnostics to your application's private logger. + error_log($error->getMessage()); +}); +$errors = new ErrorHandler($reporter); +$errors->register(); + +trigger_error('A reported warning; execution continues.', E_USER_WARNING); +throw new RuntimeException('An uncaught exception; the process exits 1.'); ``` +Load Composer's autoloader before the snippet. A normal warning is reported and execution continues. Masked/suppressed warnings are ignored. Direct `handleThrowable()` calls do not terminate the process; only the registered uncaught-exception callback does. Reporters are responsible for their output and privacy. + +## HTTP integration + +Compose `ErrorHandlerMiddleware` with a `ThrowableResponseFactory` and a PSR-17 implementation. The complete, tested [HTTP example](tests/Http/router.php) uses Nyholm PSR-7. Run its bounded smoke test with `php tests/Http/run.php`; it starts and cleans up a loopback-only server automatically. + +The middleware restores handlers in `finally`, including when response rendering fails. Reporters that emit no content produce the generic body `Internal Server Error`. A custom renderer may emit a safe response body. Exception text is never used as a reason phrase. Integer exception codes 400–599 become the response status; other codes become 500. Native PHP warnings are reported separately and do not automatically turn a successful request into an error response. + +## Documentation + +- [API and composition](docs/api.md): lifecycle, factories, handlers, containers and events. +- [Testing and limitations](docs/testing.md): reproducible fire tests, exact observed platforms, isolation and failure boundaries. +- [Migration from 2.0](docs/migration.md): breaking behavior and compatibility changes. +- [Static documentation site](docs/index.html): install, composition and evidence at a glance. Open the file locally, or preview the `docs/` directory with a loopback static server. + +GitHub Pages is prepared as a **manual** workflow restricted to `master`. After review and merge, an owner can enable Pages with GitHub Actions as the source and run the Documentation workflow from `master`. Nothing in the pull-request CI publishes documentation or executes PHP on Pages. + +## Confidence and boundaries + +The test suite includes isolated real PHP failures rather than only calls to mocked handlers. Child processes have memory, time and output bounds; tests check termination, delivery counts and restoration. This is evidence for the exercised cases, not a reliability guarantee for every fatal error, PHP SAPI or downstream reporter. See the [failure matrix](docs/testing.md). + ## Credits - [Felipe Sayão Lobato Abreu][link-author] @@ -25,14 +75,8 @@ Please see [License File](LICENSE) for more information. [ico-version]: https://img.shields.io/packagist/v/coisa/error-handler.svg?style=flat-square [ico-php]: https://img.shields.io/packagist/php-v/coisa/error-handler.svg?style=flat-square [ico-license]: https://img.shields.io/github/license/coisa/error-handler.svg?style=flat-square -[ico-build]: https://img.shields.io/scrutinizer/build/g/coisa/error-handler/master.svg?style=flat-square -[ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/coisa/error-handler.svg?style=flat-square -[ico-code-quality]: https://img.shields.io/scrutinizer/g/coisa/error-handler.svg?style=flat-square [ico-downloads]: https://img.shields.io/packagist/dt/coisa/error-handler.svg?style=flat-square [link-packagist]: https://packagist.org/packages/coisa/error-handler -[link-build]: https://scrutinizer-ci.com/g/coisa/error-handler -[link-scrutinizer]: https://scrutinizer-ci.com/g/coisa/error-handler/code-structure -[link-code-quality]: https://scrutinizer-ci.com/g/coisa/error-handler [link-downloads]: https://packagist.org/packages/coisa/error-handler [link-author]: https://github.com/coisa diff --git a/composer.json b/composer.json index 1b106bb..bb24d70 100644 --- a/composer.json +++ b/composer.json @@ -12,10 +12,7 @@ "config": { "sort-packages": true, "prefer-stable": true, - "optimize-autoloader": true, - "allow-plugins": { - "ocramius/package-versions": true - } + "optimize-autoloader": true }, "extra": { "laminas": { @@ -23,7 +20,8 @@ } }, "require": { - "php": "^7.4 || ^8.0", + "php": "^8.3", + "psr/container": "^1.1 || ^2.0", "psr/http-server-middleware": "^1.0", "psr/http-factory": "^1.1", "psr/event-dispatcher": "^1.0", @@ -35,37 +33,38 @@ } }, "autoload-dev": { + "exclude-from-classmap": ["tests/Fire/", "tests/Http/"], "psr-4": { "CoiSA\\ErrorHandler\\Test\\": "tests/" } }, "require-dev": { - "coisa/php-cs-fixer": "^2.1", + "friendsofphp/php-cs-fixer": "^3.95", "laminas/laminas-servicemanager": "^4.3", "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.1", "phly/phly-event-dispatcher": "^1.0", - "phpspec/prophecy": "^1.20", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "^8.5", - "psr/container": "^1.0 || ^2.0", - "squizlabs/php_codesniffer": "^3.11" + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.5 || ^13.0", + "squizlabs/php_codesniffer": "^4.0" }, "scripts": { "check": [ "@cs-check", "@sniff", "@test", - "@analyze" + "@analyze", + "@fire-test", + "@http-test" ], - "analyze": "phpstan analyze --level max ./src ./tests", + "analyze": "phpstan analyze --no-progress", "cs-check": "php-cs-fixer fix --dry-run --diff", "cs-fix": [ "php-cs-fixer fix", "phpcbf" ], "sniff": "phpcs", - "test": "phpunit --colors=always" + "test": "phpunit", + "fire-test": "@php tests/Fire/run.php", + "http-test": "@php tests/Http/run.php" } } diff --git a/composer.lock b/composer.lock index 206e4cb..8da8111 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,61 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "59661b496ce23170066e2e0589421c59", + "content-hash": "3fb4b5cf36d726d5fac54be222e04d90", "packages": [ + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, { "name": "psr/event-dispatcher", "version": "1.0.0", @@ -331,26 +384,26 @@ "packages-dev": [ { "name": "brick/varexporter", - "version": "0.5.0", + "version": "0.6.0", "source": { "type": "git", "url": "https://github.com/brick/varexporter.git", - "reference": "84b2a7a91f69aa5d079aec5a0a7256ebf2dceb6b" + "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/varexporter/zipball/84b2a7a91f69aa5d079aec5a0a7256ebf2dceb6b", - "reference": "84b2a7a91f69aa5d079aec5a0a7256ebf2dceb6b", + "url": "https://api.github.com/repos/brick/varexporter/zipball/af98bfc2b702a312abbcaff37656dbe419cec5bc", + "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": "^7.4 || ^8.0" + "php": "^8.1" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^9.3", - "psalm/phar": "5.21.1" + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "6.8.4" }, "type": "library", "autoload": { @@ -368,7 +421,7 @@ ], "support": { "issues": "https://github.com/brick/varexporter/issues", - "source": "https://github.com/brick/varexporter/tree/0.5.0" + "source": "https://github.com/brick/varexporter/tree/0.6.0" }, "funding": [ { @@ -376,30 +429,34 @@ "type": "github" } ], - "time": "2024-05-10T17:15:19+00:00" + "time": "2025-02-20T17:42:39+00:00" }, { - "name": "coisa/php-cs-fixer", - "version": "2.1.0", + "name": "clue/ndjson-react", + "version": "v1.3.0", "source": { "type": "git", - "url": "https://github.com/coisa/php-cs-fixer.git", - "reference": "60d871f30ef99c67897cfdb371440cfc0932cf02" + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/coisa/php-cs-fixer/zipball/60d871f30ef99c67897cfdb371440cfc0932cf02", - "reference": "60d871f30ef99c67897cfdb371440cfc0932cf02", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", "shasum": "" }, "require": { - "friendsofphp/php-cs-fixer": "^3.8", - "php": "^7.4 || ^8.0" + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" }, "type": "library", "autoload": { "psr-4": { - "CoiSA\\PhpCsFixer\\": "src" + "Clue\\React\\NDJson\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -408,41 +465,61 @@ ], "authors": [ { - "name": "Felipe Sayão Lobato Abreu", - "email": "github@mentor.dev.br" + "name": "Christian Lück", + "email": "christian@clue.engineering" } ], - "description": "Custom PHP CS Fixer Config", + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], "support": { - "issues": "https://github.com/coisa/php-cs-fixer/issues", - "source": "https://github.com/coisa/php-cs-fixer/tree/2.1.0" + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" }, - "time": "2022-05-28T01:44:24+00:00" + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -480,7 +557,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -490,26 +567,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -561,7 +634,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -571,13 +644,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-09-19T14:15:21+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "composer/xdebug-handler", @@ -646,40 +715,52 @@ "time": "2024-05-06T16:37:16+00:00" }, { - "name": "doctrine/annotations", - "version": "1.14.4", + "name": "ergebnis/agent-detector", + "version": "1.2.0", "source": { "type": "git", - "url": "https://github.com/doctrine/annotations.git", - "reference": "253dca476f70808a5aeed3a47cc2cc88c5cab915" + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/253dca476f70808a5aeed3a47cc2cc88c5cab915", - "reference": "253dca476f70808a5aeed3a47cc2cc88c5cab915", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", "shasum": "" }, "require": { - "doctrine/lexer": "^1 || ^2", - "ext-tokenizer": "*", - "php": "^7.1 || ^8.0", - "psr/cache": "^1 || ^2 || ^3" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { - "doctrine/cache": "^1.11 || ^2.0", - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "~1.4.10 || ^1.10.28", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7", - "vimeo/psalm": "^4.30 || ^5.14" - }, - "suggest": { - "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { - "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" + "Ergebnis\\AgentDetector\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -688,115 +769,44 @@ ], "authors": [ { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Docblock Annotations Parser", - "homepage": "https://www.doctrine-project.org/projects/annotations.html", - "keywords": [ - "annotations", - "docblock", - "parser" - ], - "support": { - "issues": "https://github.com/doctrine/annotations/issues", - "source": "https://github.com/doctrine/annotations/tree/1.14.4" - }, - "time": "2024-09-05T10:15:52+00:00" - }, - { - "name": "doctrine/deprecations", - "version": "1.1.4", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2026-05-07T08:19:07+00:00" }, { - "name": "doctrine/instantiator", - "version": "1.5.0", + "name": "evenement/evenement", + "version": "v3.0.2", "source": { "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": ">=7.0" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" + "phpunit/phpunit": "^9 || ^6" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + "Evenement\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -805,66 +815,53 @@ ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" } ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "description": "Événement is a very simple event dispatching library for PHP", "keywords": [ - "constructor", - "instantiate" + "event-dispatcher", + "event-emitter" ], "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" - } - ], - "time": "2022-12-30T00:15:36+00:00" + "time": "2023-08-08T05:53:35+00:00" }, { - "name": "doctrine/lexer", - "version": "2.1.1", + "name": "fidry/cpu-core-counter", + "version": "1.3.0", "source": { "type": "git", - "url": "https://github.com/doctrine/lexer.git", - "reference": "861c870e8b75f7c8f69c146c7f89cc1c0f1b49b6" + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/861c870e8b75f7c8f69c146c7f89cc1c0f1b49b6", - "reference": "861c870e8b75f7c8f69c146c7f89cc1c0f1b49b6", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.1 || ^8.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "^1.3", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^4.11 || ^5.21" + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Common\\Lexer\\": "src" + "Fidry\\CpuCoreCounter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -873,95 +870,81 @@ ], "authors": [ { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" } ], - "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "description": "Tiny utility to get the number of CPU cores.", "keywords": [ - "annotations", - "docblock", - "lexer", - "parser", - "php" + "CPU", + "core" ], "support": { - "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/2.1.1" + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", - "type": "tidelift" + "url": "https://github.com/theofidry", + "type": "github" } ], - "time": "2024-02-05T11:35:39+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.9.5", + "version": "v3.95.25", "source": { "type": "git", - "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "4465d70ba776806857a1ac2a6f877e582445ff36" + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/4465d70ba776806857a1ac2a6f877e582445ff36", - "reference": "4465d70ba776806857a1ac2a6f877e582445ff36", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", + "reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "shasum": "" }, "require": { - "composer/semver": "^3.2", - "composer/xdebug-handler": "^3.0.3", - "doctrine/annotations": "^1.13", + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", "php": "^7.4 || ^8.0", - "php-cs-fixer/diff": "^2.0", - "symfony/console": "^5.4 || ^6.0", - "symfony/event-dispatcher": "^5.4 || ^6.0", - "symfony/filesystem": "^5.4 || ^6.0", - "symfony/finder": "^5.4 || ^6.0", - "symfony/options-resolver": "^5.4 || ^6.0", - "symfony/polyfill-mbstring": "^1.23", - "symfony/polyfill-php80": "^1.25", - "symfony/polyfill-php81": "^1.25", - "symfony/process": "^5.4 || ^6.0", - "symfony/stopwatch": "^5.4 || ^6.0" + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "justinrainbow/json-schema": "^5.2", - "keradus/cli-executor": "^1.5", - "mikey179/vfsstream": "^1.6.10", - "php-coveralls/php-coveralls": "^2.5.2", - "php-cs-fixer/accessible-object": "^1.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1", - "phpspec/prophecy": "^1.15", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "phpunitgoodpractices/polyfill": "^1.5", - "phpunitgoodpractices/traits": "^1.9.1", - "symfony/phpunit-bridge": "^6.0", - "symfony/yaml": "^5.4 || ^6.0" + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -974,7 +957,10 @@ "autoload": { "psr-4": { "PhpCsFixer\\": "src/" - } + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -991,9 +977,15 @@ } ], "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], "support": { - "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues", - "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v3.9.5" + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" }, "funding": [ { @@ -1001,26 +993,26 @@ "type": "github" } ], - "time": "2022-07-22T08:43:51+00:00" + "time": "2026-09-08T11:11:37+00:00" }, { "name": "laminas/laminas-servicemanager", - "version": "4.3.0", + "version": "4.5.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-servicemanager.git", - "reference": "b87a792d232c006eec9787230f611c74cc57b8b6" + "reference": "11192d588876ad04ba2988984c77b4ecb5c771c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-servicemanager/zipball/b87a792d232c006eec9787230f611c74cc57b8b6", - "reference": "b87a792d232c006eec9787230f611c74cc57b8b6", + "url": "https://api.github.com/repos/laminas/laminas-servicemanager/zipball/11192d588876ad04ba2988984c77b4ecb5c771c2", + "reference": "11192d588876ad04ba2988984c77b4ecb5c771c2", "shasum": "" }, "require": { - "brick/varexporter": "^0.3.8 || ^0.4.0 || ^0.5.0", + "brick/varexporter": "^0.3.8 || ^0.4.0 || ^0.5.0 || ^0.6.0", "laminas/laminas-stdlib": "^3.19", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/container": "^1.1 || ^2.0" }, "conflict": { @@ -1031,19 +1023,17 @@ "psr/container-implementation": "^1.0 || ^2.0" }, "require-dev": { - "boesing/psalm-plugin-stringf": "^1.4", "composer/package-versions-deprecated": "^1.11.99.5", "friendsofphp/proxy-manager-lts": "^1.0.18", - "laminas/laminas-cli": "^1.8", - "laminas/laminas-coding-standard": "~3.0.0", - "laminas/laminas-container-config-test": "^1.0", - "lctrs/psalm-psr-container-plugin": "^1.9", + "laminas/laminas-cli": "^1.11", + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-container-config-test": "^1.1", "mikey179/vfsstream": "^1.6.12", - "phpbench/phpbench": "^1.3.1", - "phpunit/phpunit": "^10.5.36", - "psalm/plugin-phpunit": "^0.19.0", - "symfony/console": "^6.0", - "vimeo/psalm": "^5.26.1" + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^10.5.58", + "psalm/plugin-phpunit": "^0.19.5", + "symfony/console": "^6.4.17 || ^7.3.4", + "vimeo/psalm": "^6.13.1" }, "suggest": { "friendsofphp/proxy-manager-lts": "To handle lazy initialization of services", @@ -1080,7 +1070,7 @@ "chat": "https://laminas.dev/chat", "forum": "https://discourse.laminas.dev", "issues": "https://github.com/laminas/laminas-servicemanager/issues", - "source": "https://github.com/laminas/laminas-servicemanager/tree/4.3.0" + "source": "https://github.com/laminas/laminas-servicemanager/tree/4.5.1" }, "funding": [ { @@ -1088,34 +1078,34 @@ "type": "community_bridge" } ], - "time": "2024-11-05T00:35:06+00:00" + "time": "2026-05-12T09:53:32+00:00" }, { "name": "laminas/laminas-stdlib", - "version": "3.20.0", + "version": "3.21.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-stdlib.git", - "reference": "8974a1213be42c3e2f70b2c27b17f910291ab2f4" + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/8974a1213be42c3e2f70b2c27b17f910291ab2f4", - "reference": "8974a1213be42c3e2f70b2c27b17f910291ab2f4", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "conflict": { "zendframework/zend-stdlib": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "^3.0", - "phpbench/phpbench": "^1.3.1", - "phpunit/phpunit": "^10.5.38", - "psalm/plugin-phpunit": "^0.19.0", - "vimeo/psalm": "^5.26.1" + "laminas/laminas-coding-standard": "^3.1.0", + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" }, "type": "library", "autoload": { @@ -1147,24 +1137,24 @@ "type": "community_bridge" } ], - "time": "2024-10-29T13:46:07+00:00" + "time": "2025-10-11T18:13:12+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -1199,32 +1189,31 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -1239,7 +1228,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -1263,9 +1252,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "nyholm/psr7", @@ -1345,72 +1334,6 @@ ], "time": "2024-09-09T07:06:30+00:00" }, - { - "name": "nyholm/psr7-server", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/Nyholm/psr7-server.git", - "reference": "4335801d851f554ca43fa6e7d2602141538854dc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", - "reference": "4335801d851f554ca43fa6e7d2602141538854dc", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "require-dev": { - "nyholm/nsa": "^1.1", - "nyholm/psr7": "^1.3", - "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Nyholm\\Psr7Server\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - }, - { - "name": "Martijn van der Ven", - "email": "martijn@vanderven.se" - } - ], - "description": "Helper classes to handle PSR-7 server requests", - "homepage": "http://tnyholm.se", - "keywords": [ - "psr-17", - "psr-7" - ], - "support": { - "issues": "https://github.com/Nyholm/psr7-server/issues", - "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" - }, - "funding": [ - { - "url": "https://github.com/Zegnat", - "type": "github" - }, - { - "url": "https://github.com/nyholm", - "type": "github" - } - ], - "time": "2023-11-08T09:30:43+00:00" - }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -1531,20 +1454,20 @@ }, { "name": "phly/phly-event-dispatcher", - "version": "1.5.0", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/phly/phly-event-dispatcher.git", - "reference": "0a1b0aa21ace0f6f6736db71cdd3a3580fd9f481" + "reference": "09b86c00d16e34b5d33123191cb613307ff54be7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phly/phly-event-dispatcher/zipball/0a1b0aa21ace0f6f6736db71cdd3a3580fd9f481", - "reference": "0a1b0aa21ace0f6f6736db71cdd3a3580fd9f481", + "url": "https://api.github.com/repos/phly/phly-event-dispatcher/zipball/09b86c00d16e34b5d33123191cb613307ff54be7", + "reference": "09b86c00d16e34b5d33123191cb613307ff54be7", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0" }, @@ -1588,809 +1511,1097 @@ "rss": "https://github.com/phly/phly-event-dispatcher/releases.atom", "source": "https://github.com/phly/phly-event-dispatcher" }, - "time": "2024-11-25T21:57:14+00:00" + "time": "2026-09-09T16:22:59+00:00" }, { - "name": "php-cs-fixer/diff", - "version": "v2.0.2", - "source": { - "type": "git", - "url": "https://github.com/PHP-CS-Fixer/diff.git", - "reference": "29dc0d507e838c4580d018bd8b5cb412474f7ec3" - }, + "name": "phpstan/phpstan", + "version": "2.2.14", "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/diff/zipball/29dc0d507e838c4580d018bd8b5cb412474f7ec3", - "reference": "29dc0d507e838c4580d018bd8b5cb412474f7ec3", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0 || ^8.0" + "php": "^7.4|^8.0" }, - "require-dev": { - "phpunit/phpunit": "^5.7.23 || ^6.4.3 || ^7.0", - "symfony/process": "^3.3" + "conflict": { + "phpstan/phpstan-shim": "*" }, + "bin": [ + "phpstan", + "phpstan.phar" + ], "type": "library", "autoload": { - "classmap": [ - "src/" + "files": [ + "bootstrap.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Ondřej Mirtes" }, { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" } ], - "description": "sebastian/diff v3 backport support for PHP 5.6+", - "homepage": "https://github.com/PHP-CS-Fixer", + "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ - "diff" + "dev", + "static analysis" ], "support": { - "issues": "https://github.com/PHP-CS-Fixer/diff/issues", - "source": "https://github.com/PHP-CS-Fixer/diff/tree/v2.0.2" + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" }, - "abandoned": true, - "time": "2020-10-14T08:32:19+00:00" + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-09-12T21:39:33+00:00" }, { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", + "name": "phpunit/php-code-coverage", + "version": "14.3.3", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "f8640c238e930b914d0098a1edbad6652e61a64a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f8640c238e930b914d0098a1edbad6652e61a64a", + "reference": "f8640c238e930b914d0098a1edbad6652e61a64a", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.2", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.3" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { - "dev-2.x": "2.x-dev" + "dev-main": "14.3.x-dev" } }, "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" + "coverage", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.3" }, - "time": "2020-06-27T09:03:43+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-09-10T13:08:32+00:00" }, { - "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "name": "phpunit/php-file-iterator", + "version": "7.0.2", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "php": ">=8.4" }, "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "phpunit/phpunit": "^13.3.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.x-dev" + "dev-main": "7.0-dev" } }, "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2" }, - "time": "2024-12-07T09:39:29+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:47:43+00:00" }, { - "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "name": "phpunit/php-invoker", + "version": "7.0.0", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "php": ">=8.4" }, "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-main": "7.0-dev" } }, "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, - "time": "2024-11-09T15:12:26+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" }, { - "name": "phpspec/prophecy", - "version": "v1.20.0", + "name": "phpunit/php-text-template", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/phpspec/prophecy.git", - "reference": "a0165c648cab6a80311c74ffc708a07bb53ecc93" + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/a0165c648cab6a80311c74ffc708a07bb53ecc93", - "reference": "a0165c648cab6a80311c74ffc708a07bb53ecc93", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.2 || ^2.0", - "php": "^7.2 || 8.0.* || 8.1.* || 8.2.* || 8.3.* || 8.4.*", - "phpdocumentor/reflection-docblock": "^5.2", - "sebastian/comparator": "^3.0 || ^4.0 || ^5.0 || ^6.0", - "sebastian/recursion-context": "^3.0 || ^4.0 || ^5.0 || ^6.0" + "php": ">=8.4" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.40", - "phpspec/phpspec": "^6.0 || ^7.0", - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-main": "6.0-dev" } }, "autoload": { - "psr-4": { - "Prophecy\\": "src/Prophecy" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Konstantin Kudryashov", - "email": "ever.zet@gmail.com", - "homepage": "http://everzet.com" - }, - { - "name": "Marcello Duarte", - "email": "marcello.duarte@gmail.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Highly opinionated mocking framework for PHP 5.3+", - "homepage": "https://github.com/phpspec/prophecy", + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "Double", - "Dummy", - "dev", - "fake", - "mock", - "spy", - "stub" + "template" ], "support": { - "issues": "https://github.com/phpspec/prophecy/issues", - "source": "https://github.com/phpspec/prophecy/tree/v1.20.0" + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, - "time": "2024-11-19T13:12:41+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.0.0", + "name": "phpunit/php-timer", + "version": "9.0.0", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/c00d78fb6b29658347f9d37ebe104bffadf36299", - "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": ">=8.4" }, "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" + "phpunit/phpunit": "^13.0" }, "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.0.0" + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, - "time": "2024-10-13T11:29:49+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" }, { - "name": "phpstan/phpstan", - "version": "1.12.13", + "name": "phpunit/phpunit", + "version": "13.3.4", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "d19d90cf860efb7e4d96cee8243e06a248578085" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d19d90cf860efb7e4d96cee8243e06a248578085", + "reference": "d19d90cf860efb7e4d96cee8243e06a248578085", "shasum": "" }, "require": { - "php": "^7.2|^8.0" - }, - "conflict": { - "phpstan/phpstan-shim": "*" + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.3.3", + "phpunit/php-file-iterator": "^7.0.2", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.1", + "sebastian/comparator": "^8.4", + "sebastian/diff": "^9.0.1", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.2.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.1.0", + "sebastian/recursion-context": "^8.0.1", + "sebastian/type": "^7.0.2", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ - "phpstan", - "phpstan.phar" + "phpunit" ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.3-dev" + } + }, "autoload": { "files": [ - "bootstrap.php" + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "PHPStan - PHP Static Analysis Tool", + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", "keywords": [ - "dev", - "static analysis" + "phpunit", + "testing", + "xunit" ], "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.4" }, "funding": [ { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", - "type": "github" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-12-17T17:00:20+00:00" + "time": "2026-09-15T07:37:05+00:00" }, { - "name": "phpstan/phpstan-strict-rules", - "version": "1.6.1", + "name": "react/cache", + "version": "v1.2.0", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "daeec748b53de80a97498462513066834ec28f8b" + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/daeec748b53de80a97498462513066834ec28f8b", - "reference": "daeec748b53de80a97498462513066834ec28f8b", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.12.4" + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" }, "require-dev": { - "nikic/php-parser": "^4.13.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5" - }, - "type": "phpstan-extension", - "extra": { - "phpstan": { - "includes": [ - "rules.neon" - ] - } + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" }, + "type": "library", "autoload": { "psr-4": { - "PHPStan\\": "src/" + "React\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Extra strict and opinionated rules for PHPStan", + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], "support": { - "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.1" + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" }, - "time": "2024-09-20T14:04:44+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "7.0.17", + "name": "react/child-process", + "version": "v0.6.7", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "40a4ed114a4aea5afd6df8d0f0c9cd3033097f66" + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/40a4ed114a4aea5afd6df8d0f0c9cd3033097f66", - "reference": "40a4ed114a4aea5afd6df8d0f0c9cd3033097f66", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-xmlwriter": "*", - "php": ">=7.2", - "phpunit/php-file-iterator": "^2.0.2", - "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^3.1.3 || ^4.0", - "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^4.2.2", - "sebastian/version": "^2.0.1", - "theseer/tokenizer": "^1.1.3" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^8.2.2" - }, - "suggest": { - "ext-xdebug": "^2.7.2" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "7.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\ChildProcess\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "description": "Event-driven library for executing child processes with ReactPHP.", "keywords": [ - "coverage", - "testing", - "xunit" + "event-driven", + "process", + "reactphp" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/7.0.17" + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2024-03-02T06:09:37+00:00" + "time": "2025-12-23T15:25:20+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "2.0.6", + "name": "react/dns", + "version": "v1.14.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "69deeb8664f611f156a924154985fbd4911eb36b" + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/69deeb8664f611f156a924154985fbd4911eb36b", - "reference": "69deeb8664f611f156a924154985fbd4911eb36b", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^8.5" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\EventLoop\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", "keywords": [ - "filesystem", - "iterator" + "asynchronous", + "event-loop" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/2.0.6" + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2024-03-01T13:39:50+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { - "name": "phpunit/php-text-template", - "version": "1.2.1", + "name": "react/promise", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "description": "A lightweight implementation of CommonJS Promises/A for PHP", "keywords": [ - "template" + "promise", + "promises" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1" + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, - "time": "2015-06-21T13:50:34+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" }, { - "name": "phpunit/php-timer", - "version": "2.1.4", + "name": "react/socket", + "version": "v1.17.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "a691211e94ff39a34811abd521c31bd5b305b0bb" + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a691211e94ff39a34811abd521c31bd5b305b0bb", - "reference": "a691211e94ff39a34811abd521c31bd5b305b0bb", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", "shasum": "" }, "require": { - "php": ">=7.1" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^8.5" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.1-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Socket\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", "keywords": [ - "timer" + "Connection", + "Socket", + "async", + "reactphp", + "stream" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/2.1.4" + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2024-03-01T13:42:41+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { - "name": "phpunit/php-token-stream", - "version": "4.0.4", + "name": "react/stream", + "version": "v1.4.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3" + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/a853a0e183b9db7eed023d7933a858fa1c8d25a3", - "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": "^7.3 || ^8.0" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Stream\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Wrapper around PHP's tokenizer extension.", - "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", "keywords": [ - "tokenizer" + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-token-stream/issues", - "source": "https://github.com/sebastianbergmann/php-token-stream/tree/master" + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "abandoned": true, - "time": "2020-08-04T08:28:15+00:00" + "time": "2024-06-11T12:45:25+00:00" }, { - "name": "phpunit/phpunit", - "version": "8.5.41", + "name": "sebastian/cli-parser", + "version": "5.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d843cb5bcf0bf9ae3484016444fe0c5b6ec7e4fa" + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d843cb5bcf0bf9ae3484016444fe0c5b6ec7e4fa", - "reference": "d843cb5bcf0bf9ae3484016444fe0c5b6ec7e4fa", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", - "phar-io/manifest": "^2.0.4", - "phar-io/version": "^3.2.1", - "php": ">=7.2", - "phpunit/php-code-coverage": "^7.0.17", - "phpunit/php-file-iterator": "^2.0.6", - "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.1.4", - "sebastian/comparator": "^3.0.5", - "sebastian/diff": "^3.0.6", - "sebastian/environment": "^4.2.5", - "sebastian/exporter": "^3.1.6", - "sebastian/global-state": "^3.0.5", - "sebastian/object-enumerator": "^3.0.5", - "sebastian/resource-operations": "^2.0.3", - "sebastian/type": "^1.1.5", - "sebastian/version": "^2.0.1" + "php": ">=8.4" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage", - "phpunit/php-invoker": "To allow enforcing time limits" + "require-dev": { + "phpunit/phpunit": "^13.2.6" }, - "bin": [ - "phpunit" - ], "type": "library", "extra": { "branch-alias": { - "dev-master": "8.5-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -2409,160 +2620,220 @@ "role": "lead" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.41" + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" }, "funding": [ - { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, { "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", "type": "tidelift" } ], - "time": "2024-12-05T13:44:26+00:00" + "time": "2026-08-01T04:27:14+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "sebastian/comparator", + "version": "8.4.0", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^13.3" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "8.4-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" } ], - "description": "Common interface for caching libraries", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ - "cache", - "psr", - "psr-6" + "comparator", + "compare", + "equality" ], "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-08-07T07:23:13+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "sebastian/complexity", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { - "php": ">=7.4.0" + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "6.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "1.0.3", + "name": "sebastian/diff", + "version": "9.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54" + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54", - "reference": "92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", "shasum": "" }, "require": { - "php": ">=5.6" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^8.5" + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -2578,48 +2849,72 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/1.0.3" + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2024-03-01T13:45:45+00:00" + "time": "2026-08-25T15:38:55+00:00" }, { - "name": "sebastian/comparator", - "version": "3.0.5", + "name": "sebastian/environment", + "version": "9.3.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "1dc7ceb4a24aede938c7af2a9ed1de09609ca770" + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1dc7ceb4a24aede938c7af2a9ed1de09609ca770", - "reference": "1dc7ceb4a24aede938c7af2a9ed1de09609ca770", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", "shasum": "" }, "require": { - "php": ">=7.1", - "sebastian/diff": "^3.0", - "sebastian/exporter": "^3.1" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^8.5" + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "9.3-dev" } }, "autoload": { @@ -2635,64 +2930,66 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ - "comparator", - "compare", - "equality" + "Xdebug", + "environment", + "hhvm" ], "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/3.0.5" + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2022-09-14T12:31:48+00:00" + "time": "2026-05-25T13:41:38+00:00" }, { - "name": "sebastian/diff", - "version": "3.0.6", + "name": "sebastian/exporter", + "version": "8.2.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "98ff311ca519c3aa73ccd3de053bdb377171d7b6" + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/98ff311ca519c3aa73ccd3de053bdb377171d7b6", - "reference": "98ff311ca519c3aa73ccd3de053bdb377171d7b6", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.0", - "symfony/process": "^2 || ^3.3 || ^4" + "phpunit/phpunit": "^13.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "8.2-dev" } }, "autoload": { @@ -2710,57 +3007,77 @@ "email": "sebastian@phpunit.de" }, { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "export", + "exporter" ], "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/3.0.6" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:16:36+00:00" + "time": "2026-08-07T07:22:06+00:00" }, { - "name": "sebastian/environment", - "version": "4.2.5", + "name": "sebastian/file-filter", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "56932f6049a0482853056ffd617c91ffcc754205" + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/56932f6049a0482853056ffd617c91ffcc754205", - "reference": "56932f6049a0482853056ffd617c91ffcc754205", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^7.5" - }, - "suggest": { - "ext-posix": "*" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-main": "1.0-dev" } }, "autoload": { @@ -2775,54 +3092,61 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/4.2.5" + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" } ], - "time": "2024-03-01T13:49:59+00:00" + "time": "2026-04-22T07:20:04+00:00" }, { - "name": "sebastian/exporter", - "version": "3.1.6", + "name": "sebastian/git-state", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "1939bc8fd1d39adcfa88c5b35335910869214c56" + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/1939bc8fd1d39adcfa88c5b35335910869214c56", - "reference": "1939bc8fd1d39adcfa88c5b35335910869214c56", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", "shasum": "" }, "require": { - "php": ">=7.2", - "sebastian/recursion-context": "^3.0" + "php": ">=8.4" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^8.5" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-main": "1.0-dev" } }, "autoload": { @@ -2837,73 +3161,64 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "http://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/3.1.6" + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:21:38+00:00" + "time": "2026-03-21T12:54:28+00:00" }, { "name": "sebastian/global-state", - "version": "3.0.5", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "91c7c47047a971f02de57ed6f040087ef110c5d9" + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/91c7c47047a971f02de57ed6f040087ef110c5d9", - "reference": "91c7c47047a971f02de57ed6f040087ef110c5d9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "shasum": "" }, "require": { - "php": ">=7.2", - "sebastian/object-reflector": "^1.1.1", - "sebastian/recursion-context": "^3.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^8.0" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^13.1.13" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -2922,48 +3237,60 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/3.0.5" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:13:16+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "3.0.5", + "name": "sebastian/lines-of-code", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "ac5b293dba925751b808e02923399fb44ff0d541" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/ac5b293dba925751b808e02923399fb44ff0d541", - "reference": "ac5b293dba925751b808e02923399fb44ff0d541", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "php": ">=7.0", - "sebastian/object-reflector": "^1.1.1", - "sebastian/recursion-context": "^3.0" + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0.x-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -2978,47 +3305,62 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/3.0.5" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2024-03-01T13:54:02+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { - "name": "sebastian/object-reflector", - "version": "1.1.3", + "name": "sebastian/object-enumerator", + "version": "8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "1d439c229e61f244ff1f211e5c99737f90c67def" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/1d439c229e61f244ff1f211e5c99737f90c67def", - "reference": "1d439c229e61f244ff1f211e5c99737f90c67def", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576", "shasum": "" }, "require": { - "php": ">=7.0" + "php": ">=8.4", + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -3036,44 +3378,57 @@ "email": "sebastian@phpunit.de" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/1.1.3" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2024-03-01T13:56:04+00:00" + "time": "2026-08-13T07:05:05+00:00" }, { - "name": "sebastian/recursion-context", - "version": "3.0.2", + "name": "sebastian/object-reflector", + "version": "6.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "9bfd3c6f1f08c026f542032dfb42813544f7d64c" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/9bfd3c6f1f08c026f542032dfb42813544f7d64c", - "reference": "9bfd3c6f1f08c026f542032dfb42813544f7d64c", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2", "shasum": "" }, "require": { - "php": ">=7.0" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0.x-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3089,51 +3444,59 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2024-03-01T14:07:30+00:00" + "time": "2026-08-13T06:34:36+00:00" }, { - "name": "sebastian/resource-operations", - "version": "2.0.3", + "name": "sebastian/recursion-context", + "version": "8.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "72a7f7674d053d548003b16ff5a106e7e0e06eee" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/72a7f7674d053d548003b16ff5a106e7e0e06eee", - "reference": "72a7f7674d053d548003b16ff5a106e7e0e06eee", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -3149,45 +3512,67 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/2.0.3" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-03-01T13:59:09+00:00" + "time": "2026-08-03T05:58:12+00:00" }, { "name": "sebastian/type", - "version": "1.1.5", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "18f071c3a29892b037d35e6b20ddf3ea39b42874" + "reference": "bd1df467864cb95140414059a535b2d906173fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/18f071c3a29892b037d35e6b20ddf3ea39b42874", - "reference": "18f071c3a29892b037d35e6b20ddf3ea39b42874", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf", + "reference": "bd1df467864cb95140414059a535b2d906173fcf", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^8.2" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -3210,37 +3595,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/1.1.5" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-03-01T14:04:07+00:00" + "time": "2026-08-10T08:00:57+00:00" }, { "name": "sebastian/version", - "version": "2.0.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", - "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=5.6" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -3263,43 +3661,62 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/master" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, - "time": "2016-10-03T07:35:21+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.2", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079" + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1368f4a58c3c52114b86b1abe8f4098869cb0079", - "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", "shasum": "" }, "require": { + "ext-libxml": "*", "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "php": ">=7.2.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." }, "bin": [ "bin/phpcbf", "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -3318,7 +3735,7 @@ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", @@ -3341,55 +3758,113 @@ "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T02:45:27+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" } ], - "time": "2024-12-11T16:04:26+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/console", - "version": "v6.4.15", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd" + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", + "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^7.4.6|^8.0.6" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3423,7 +3898,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.15" + "source": "https://github.com/symfony/console/tree/v8.1.7" }, "funding": [ { @@ -3434,25 +3909,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2026-09-13T10:55:57+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3460,12 +3939,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -3490,7 +3969,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3501,33 +3980,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.13", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -3536,13 +4020,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3570,7 +4055,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -3581,25 +4066,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -3608,12 +4097,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -3646,7 +4135,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -3657,34 +4146,39 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.13", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/4856c9cf585d5a0313d8d35afd681a526f038dd3", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3712,7 +4206,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.13" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -3723,32 +4217,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", - "version": "v6.4.13", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958" + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/daea9eca0b08d0ed1dc9ab702a46128fd1be4958", - "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958", + "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.4.1" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3776,7 +4274,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.13" + "source": "https://github.com/symfony/finder/tree/v8.1.7" }, "funding": [ { @@ -3787,29 +4285,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-01T08:30:56+00:00" + "time": "2026-09-10T19:14:39+00:00" }, { "name": "symfony/options-resolver", - "version": "v6.4.16", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "368128ad168f20e22c32159b9f761e456cec0c78" + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/368128ad168f20e22c32159b9f761e456cec0c78", - "reference": "368128ad168f20e22c32159b9f761e456cec0c78", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -3843,7 +4345,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.4.16" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { @@ -3854,25 +4356,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-20T10:57:02+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -3922,7 +4428,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -3933,25 +4439,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -4000,7 +4510,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -4011,25 +4521,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -4081,7 +4595,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -4092,28 +4606,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -4161,7 +4680,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4172,25 +4691,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -4241,7 +4764,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4252,25 +4775,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -4317,7 +4844,167 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -4328,29 +5015,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/process", - "version": "v6.4.15", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "3cb242f059c14ae08591c5c4087d1fe443564392" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392", - "reference": "3cb242f059c14ae08591c5c4087d1fe443564392", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -4378,7 +5069,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.15" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -4389,25 +5080,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -4420,12 +5115,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -4461,7 +5156,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -4472,29 +5167,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stopwatch", - "version": "v6.4.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "2cae0a6f8d04937d02f6d19806251e2104d54f92" + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/2cae0a6f8d04937d02f6d19806251e2104d54f92", - "reference": "2cae0a6f8d04937d02f6d19806251e2104d54f92", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.4.1", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -4523,7 +5222,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v6.4.13" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -4534,44 +5233,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4610,7 +5312,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -4621,32 +5323,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -4668,7 +5374,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -4676,65 +5382,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" - }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], @@ -4743,8 +5391,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.2 || ^8.0" + "php": "^8.3" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index b9f2173..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: '3.7' -services: - composer: - volumes: - - ./:${APP_DIR:-/app} - - phpunit: - volumes: - - ./:${APP_DIR:-/app} diff --git a/docker-compose.yml b/docker-compose.yml index 0bdcd19..f27f3e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,11 @@ -version: '3.7' services: - composer: + tests: build: context: . dockerfile: Dockerfile args: - APP_DIR: ${APP_DIR:-/app} - APP_VERSION: ${APP_VERSION:-latest} - image: coisa/error-handler:${APP_VERSION:-latest} - entrypoint: composer - - phpunit: - depends_on: - - composer - image: coisa/error-handler:${APP_VERSION:-latest} - command: composer test + PHP_VERSION: ${PHP_VERSION:-8.3} + network_mode: none + mem_limit: 512m + cpus: 2 + pids_limit: 64 diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..1de5fff --- /dev/null +++ b/docs/api.md @@ -0,0 +1,62 @@ +# API and composition + +All classes are under `CoiSA\ErrorHandler`. Handler interfaces remain small so reporting, application termination, HTTP rendering and event dispatch can be composed explicitly. + +## Lifecycle + +```php +$handler = new ErrorHandler($reporter, $optionalPhpErrorHandler, $optionalShutdownHandler); +$handler->register(); +try { + // Work that must return or propagate to a caller. +} finally { + $handler->unregister(); +} +``` + +Use this scoped form for code whose caller handles propagated exceptions. For process-wide uncaught exceptions, register at application bootstrap and keep the handler active through termination, as in [`examples/cli.php`](../examples/cli.php). PHP executes `finally` before dispatching an uncaught exception, so unregistering there intentionally returns responsibility to the previous exception handler. + +Registration pushes PHP callbacks onto the native stacks. Previous callbacks are restored, including their error-level masks, when unregistered in reverse order. They are **not automatically called** while this handler is active. A second registration of the same instance is a no-op, not a nested reference count. Shutdown registration happens once per instance because PHP provides no shutdown-unregister function; inactive instances do nothing at shutdown. Reuse an instance when practical: PHP retains registered shutdown callbacks until process exit. + +Only the instance owning both current PHP callbacks receives shutdown events. If another integration replaces a callback, restore it before unregistering this instance. Otherwise `unregister()` rejects the operation without altering either stack. + +| API | Behavior | +| --- | --- | +| `ErrorHandlerInterface::register(): void` | Installs error, exception and shutdown handling | +| `ErrorHandlerInterface::unregister(): void` | Restores previous error/exception callbacks; deactivates shutdown | +| `ThrowableHandlerInterface::handleThrowable(Throwable): void` | Reports the original object; a direct call does not exit | +| `PhpErrorHandlerInterface::handlePhpError(int, string, string, int): void` | Processes a PHP error with severity, message, file and line | +| `ShutdownHandlerInterface::handleShutdown(): void` | Handles a shutdown notification | + +`ErrorHandler` defaults to `ThrowErrorExceptionPhpErrorHandler` and `PhpLastErrorShutdownHandler`. The former respects the current `error_reporting()` bitmask (including `@`) and converts reportable errors to the package's `ErrorException`. The outer `ErrorHandler` delivers that exception and then resumes normal execution for recoverable warnings. The shutdown handler inspects `error_get_last()` for `E_ERROR`, `E_PARSE`, `E_CORE_ERROR` or `E_COMPILE_ERROR`; it does not turn fatal termination into recovery. + +A reporter that throws is marked failed and is not retried during the same registration. Recursive calls through the same `ErrorHandler` raise `LogicException`. Direct calls propagate reporter failures; uncaught reporter failures fall back to PHP's native failure path. `unregister()` followed by `register()` begins a fresh lifecycle. The library does not choose an alternative logger or retry remote delivery. + +## Reporter composition and PSR-14 + +`CallableThrowableHandler` adapts `callable(Throwable): void`. `ThrowableHandlerAggregate` accepts variadic reporters and supports `attach()`; it calls them in attachment order with the same throwable object. It stops if a reporter throws. Use a custom composite when independent best-effort reporting is required. + +`DispatchThrowableHandler` dispatches the throwable itself through a PSR-14 dispatcher. `DispatchErrorEventThrowableHandler` dispatches an `ErrorEvent` containing it. `ThrowableListenerProvider` and `ErrorEventListenerProvider` select compatible events; the callable listeners adapt application callbacks. `LogErrorEventListener` forwards an error event to a PSR-3 logger. Event stringification contains exception details and is intended for private diagnostic sinks, not untrusted HTTP output. + +## PSR-15/PSR-17 HTTP + +```php +$streams = new ThrowableStreamFactory($psr17Factory, $reporter); +$responses = new ThrowableResponseFactory($streams, $psr17Factory); +$middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); +$response = $middleware->process($serverRequest, $requestHandler); +``` + +Use the `Http\Message` and `Http\Middleware` namespaces for these classes. The PSR-17 factory must implement both stream and response factories. A complete runnable composition is in [`tests/Http/router.php`](../tests/Http/router.php). + +The middleware returns normal downstream responses unchanged and catches downstream `Throwable` objects to generate an error response. It always unregisters its handler after processing. Use a distinct handler instance for each nested middleware scope; do not share/pre-register one instance across simultaneous requests or fibers. PHP's global callback stacks are process state, not request-local storage. + +`ThrowableStreamFactory` captures reporter output. Empty output maps to `Internal Server Error`; `0` is a valid body. The factory cleans buffers it opens if rendering fails. Renderers must balance their own successful nested buffers and must not close the caller's buffers. `ThrowableResponseFactory` keeps integer codes 400–599, uses 500 otherwise, and lets the PSR-17 factory supply the standard reason phrase. + +Neither the middleware nor the global handler emits PSR-7 responses to a server. The application/framework owns emission, headers, content type and transport. A PHP engine fatal error bypasses normal middleware recovery; shutdown reporting cannot reliably replace an already-started HTTP response. + +## PSR-11 container integration + +`Container\ConfigProvider` exposes `__invoke()`, `getDependencies()`, `getAliases()` and `getFactories()` for compatible containers such as Laminas ServiceManager. `ErrorHandlerContainer` can wrap a parent PSR-11 container, delegates known services to it, and caches services built by the library factories. + +Provide `ThrowableHandlerInterface` for a custom reporter. Otherwise `ThrowableHandlerAggregateFactory` composes any available callable/event-dispatch reporters; an empty aggregate intentionally produces no diagnostics. HTTP factories also require PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface` services. PSR-14 integration needs `EventDispatcherInterface`. These implementations belong to the application; the package ships interfaces and adapters, not a logger, event bus or HTTP framework. diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..e8ba674 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,81 @@ + + + + + + + CoiSA Error Handler — Make failure observable + + + +
{!}CoiSA / Error HandlerView source ↗
+
+ +
+
+
A small PHP library. An explicit failure contract.
+

Make failure
observable.

+

Connect PHP warnings, uncaught exceptions and shutdown errors to the reporting tools your application already uses.

+
PHP 8.3–8.5PSR-11 · PSR-14 · PSR-15MIT license
+ +
Next major · unreleased. This guide describes the modernization branch. Published version 2.0.0 has different compatibility and behavior. Review the migration guide before adoption.
+
+
+
01 / Reproduce it

Install the development checkout.

+

Use PHP 8.5 and Composer 2 for the checked-in dependency lock. Check out the pull-request branch when reviewing unmerged changes.

+
TERMINAL · DEVELOPMENT CHECKOUT
git clone https://github.com/coisa/error-handler.git
+cd error-handler
+composer install --no-plugins --no-scripts
+composer --no-plugins check
+php examples/cli.php
+

The example reports a warning, then an uncaught exception, and deliberately exits with status 1. On PHP 8.3 or 8.4, use composer update --no-plugins --no-scripts first to resolve compatible development tools.

+

composer require coisa/error-handler installs the published Packagist release; it does not select this unreleased work.

+
+
+
02 / Three useful boundaries

Keep reporting composable.

+
01 — CAPTURE

PHP callbacks

Register for errors, uncaught throwables and shutdown. Respect suppression and restore earlier handlers.

02 — REPORT

Your reporter

Adapt a callable, compose a sequence of reporters, or dispatch a PSR-14 event to your own listeners.

03 — RESPOND

HTTP middleware

Use PSR-15 and PSR-17 to turn a downstream throwable into a response with a controlled public body.

+
PHP · LOAD COMPOSER AUTOLOAD FIRST
use CoiSA\ErrorHandler\ErrorHandler;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+
+$reporter = new CallableThrowableHandler(
+    static function (Throwable $error): void {
+        error_log($error->getMessage());
+    }
+);
+
+$errors = new ErrorHandler($reporter);
+$errors->register();
+

Keep diagnostic output in an appropriate private sink. A reporter that emits nothing produces the generic HTTP body Internal Server Error.

+
+
+
03 / Observable behavior

Know what each boundary does.

+
Input or operationResult
Reportable warningOne error exception; execution continues.
@ or an excluded severityThe default reporter does not receive the warning.
Uncaught throwableReport the original object; exit 1 when reporting returns.
Direct handleThrowable()Report and return; the caller controls execution.
Nested registrationsUnwind in reverse order; only the active handler reports shutdown.
Reporter failure or recursionStop retrying that reporter and propagate the failure.
HTTP error responsePreserve integer codes 400–599; otherwise use 500. No exception text in the reason phrase.
+

Read the complete API and composition guide ↗

+
+
+
04 / Exercise the failure path

A test should survive the crash.

+
20ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, registration cycles and reporter recursion run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

+

The runner checks delivery counts, ordering, throwable classes, stderr and exact exit status. Separate integration tests exercise PSR composition and real HTTP 500/503 responses through a loopback-only server.

+
RUN THE EVIDENCE YOURSELF
php tests/Fire/run.php
+composer --no-plugins test
+php tests/Http/run.php
+ +
+
+
05 / Move deliberately

Upgrade as a major release.

+
  • Raise the runtime to PHP 8.3 or newer.
  • Expect failure exit status 1 after a reported uncaught exception.
  • Unregister handlers in reverse order; use distinct instances for nested middleware.
  • Review the generic HTTP fallback and standard reason phrases.
  • Replace the old PHP 7.2 development container with the current Compose test service.
+

Read the 2.0 migration guide ↗

+
+
+
06 / What this evidence means

Reporting is not recovery.

+

A shutdown callback cannot rescue startup errors, a killed process or every real-world out-of-memory condition. It does not restart the application or guarantee delivery to a remote logger.

+

Local evidence covers macOS and Linux arm64 CLI runtimes, plus PHP's built-in HTTP server. PHP-FPM, Apache, Windows, concurrent request runtimes and production load need application-specific validation. Use the recorded platform details and the current commit's CI results.

+
+
CoiSA Error Handler · Felipe Sayão Lobato Abreu · MIT
Static documentation. PHP execution belongs in your application, not on GitHub Pages.
+
+
+ + diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..166f184 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,14 @@ +# Migrating from 2.0 + +This is the migration contract for the unreleased next major. It does not describe an already published Packagist release. + +1. Upgrade the application to PHP 8.3, 8.4 or 8.5. Resolve Composer dependencies again. The checked-in development lock targets PHP 8.5; developers on 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible tools. Consumers resolve their own lock as usual. +2. Expect exit status **1** after a globally uncaught `Throwable` is reported successfully. Version 2 could exit with status 0. A reporter that explicitly terminates the process still controls its own exit code. Direct `handleThrowable()` calls do not exit. +3. Pair `register()` and `unregister()` in reverse order. A repeated `register()` on the same instance is idempotent, not reference counted. Out-of-order unregistration now throws `LogicException` instead of silently removing someone else's callback. Use separate instances for nested middleware and do not pre-register the middleware's own instance. +4. A reporter must not call its own `ErrorHandler` recursively. After a reporter throws, that instance stops retrying it until it is unregistered and registered again. The failure propagates to the caller or PHP; it is not silently swallowed. Only the active registration receives shutdown notifications, preventing duplicates across nested handlers. +5. Review custom HTTP rendering. Empty output now produces `Internal Server Error`, instead of a stringified exception and stack trace. The string `0` is preserved. Exception messages no longer become HTTP reason phrases. Codes 400–599 map to their matching status; other integer codes and string SQLSTATE codes map to 500. +6. If you used the old PHP 7.2 Docker setup, replace the two-service invocation with `docker compose run --rm --build tests`. It defaults to PHP 8.3 and resolves dependencies in the image, without using your host `vendor/`. + +Public handler and factory interfaces retain their names and method signatures. The PHP floor, CLI exit semantics, lifecycle rejection and HTTP output defaults warrant a major release even though most integration code can remain unchanged. + +Before upgrading a real application, run the [documented checks](testing.md) and application-level tests for the actual reporter, middleware stack and PHP SAPI. These isolated fixtures do not establish reliability of third-party loggers, remote services or production infrastructure. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b728bcc --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,78 @@ +# Reproducing the failure tests + +The suite deliberately causes errors in **child processes**, never in PHPUnit or a host application. Fixtures contain only synthetic data. No external service or credential is used. + +## Commands + +```sh +# Reference development lock: PHP 8.5 + Composer 2. +composer install --no-plugins --no-scripts --no-interaction --prefer-dist +composer --no-plugins check + +# On PHP 8.3 or 8.4, first resolve compatible development dependencies. +composer update --no-plugins --no-scripts --no-interaction --prefer-dist +composer --no-plugins check + +# Core crash tests need PHP only; no Composer dependencies or extensions. +php tests/Fire/run.php + +# PSR integration and real HTTP responses. +composer --no-plugins test +php tests/Http/run.php +``` + +The checks run PSR-12 formatting (with the existing declare-before-license convention), PHP_CodeSniffer, PHPUnit, PHPStan at maximum level over `src/`, crash tests and loopback HTTP smoke tests. Deliberately invalid `.inc` crash fixtures are excluded from lint and autoload classmaps. PHPUnit restores its own error handler between tests; the PHP-warning integration case explicitly disables PHPUnit's error handler so the package receives the real warning. + +## Fire-test contract + +Every child uses `php -n`, a 16 MiB PHP memory limit, a five-second wall-clock deadline and a 256 KiB combined output cap. The parent captures stdout, stderr, exact exit status, event ordering, event counts, throwable class and applicable severity. Exceeding a limit terminates that child and fails the suite. A native fatal diagnostic on stderr is expected only in the scenarios that explicitly exercise it. + +| Cases | Expected observation | +| --- | --- | +| User/native warnings | One `ErrorException`; execution continues, exit 0 | +| `error_reporting()` and `@` | Suppressed warnings are not delivered | +| Uncaught exception, `Error`, `TypeError`, included parse error | One original throwable, exit 1 | +| Compile fatal, nested-handler compile fatal | One active-handler delivery, native diagnostic, exit 255 | +| Controlled allocation failure | One fatal delivery, native diagnostic, exit 255 | +| 25 register/unregister cycles and final registration | One shutdown callback | +| Unregistered instance | No shutdown delivery | +| Nested handlers and previous native callbacks | Restoration preserves the original error mask; previous exception handler exits 42 | +| Out-of-order unregister | Rejected without removing the active handler | +| Reporter throws, shutdown reporter throws, recursive reporter | One reporter invocation and a bounded nonzero failure | +| Warning inside the PHP-error reporter | Native diagnostic; no recursive reporter call | +| Custom shutdown throws | Its throwable is delivered once | +| Aggregate | Reporters run in attachment order | + +The allocation case deliberately requests 32 MiB under a 16 MiB limit. It proves this failure path only. There is no memory reserve and **no guarantee** of reporting arbitrary real-world out-of-memory conditions. + +## Observed baseline and verification + +On 2026-09-15, the original `f7284cd` source passed 10 of the first 19 isolated scenarios on macOS arm64/PHP 8.5.10. Four uncaught throwable cases exited 0; 25 registration cycles caused 26 shutdown deliveries; failing reporters were called three times; recursion produced 9,389 events before the output cap terminated the child. The original Composer lock could not install on PHP 8.5 because several locked integrations excluded it. + +The final 20-case core suite, including nested fatal-handler coverage, passed on macOS arm64/PHP 8.5.10 and official Linux aarch64 images for PHP 8.3.33, 8.4.25 and 8.5.10. PHP 8.2.33 also passed the original 19 core cases during investigation but is outside the new support contract. + +Native PHP 8.5.10 verification additionally runs PHPUnit 13.3.4, PHPStan 2.2.14 and three real HTTP requests through PHP's built-in server: 500, 503 and TypeError→500, all with a redacted body. The CI matrix checks lowest/highest compatible dependencies on PHP 8.3, highest on 8.4, and locked/highest on 8.5. A green result applies only to the exact checked commit; consult the [Actions run](https://github.com/coisa/error-handler/actions/workflows/ci.yml) rather than assuming that a configured matrix has passed. + +The full `composer --no-plugins check` command also passed in the rebuilt PHP 8.3.33 container with PHPUnit 12.5.35: 100 tests and 144 assertions, maximum-level source analysis, formatting, all 20 crash scenarios and all three HTTP responses. The runnable CLI example was checked for exit status 1, empty stdout and both expected diagnostic messages on stderr. + +```sh +docker run --rm --network none --read-only --memory 128m --cpus 1 --pids-limit 32 \ + -v "$PWD:/app:ro" -w /app php:8.3-cli-alpine php tests/Fire/run.php +docker compose run --rm --build tests +``` + +Container builds resolve development packages for the requested PHP version. For a specific matrix run, record the actual PHP version, resolved dependencies and image digest; floating version tags may change. The recorded image digests are: + +- PHP 8.3.33: `sha256:afdf8b1fee58486ccc0dab5f30f634b86873d56dac985f71ba217945647c05ad` +- PHP 8.4.25: `sha256:2f389f933c3cc58cc622bd243bb4ecff7e6553e2de4387a239bca640c988be19` +- PHP 8.5.10: `sha256:aa66ee4275e0472bafcd18e35142499fcbda6ac90e641e751418e052e6a0d812` + +## Boundaries and troubleshooting + +- PHP must have `proc_open()` enabled for crash/HTTP tests. Unit tests require the normal PHPUnit extensions. The HTTP runner binds only to a dynamically selected `127.0.0.1` port, waits at most five seconds to start, gives each request two seconds, and terminates its fixture server in `finally`. +- PHP-FPM, Apache modules, Windows, production load, asynchronous/concurrent runtimes, remote logging failures and process signals are not covered by the local evidence. The Linux CI runner adds its own platform evidence when it succeeds. +- Errors before registration, startup failures and `SIGKILL` cannot be rescued by a PHP shutdown callback. A handler does not make a fatal error recoverable. Native shutdown diagnostics may still be emitted by PHP; applications must configure `display_errors`/logging appropriately. +- A global Composer plugin can interfere with newer tooling. The documented `--no-plugins` commands isolate this project from plugins; they do not modify global configuration. +- If unregistration throws, unwind the middleware/handler stack in reverse order. If a renderer throws, its exception propagates after library-owned buffers are cleaned. Renderers must not close buffers owned by the caller. + +Sources: [PHP supported versions](https://www.php.net/supported-versions.php), [error-handler semantics](https://www.php.net/manual/en/function.set-error-handler.php), [exception handlers](https://www.php.net/manual/en/function.set-exception-handler.php), [shutdown semantics](https://www.php.net/manual/en/function.register-shutdown-function.php). diff --git a/examples/cli.php b/examples/cli.php new file mode 100644 index 0000000..4b752bf --- /dev/null +++ b/examples/cli.php @@ -0,0 +1,17 @@ +getMessage() . PHP_EOL); +}); +$handler = new ErrorHandler($reporter); +$handler->register(); + +trigger_error('Example warning; execution continues.', E_USER_WARNING); +throw new RuntimeException('Example uncaught exception; exit status is 1.'); diff --git a/phpcs.xml b/phpcs.xml index e98ff5f..7b9e967 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,10 +1,18 @@ - + + + + + + tests/Fire/bootstrap.php + src tests vendor/* tests/html/* + + tests/Fire/*.inc diff --git a/phpstan.neon b/phpstan.neon index f5a5301..9192c76 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,5 @@ -includes: - - vendor/jangregor/phpstan-prophecy/src/extension.neon - parameters: - ignoreErrors: - - message: '/^Parameter \#1 \$error_handler of function set_error_handler expects/' - path: src/ErrorHandler.php + level: max + paths: + - src + tmpDir: tmp/phpstan diff --git a/phpunit.xml b/phpunit.xml index 13673ab..c7ff5c2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,12 +1,13 @@ + cacheDirectory="tmp/phpunit" + failOnRisky="true" + failOnWarning="true" + failOnDeprecation="true" + failOnPhpunitDeprecation="true" + colors="true"> tests/Unit @@ -15,15 +16,5 @@ tests/Functional - - - src - - - - - - - - + src diff --git a/src/Container/ConfigProvider.php b/src/Container/ConfigProvider.php index 35b5dac..cbf5f89 100644 --- a/src/Container/ConfigProvider.php +++ b/src/Container/ConfigProvider.php @@ -35,6 +35,8 @@ * required for the proper functioning of the error-handler library. * * @package CoiSA\ErrorHandler\Container + * @phpstan-type Factories array + * @phpstan-type Dependencies array{aliases: array, factories: Factories} */ final class ConfigProvider { @@ -45,7 +47,7 @@ final class ConfigProvider * for dependency injection containers, including dependencies, aliases, * and factories. * - * @return array The configuration array. + * @return array{dependencies: Dependencies} The configuration array. */ public function __invoke(): array { @@ -60,7 +62,7 @@ public function __invoke(): array * This method SHALL define aliases and factories used for resolving * dependencies in the container. * - * @return array An array containing aliases and factories. + * @return Dependencies An array containing aliases and factories. */ public function getDependencies(): array { @@ -76,7 +78,7 @@ public function getDependencies(): array * This method SHALL define class aliases to allow easier resolution * of services in the dependency injection container. * - * @return array An associative array of aliases. + * @return array An associative array of aliases. */ public function getAliases(): array { @@ -93,7 +95,7 @@ public function getAliases(): array * This method SHALL define factories responsible for instantiating * and configuring services in the dependency injection container. * - * @return array An associative array of service factories. + * @return Factories An associative array of service factories. */ public function getFactories(): array { diff --git a/src/Container/ErrorHandlerContainer.php b/src/Container/ErrorHandlerContainer.php index 821fcd4..03a688d 100644 --- a/src/Container/ErrorHandlerContainer.php +++ b/src/Container/ErrorHandlerContainer.php @@ -28,6 +28,7 @@ * to a parent container when configured. * * @package CoiSA\ErrorHandler\Container + * @phpstan-import-type Factories from ConfigProvider */ final class ErrorHandlerContainer implements ContainerInterface { @@ -37,7 +38,7 @@ final class ErrorHandlerContainer implements ContainerInterface private ?ContainerInterface $container; /** - * @var string[] List of factories mapped to their service IDs. + * @var Factories List of factories mapped to their service IDs. */ private array $factories; @@ -47,7 +48,7 @@ final class ErrorHandlerContainer implements ContainerInterface private array $aliases; /** - * @var object[] Cached instances of resolved services. + * @var array Cached services, including values delegated through aliases. */ private array $instances = []; @@ -97,7 +98,7 @@ public function has(string $id): bool * * @return mixed The resolved service instance. */ - public function get(string $id) + public function get(string $id): mixed { if ($this->container && $this->container->has($id)) { return $this->container->get($id); @@ -139,6 +140,11 @@ private function getFactory(string $id): callable return new Factory\AliasFactory($this->aliases[$id]); } - return new $this->factories[$id](); + $factory = new $this->factories[$id](); + if (!is_callable($factory)) { + throw new ContainerException(sprintf('Factory for class %s is not callable', $id)); + } + + return $factory; } } diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index 40a1474..8eacb24 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -56,6 +56,16 @@ final class ErrorHandler implements */ private bool $isRegistered = false; + private bool $shutdownRegistered = false; + + private bool $isHandlingThrowable = false; + + private bool $handlerFailed = false; + + private \Closure $errorCallback; + + private \Closure $exceptionCallback; + /** * Constructs the ErrorHandler. * @@ -73,6 +83,8 @@ public function __construct( $this->throwableHandler = $throwableHandler; $this->phpErrorHandler = $phpErrorHandler ?? new ThrowErrorExceptionPhpErrorHandler(); $this->shutdownHandler = $shutdownHandler ?? new PhpLastErrorShutdownHandler($this); + $this->errorCallback = $this->onPhpError(...); + $this->exceptionCallback = $this->onUncaughtThrowable(...); } /** @@ -87,10 +99,14 @@ public function register(): void return; } - set_error_handler([$this, 'handlePhpError']); - set_exception_handler([$this, 'handleThrowable']); - register_shutdown_function([$this, 'handleShutdown']); + set_error_handler($this->errorCallback); + set_exception_handler($this->exceptionCallback); + if (!$this->shutdownRegistered) { + register_shutdown_function([$this, 'handleShutdown']); + $this->shutdownRegistered = true; + } + $this->handlerFailed = false; $this->isRegistered = true; } @@ -105,6 +121,10 @@ public function unregister(): void return; } + if (!$this->hasActiveCallbacks()) { + throw new \LogicException('Error handlers must be unregistered in reverse registration order.'); + } + restore_error_handler(); restore_exception_handler(); @@ -118,7 +138,22 @@ public function unregister(): void */ public function handleThrowable(\Throwable $throwable): void { - $this->throwableHandler->handleThrowable($throwable); + if ($this->isHandlingThrowable) { + throw new \LogicException('Recursive throwable handling is not supported.', 0, $throwable); + } + if ($this->handlerFailed) { + throw $throwable; + } + + $this->isHandlingThrowable = true; + try { + $this->throwableHandler->handleThrowable($throwable); + } catch (\Throwable $failure) { + $this->handlerFailed = true; + throw $failure; + } finally { + $this->isHandlingThrowable = false; + } } /** @@ -145,11 +180,11 @@ public function handlePhpError(int $code, string $message, string $filename, int /** * Handles shutdown events. * - * Ensures graceful shutdown and captures any remaining errors. + * Reports observable fatal errors or custom shutdown notifications while active. */ public function handleShutdown(): void { - if (!$this->isRegistered) { + if (!$this->isRegistered || $this->handlerFailed || !$this->hasActiveCallbacks()) { return; } @@ -159,4 +194,30 @@ public function handleShutdown(): void $this->handleThrowable($throwable); } } + + private function onPhpError(int $code, string $message, string $filename, int $line): bool + { + $this->handlePhpError($code, $message, $filename, $line); + + return true; + } + + private function hasActiveCallbacks(): bool + { + // Probe without changing the existing stacks or their error-level masks. + $currentErrorHandler = set_error_handler(static fn (): bool => false); + restore_error_handler(); + $currentExceptionHandler = set_exception_handler(static function (\Throwable $error): void { + }); + restore_exception_handler(); + + return $currentErrorHandler === $this->errorCallback + && $currentExceptionHandler === $this->exceptionCallback; + } + + private function onUncaughtThrowable(\Throwable $throwable): never + { + $this->handleThrowable($throwable); + exit(1); + } } diff --git a/src/ErrorHandlerInterface.php b/src/ErrorHandlerInterface.php index 85384d2..7428b71 100644 --- a/src/ErrorHandlerInterface.php +++ b/src/ErrorHandlerInterface.php @@ -42,7 +42,7 @@ public function register(): void; * Unregisters the error handler. * * Implementing classes MUST ensure that this method restores - * previous error, exception, and shutdown handlers. If the handler + * previous error and exception handlers and deactivate shutdown handling. If the handler * was never registered, this method SHOULD do nothing. * * @return void diff --git a/src/EventDispatcher/ListenerProvider/ErrorEventListenerProvider.php b/src/EventDispatcher/ListenerProvider/ErrorEventListenerProvider.php index c015a1e..16522f3 100644 --- a/src/EventDispatcher/ListenerProvider/ErrorEventListenerProvider.php +++ b/src/EventDispatcher/ListenerProvider/ErrorEventListenerProvider.php @@ -66,7 +66,7 @@ public function attach(callable $listener): void * * @param object $event The event for which listeners are requested. * - * @return iterable The list of applicable listeners. + * @return iterable The list of applicable listeners. */ public function getListenersForEvent(object $event): iterable { diff --git a/src/EventDispatcher/ListenerProvider/ThrowableListenerProvider.php b/src/EventDispatcher/ListenerProvider/ThrowableListenerProvider.php index 1eec4e3..665ba34 100644 --- a/src/EventDispatcher/ListenerProvider/ThrowableListenerProvider.php +++ b/src/EventDispatcher/ListenerProvider/ThrowableListenerProvider.php @@ -65,7 +65,7 @@ public function attach(callable $listener): void * * @param object $event The event for which listeners are requested. * - * @return iterable The list of applicable listeners. + * @return iterable The list of applicable listeners. */ public function getListenersForEvent(object $event): iterable { diff --git a/src/Handler/PhpLastErrorShutdownHandler.php b/src/Handler/PhpLastErrorShutdownHandler.php index fef3898..de82ac4 100644 --- a/src/Handler/PhpLastErrorShutdownHandler.php +++ b/src/Handler/PhpLastErrorShutdownHandler.php @@ -72,7 +72,7 @@ public function handleShutdown(): void * * This method SHALL check if the provided error level corresponds * to one of the fatal error types: E_ERROR, E_PARSE, E_CORE_ERROR, - * E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING. + * E_COMPILE_ERROR. Warnings are not fatal shutdown events. * * @param int $level The PHP error level to check. * @@ -83,9 +83,7 @@ private function isCatchableFatalError(int $level): bool $errors = E_ERROR | E_PARSE | E_CORE_ERROR - | E_CORE_WARNING - | E_COMPILE_ERROR - | E_COMPILE_WARNING; + | E_COMPILE_ERROR; return ($level & $errors) > 0; } diff --git a/src/Http/Message/ThrowableResponseFactory.php b/src/Http/Message/ThrowableResponseFactory.php index 79178e5..324037e 100644 --- a/src/Http/Message/ThrowableResponseFactory.php +++ b/src/Http/Message/ThrowableResponseFactory.php @@ -57,8 +57,8 @@ public function __construct( /** * Creates a PSR-7 response from a throwable instance. * - * This method SHALL create a response using the provided throwable's code and message, - * set the HTTP status code appropriately, and include the throwable details in the body. + * This method SHALL map the throwable's code to an HTTP status, use the factory's + * standard reason phrase, and include the stream factory's public response body. * * @param \Throwable $throwable The throwable to convert into an HTTP response. * @@ -67,11 +67,9 @@ public function __construct( public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface { $code = $throwable->getCode(); - $message = $throwable->getMessage(); - $statusCode = $this->getStatusCode($code); - $response = $this->responseFactory->createResponse($statusCode, $message); + $response = $this->responseFactory->createResponse($statusCode); $stream = $this->streamFactory->createStreamFromThrowable($throwable); return $response->withBody($stream); @@ -80,15 +78,15 @@ public function createResponseFromThrowable(\Throwable $throwable): ResponseInte /** * Determines the HTTP status code based on the throwable code. * - * This method SHALL return the throwable code if it falls within the 400–500 range. + * This method SHALL return an integer throwable code in the 400–599 range. * Otherwise, it SHALL return a default status code of 500. * - * @param int $code The throwable code. + * @param int|string $code The throwable code (PDO can use a SQLSTATE string). * * @return int The HTTP status code to use in the response. */ - private function getStatusCode(int $code): int + private function getStatusCode(int|string $code): int { - return ($code >= 400 && $code <= 500) ? $code : 500; + return (is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; } } diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php index 5b8dd98..64a3545 100644 --- a/src/Http/Message/ThrowableStreamFactory.php +++ b/src/Http/Message/ThrowableStreamFactory.php @@ -63,13 +63,24 @@ public function __construct( * * @param \Throwable $throwable The throwable instance to process. * - * @return StreamInterface The resulting PSR-7 stream containing throwable details. + * @return StreamInterface The rendered response body or a generic fallback. */ public function createStreamFromThrowable(\Throwable $throwable): StreamInterface { + $initialLevel = ob_get_level(); ob_start(); - $this->throwableHandler->handleThrowable($throwable); - $buffer = ob_get_clean() ?: (string) $throwable; + try { + $this->throwableHandler->handleThrowable($throwable); + $buffer = ob_get_contents(); + } finally { + while (ob_get_level() > $initialLevel) { + ob_end_clean(); + } + } + + if ($buffer === false || $buffer === '') { + $buffer = 'Internal Server Error'; + } return $this->streamFactory->createStream($buffer); } diff --git a/tests/Fire/bootstrap.php b/tests/Fire/bootstrap.php new file mode 100644 index 0000000..47ef4c8 --- /dev/null +++ b/tests/Fire/bootstrap.php @@ -0,0 +1,16 @@ + $name] + $data, JSON_THROW_ON_ERROR) . "\n"; +} diff --git a/tests/Fire/compile-fatal.inc b/tests/Fire/compile-fatal.inc new file mode 100644 index 0000000..b98bc59 --- /dev/null +++ b/tests/Fire/compile-fatal.inc @@ -0,0 +1,3 @@ + [0, ['error', 'continued'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_USER_WARNING], + 'native-warning' => [0, ['error', 'continued'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_WARNING], + 'suppression' => [0, ['continued']], + 'exception' => [1, ['error'], RuntimeException::class], + 'error' => [1, ['error'], Error::class], + 'type-error' => [1, ['error'], TypeError::class], + 'parse-error' => [1, ['error'], ParseError::class], + 'compile-fatal' => [255, ['error'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_COMPILE_ERROR], + 'nested-fatal' => [255, ['error'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_COMPILE_ERROR], + 'memory-limit' => [255, ['error'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_ERROR], + 'shutdown-once' => [0, ['shutdown']], + 'unregistered-shutdown' => [0, ['continued']], + 'restore' => [42, ['error', 'previous-error', 'previous-exception']], + 'out-of-order' => [0, ['rejected', 'error']], + 'reporter-throws' => [255, ['reporter-called']], + 'shutdown-reporter-throws' => [255, ['reporter-called']], + 'recursive-reporter' => [255, ['reporter-called']], + 'reporter-warning' => [0, ['reporter-called', 'continued']], + 'shutdown-throws' => [0, ['error'], RuntimeException::class], + 'aggregate' => [0, ['first', 'second']], +]; +$failed = 0; +printf("PHP %s | %s %s | %d isolated scenarios\n", PHP_VERSION, PHP_OS_FAMILY, php_uname('m'), count($cases)); +foreach ($cases as $name => $expected) { + $process = proc_open([ + PHP_BINARY, '-n', '-d', 'display_errors=stderr', '-d', 'log_errors=0', + '-d', 'error_reporting=-1', '-d', 'memory_limit=16M', + __DIR__ . '/scenario.php', $name, + ], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Could not start child process'); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $stdout = $stderr = ''; + $deadline = microtime(true) + 5; + $bounded = true; + do { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $status = proc_get_status($process); + if (microtime(true) > $deadline || strlen($stdout) + strlen($stderr) > 262144) { + proc_terminate($process, 9); + $bounded = false; + break; + } + if ($status['running']) { + usleep(1000); + } + } while ($status['running']); + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $closed = proc_close($process); + $exitCode = $status['exitcode'] >= 0 ? $status['exitcode'] : $closed; + $events = []; + $validJson = true; + foreach (array_filter(explode("\n", trim($stdout))) as $line) { + $event = json_decode($line, true); + if (!is_array($event) || !isset($event['event'])) { + $validJson = false; + break; + } + $events[] = $event; + } + $actualNames = array_column($events, 'event'); + $ok = $bounded && $validJson && $exitCode === $expected[0] && $actualNames === $expected[1]; + if (isset($expected[2])) { + $ok = $ok && ($events[0]['class'] ?? null) === $expected[2]; + } + if (isset($expected[3])) { + $ok = $ok && ($events[0]['severity'] ?? null) === $expected[3]; + } + $nativeDiagnostic = in_array($name, [ + 'compile-fatal', 'nested-fatal', 'memory-limit', 'reporter-throws', 'shutdown-reporter-throws', + 'recursive-reporter', 'reporter-warning', + ], true); + $ok = $ok && ($nativeDiagnostic ? $stderr !== '' : $stderr === ''); + printf("%s %-27s exit=%d events=%d\n", $ok ? 'PASS' : 'FAIL', $name, $exitCode, count($events)); + if (!$ok) { + ++$failed; + printf( + " bounded=%s expected=%s actual=%s stderr=%s\n", + $bounded ? 'yes' : 'no', + json_encode($expected), + json_encode(array_slice($events, 0, 4)), + substr($stderr, 0, 600) + ); + } +} +printf("%d passed; %d failed.\n", count($cases) - $failed, $failed); +exit($failed === 0 ? 0 : 1); diff --git a/tests/Fire/scenario.php b/tests/Fire/scenario.php new file mode 100644 index 0000000..0f2cddf --- /dev/null +++ b/tests/Fire/scenario.php @@ -0,0 +1,171 @@ + get_class($error), + 'message' => $error->getMessage(), + 'severity' => $error instanceof ErrorException ? $error->getSeverity() : null, + ]); +}); +$handler = new ErrorHandler($reporter); +$scenario = $argv[1] ?? ''; + +switch ($scenario) { + case 'warning': + $handler->register(); + trigger_error('visible warning', E_USER_WARNING); + event('continued'); + break; + case 'native-warning': + $handler->register(); + fopen(__DIR__ . '/does-not-exist.fixture', 'r'); + event('continued'); + break; + case 'suppression': + $handler->register(); + error_reporting(E_ALL & ~E_USER_WARNING); + trigger_error('masked warning', E_USER_WARNING); + error_reporting(E_ALL); + @trigger_error('suppressed warning', E_USER_WARNING); + @fopen(__DIR__ . '/does-not-exist.fixture', 'r'); + event('continued'); + break; + case 'exception': + $handler->register(); + throw new RuntimeException('uncaught exception'); + case 'error': + $handler->register(); + error_handler_nonexistent_function(); + break; + case 'type-error': + $handler->register(); + strlen([]); + break; + case 'parse-error': + $handler->register(); + require __DIR__ . '/parse-error.inc'; + break; + case 'compile-fatal': + case 'nested-fatal': + $handler->register(); + if ($scenario === 'nested-fatal') { + (new ErrorHandler($reporter))->register(); + } + require __DIR__ . '/compile-fatal.inc'; + break; + case 'memory-limit': + $handler->register(); + // Allocation fails only inside this child, capped at 16 MiB by the runner. + str_repeat('x', 32 * 1024 * 1024); + break; + case 'shutdown-once': + $handler = new ErrorHandler($reporter, null, new CallableShutdownHandler(static function (): void { + event('shutdown'); + })); + for ($i = 0; $i < 25; ++$i) { + $handler->register(); + $handler->register(); + $handler->unregister(); + } + $handler->register(); + break; + case 'unregistered-shutdown': + $handler = new ErrorHandler($reporter, null, new CallableShutdownHandler(static function (): void { + event('unexpected-shutdown'); + })); + $handler->register(); + $handler->unregister(); + $handler->unregister(); + event('continued'); + break; + case 'restore': + $previousError = static function (): bool { + event('previous-error'); + return true; + }; + $previousException = static function (Throwable $error): void { + event('previous-exception'); + exit(42); + }; + set_error_handler($previousError, E_USER_NOTICE); + set_exception_handler($previousException); + $inner = new ErrorHandler($reporter); + $handler->register(); + $inner->register(); + $inner->unregister(); + trigger_error('outer active', E_USER_WARNING); + $handler->unregister(); + @trigger_error('outside previous mask', E_USER_WARNING); + trigger_error('previous active', E_USER_NOTICE); + throw new RuntimeException('previous exception active'); + case 'out-of-order': + $inner = new ErrorHandler($reporter); + $handler->register(); + $inner->register(); + try { + $handler->unregister(); + } catch (LogicException $error) { + event('rejected'); + } + trigger_error('inner still active', E_USER_WARNING); + $inner->unregister(); + $handler->unregister(); + break; + case 'reporter-throws': + case 'shutdown-reporter-throws': + $handler = new ErrorHandler(new CallableThrowableHandler(static function (): void { + event('reporter-called'); + throw new RuntimeException('reporter failed'); + })); + $handler->register(); + if ($scenario === 'shutdown-reporter-throws') { + require __DIR__ . '/compile-fatal.inc'; + } + throw new RuntimeException('original failure'); + case 'recursive-reporter': + $handler = new ErrorHandler(new CallableThrowableHandler( + static function (Throwable $error) use (&$handler): void { + event('reporter-called'); + $handler->handleThrowable($error); + } + )); + $handler->register(); + throw new RuntimeException('original failure'); + case 'reporter-warning': + $handler = new ErrorHandler(new CallableThrowableHandler(static function (): void { + event('reporter-called'); + trigger_error('reporter warning', E_USER_WARNING); + })); + $handler->register(); + trigger_error('original warning', E_USER_WARNING); + event('continued'); + break; + case 'shutdown-throws': + $handler = new ErrorHandler($reporter, null, new CallableShutdownHandler(static function (): void { + throw new RuntimeException('shutdown failed'); + })); + $handler->register(); + break; + case 'aggregate': + $aggregate = new ThrowableHandlerAggregate( + new CallableThrowableHandler(static function (Throwable $error): void { + event('first'); + }), + new CallableThrowableHandler(static function (Throwable $error): void { + event('second'); + }) + ); + (new ErrorHandler($aggregate))->handleThrowable(new RuntimeException('chain')); + break; + default: + throw new InvalidArgumentException('Unknown scenario: ' . $scenario); +} diff --git a/tests/Functional/Container/ConfigProviderTest.php b/tests/Functional/Container/ConfigProviderTest.php index 0ba7403..536163e 100644 --- a/tests/Functional/Container/ConfigProviderTest.php +++ b/tests/Functional/Container/ConfigProviderTest.php @@ -23,6 +23,7 @@ use Phly\EventDispatcher\EventDispatcher; use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -68,33 +69,33 @@ public function setUpServiceManager(ServiceManager $serviceManager): void $serviceManager->setService(StreamFactoryInterface::class, $factory); } - public function provideContainerDependenciesClassNames(): array + public static function provideContainerDependenciesClassNames(): array { $dependencies = (new ConfigProvider())->getDependencies(); return array_chunk(array_keys(array_merge(...array_values($dependencies))), 1); } - /** @dataProvider provideContainerDependenciesClassNames */ + #[DataProvider('provideContainerDependenciesClassNames')] public function testServiceManagerHasConfigProviderDependencies(string $className): void { static::assertTrue($this->serviceManager->has($className)); } - /** @dataProvider provideContainerDependenciesClassNames */ + #[DataProvider('provideContainerDependenciesClassNames')] public function testContainerWithServiceManagerHasConfigProviderDependencies(string $className): void { static::assertTrue($this->container->has($className)); } - /** @dataProvider provideContainerDependenciesClassNames */ + #[DataProvider('provideContainerDependenciesClassNames')] public function testContainerWithoutServiceManagerHasConfigProviderDependencies(string $className): void { $container = new ErrorHandlerContainer(); static::assertTrue($container->has($className)); } - public function provideContainerDependenciesWithInstanceType(): array + public static function provideContainerDependenciesWithInstanceType(): array { $dependencies = (new ConfigProvider())->getDependencies(); $classNames = array_keys(array_merge(...array_values($dependencies))); @@ -109,33 +110,27 @@ public function provideContainerDependenciesWithInstanceType(): array }, $classNames); } - /** - * @dataProvider provideContainerDependenciesWithInstanceType - */ + #[DataProvider('provideContainerDependenciesWithInstanceType')] public function testServiceManagerCanCreateDependecyThatImplements(string $className, string $instanceOf): void { $object = $this->serviceManager->get($className); static::assertInstanceOf($instanceOf, $object); } - /** - * @dataProvider provideContainerDependenciesWithInstanceType - */ + #[DataProvider('provideContainerDependenciesWithInstanceType')] public function testContainerCanCreateDependecyThatImplements(string $className, string $instanceOf): void { $object = $this->container->get($className); static::assertInstanceOf($instanceOf, $object); } - /** - * @dataProvider provideContainerDependenciesWithInstanceType - */ + #[DataProvider('provideContainerDependenciesWithInstanceType')] public function testWithCleanServiceManagerCanCreateDependecyThatImplements( string $className, string $instanceOf ): void { $serviceManager = new ServiceManager(); - $container = new ErrorHandlerContainer($this->serviceManager); + $container = new ErrorHandlerContainer($serviceManager); $this->setUpServiceManager($serviceManager); diff --git a/tests/Functional/Container/ErrorHandlerContainerTest.php b/tests/Functional/Container/ErrorHandlerContainerTest.php index 884632f..535f4a6 100644 --- a/tests/Functional/Container/ErrorHandlerContainerTest.php +++ b/tests/Functional/Container/ErrorHandlerContainerTest.php @@ -37,6 +37,8 @@ use Phly\EventDispatcher\EventDispatcher; use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -80,20 +82,25 @@ protected function setUp(): void $this->serviceManager->setService(StreamFactoryInterface::class, $factory); } - public function provideServiceProviderClassNames() + protected function tearDown(): void + { + $this->container->get(ErrorHandler::class)->unregister(); + } + + public static function provideServiceProviderClassNames(): array { $factories = (new ConfigProvider())->getFactories(); return array_chunk(array_keys($factories), 1); } - /** @dataProvider provideServiceProviderClassNames */ + #[DataProvider('provideServiceProviderClassNames')] public function testContainerHasConfigProviderFactories(string $service): void { static::assertTrue($this->container->has($service)); } - /** @dataProvider provideServiceProviderClassNames */ + #[DataProvider('provideServiceProviderClassNames')] public function testContainerProvideConfigProviderFactories(string $service): void { $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher); @@ -197,6 +204,7 @@ public function testErrorHandlerRespectErrorReporting(): void static::assertTrue(true); } + #[WithoutErrorHandler] public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): void { $callableThrowableHandler = new CallableThrowableHandler(function ($throwable): void { @@ -207,7 +215,7 @@ public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): voi $errorHandler = $this->container->get(ErrorHandler::class); $errorHandler->register(); - trigger_error('Test user error', E_USER_ERROR); + trigger_error('Test user warning', E_USER_WARNING); } public function testErrorHandlerMiddlewareHandleRequestException(): void @@ -255,11 +263,9 @@ public function handle(ServerRequestInterface $serverRequest): ResponseInterface public function testUnregisteredErrorHandlerHandleShutdownWillDoNothing(): void { + $this->expectOutputString(''); $errorHandler = $this->container->get(ErrorHandler::class); $errorHandler->handleShutdown(); - - $output = $this->getActualOutput(); - static::assertEmpty($output); } public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHandler(): void diff --git a/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php new file mode 100644 index 0000000..55ed6d8 --- /dev/null +++ b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php @@ -0,0 +1,62 @@ + true; + $previousException = static function (\Throwable $error): void { + }; + set_error_handler($previousError, E_USER_NOTICE); + set_exception_handler($previousException); + $failure = new \RuntimeException('response factory failed'); + $responses = new class ($failure) implements ThrowableResponseFactoryInterface { + public function __construct(private \Throwable $failure) + { + } + public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface + { + throw $this->failure; + } + }; + $next = new class () implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \TypeError('request failed'); + } + }; + $reporter = new CallableThrowableHandler(static function (): void { + }); + $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); + try { + $middleware->process((new Psr17Factory())->createServerRequest('GET', '/'), $next); + self::fail('Response factory failure must propagate.'); + } catch (\RuntimeException $actual) { + self::assertSame($failure, $actual); + $activeError = set_error_handler(static fn (): bool => true); + restore_error_handler(); + $activeException = set_exception_handler(static function (\Throwable $error): void { + }); + restore_exception_handler(); + self::assertSame($previousError, $activeError); + self::assertSame($previousException, $activeException); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } +} diff --git a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php index 0f68885..0be46ca 100644 --- a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php +++ b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php @@ -14,7 +14,7 @@ * @license https://opensource.org/licenses/MIT MIT License */ -namespace CoiSA\ErrorHandler\Test\Functional\Container; +namespace CoiSA\ErrorHandler\Test\Functional\Http\Middleware; use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; diff --git a/tests/Http/router.php b/tests/Http/router.php new file mode 100644 index 0000000..f6f4b23 --- /dev/null +++ b/tests/Http/router.php @@ -0,0 +1,40 @@ +getUri()->getPath() === '/unavailable') { + throw new RuntimeException('private upstream address', 503); + } + if ($request->getUri()->getPath() === '/type-error') { + strlen([]); + } + throw new RuntimeException('private connection string'); + } +}; +$response = $middleware->process($factory->createServerRequest('GET', $_SERVER['REQUEST_URI']), $next); +http_response_code($response->getStatusCode()); +header('Content-Type: text/plain; charset=utf-8'); +echo $response->getBody(); diff --git a/tests/Http/run.php b/tests/Http/run.php new file mode 100644 index 0000000..1a9368d --- /dev/null +++ b/tests/Http/run.php @@ -0,0 +1,67 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); +if (!is_resource($process)) { + throw new RuntimeException('Could not start fixture server'); +} +fclose($pipes[0]); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); +try { + $ready = false; + $deadline = microtime(true) + 5; + do { + $connection = @stream_socket_client('tcp://' . $address, $errno, $error, 0.1); + if (is_resource($connection)) { + fclose($connection); + $ready = true; + break; + } + if (!proc_get_status($process)['running']) { + break; + } + usleep(10000); + } while (microtime(true) < $deadline); + if (!$ready) { + throw new RuntimeException('Fixture server did not start: ' . stream_get_contents($pipes[2])); + } + foreach (['/error' => 500, '/unavailable' => 503, '/type-error' => 500] as $route => $expected) { + $context = stream_context_create(['http' => ['ignore_errors' => true, 'timeout' => 2]]); + $stream = fopen('http://' . $address . $route, 'r', false, $context); + if ($stream === false) { + throw new RuntimeException('Request failed: ' . $route); + } + $body = stream_get_contents($stream); + $metadata = stream_get_meta_data($stream); + fclose($stream); + $status = $metadata['wrapper_data'][0]; + if (!str_contains($status, ' ' . $expected . ' ') || $body !== 'Internal Server Error') { + throw new RuntimeException('Unexpected response: ' . $status . ' ' . $body); + } + printf("PASS HTTP %-14s status=%d body=redacted\n", $route, $expected); + } +} finally { + proc_terminate($process); + $deadline = microtime(true) + 1; + while (proc_get_status($process)['running'] && microtime(true) < $deadline) { + usleep(10000); + } + if (proc_get_status($process)['running']) { + proc_terminate($process, 9); + } + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); +} diff --git a/tests/Unit/Container/ErrorHandlerContainerTest.php b/tests/Unit/Container/ErrorHandlerContainerTest.php index 75aea3c..969f679 100644 --- a/tests/Unit/Container/ErrorHandlerContainerTest.php +++ b/tests/Unit/Container/ErrorHandlerContainerTest.php @@ -22,7 +22,7 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; +use PHPUnit\Framework\MockObject\Stub; use Psr\Container\ContainerInterface; /** @@ -35,7 +35,7 @@ */ final class ErrorHandlerContainerTest extends TestCase { - /** @var ContainerInterface|ObjectProphecy */ + /** @var ContainerInterface&Stub */ private $innerContainer; /** @var ErrorHandlerContainer */ @@ -43,25 +43,27 @@ final class ErrorHandlerContainerTest extends TestCase protected function setUp(): void { - $this->innerContainer = $this->prophesize(ContainerInterface::class); - $this->container = new ErrorHandlerContainer($this->innerContainer->reveal()); + $this->innerContainer = $this->createStub(ContainerInterface::class); + $this->container = new ErrorHandlerContainer($this->innerContainer); } public function testGetWithInvalidServiceWillThrowNotFoundException(): void { $this->expectException(NotFoundException::class); $index = uniqid('test', true); - $this->innerContainer->has($index)->willReturn(false); + $this->innerContainer->method('has')->willReturn(false); $this->container->get($index); } public function testGetWithServiceThatThrowsExceptionWillThrowContainerException(): void { - $this->innerContainer->has(ErrorHandler::class)->willReturn(false); - $this->innerContainer->has(ThrowableHandlerInterface::class)->willReturn(true); - $this->innerContainer->get(ThrowableHandlerInterface::class)->willThrow(\UnexpectedValueException::class); + $this->innerContainer->method('has')->willReturnMap([ + [ErrorHandler::class, false], + [ThrowableHandlerInterface::class, true], + ]); + $this->innerContainer->method('get')->willThrowException(new \UnexpectedValueException()); - $container = new ErrorHandlerContainer($this->innerContainer->reveal()); + $container = new ErrorHandlerContainer($this->innerContainer); $this->expectException(ContainerException::class); $container->get(ErrorHandler::class); diff --git a/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php b/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php new file mode 100644 index 0000000..39e6ff5 --- /dev/null +++ b/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php @@ -0,0 +1,88 @@ +createResponseFromThrowable($error); + + self::assertSame($expected, $response->getStatusCode()); + self::assertStringNotContainsString('private detail', $response->getReasonPhrase()); + self::assertSame('Internal Server Error', (string) $response->getBody()); + self::assertSame([$error], $reported); + } + + public function testStringSqlStateFallsBackTo500(): void + { + $error = new class () extends \PDOException { + public function __construct() + { + $this->code = 'HY000'; + } + }; + $factory = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function (): void { + }); + $responses = new ThrowableResponseFactory(new ThrowableStreamFactory($factory, $reporter), $factory); + self::assertSame(500, $responses->createResponseFromThrowable($error)->getStatusCode()); + } + + public function testZeroIsAValidRenderedBody(): void + { + $reporter = new CallableThrowableHandler(static function (): void { + echo '0'; + }); + $streams = new ThrowableStreamFactory(new Psr17Factory(), $reporter); + self::assertSame('0', (string) $streams->createStreamFromThrowable(new \RuntimeException('private'))); + } + + public function testRendererFailureCleansItsBuffersAndPreservesCallerBuffer(): void + { + $failure = new \RuntimeException('renderer failed'); + $reporter = new CallableThrowableHandler(static function () use ($failure): void { + echo 'partial output'; + ob_start(); + echo 'nested output'; + throw $failure; + }); + $streams = new ThrowableStreamFactory(new Psr17Factory(), $reporter); + ob_start(); + echo 'caller output'; + $level = ob_get_level(); + try { + $streams->createStreamFromThrowable(new \RuntimeException('original')); + self::fail('Renderer failure must propagate.'); + } catch (\RuntimeException $actual) { + self::assertSame($failure, $actual); + self::assertSame($level, ob_get_level()); + self::assertSame('caller output', ob_get_contents()); + } finally { + ob_end_clean(); + } + } +} From dfb0433db0effd298080ae5f8e63f3da051e6cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Tue, 15 Sep 2026 20:39:20 -0300 Subject: [PATCH 02/27] fix: preserve shutdown delivery and bound renderer cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .github/dependabot.yml | 12 ++++ CHANGELOG.md | 4 +- README.md | 2 + composer.json | 2 +- docs/api.md | 4 +- docs/index.html | 2 +- docs/testing.md | 25 ++++++-- phpstan.neon | 2 + src/ErrorHandler.php | 15 +++-- src/Http/Message/ThrowableStreamFactory.php | 22 ++++++- tests/Fire/run.php | 18 ++++++ tests/Fire/scenario.php | 65 +++++++++++++++++++++ tests/StaticAnalysis/output-buffer.stub | 9 +++ 13 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 tests/StaticAnalysis/output-buffer.stub diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bf60b15 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e486e..ee1723f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ - Re-registering an instance no longer adds duplicate shutdown callbacks. - Failing or recursive reporters are not invoked repeatedly during exception/shutdown handling. -- Throwable rendering cleans its output buffers even when the renderer throws, preserves the body `0`, and accepts SQLSTATE exception codes without raising a secondary type error. +- Custom shutdown callbacks still run after a reported uncaught throwable; replacing the active callback continues to suppress the old registration. +- Throwable rendering cleans reachable removable buffers when the renderer throws, stops at non-removable buffers, preserves the original failure if cleanup also throws, preserves the body `0`, and accepts SQLSTATE exception codes without raising a secondary type error. - Shutdown reporting excludes nonfatal core and compile warnings. ### Added - Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests. - English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment. +- Weekly Composer and GitHub Actions update proposals through Dependabot. The historical 2.0.0 and 1.0.0 releases are available in [GitHub Releases](https://github.com/coisa/error-handler/releases). diff --git a/README.md b/README.md index 8bc1ff7..d44b8ea 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ The middleware restores handlers in `finally`, including when response rendering GitHub Pages is prepared as a **manual** workflow restricted to `master`. After review and merge, an owner can enable Pages with GitHub Actions as the source and run the Documentation workflow from `master`. Nothing in the pull-request CI publishes documentation or executes PHP on Pages. +Dependabot is configured to propose Composer and GitHub Actions updates weekly after this configuration reaches the default branch. Updates remain reviewable pull requests. + ## Confidence and boundaries The test suite includes isolated real PHP failures rather than only calls to mocked handlers. Child processes have memory, time and output bounds; tests check termination, delivery counts and restoration. This is evidence for the exercised cases, not a reliability guarantee for every fatal error, PHP SAPI or downstream reporter. See the [failure matrix](docs/testing.md). diff --git a/composer.json b/composer.json index bb24d70..44649dd 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ ], "sniff": "phpcs", "test": "phpunit", - "fire-test": "@php tests/Fire/run.php", + "fire-test": "@php tests/Fire/run.php --with-psr", "http-test": "@php tests/Http/run.php" } } diff --git a/docs/api.md b/docs/api.md index 1de5fff..f34bad9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,7 +18,7 @@ Use this scoped form for code whose caller handles propagated exceptions. For pr Registration pushes PHP callbacks onto the native stacks. Previous callbacks are restored, including their error-level masks, when unregistered in reverse order. They are **not automatically called** while this handler is active. A second registration of the same instance is a no-op, not a nested reference count. Shutdown registration happens once per instance because PHP provides no shutdown-unregister function; inactive instances do nothing at shutdown. Reuse an instance when practical: PHP retains registered shutdown callbacks until process exit. -Only the instance owning both current PHP callbacks receives shutdown events. If another integration replaces a callback, restore it before unregistering this instance. Otherwise `unregister()` rejects the operation without altering either stack. +Only the active instance receives shutdown events. This includes a custom shutdown callback after an uncaught throwable has been reported: PHP temporarily clears the exception callback during its invocation, and the library tracks that terminating path. Replacing either callback with another integration still transfers ownership away. Restore that callback before unregistering this instance; otherwise `unregister()` rejects the operation without altering either stack. | API | Behavior | | --- | --- | @@ -51,7 +51,7 @@ Use the `Http\Message` and `Http\Middleware` namespaces for these classes. The P The middleware returns normal downstream responses unchanged and catches downstream `Throwable` objects to generate an error response. It always unregisters its handler after processing. Use a distinct handler instance for each nested middleware scope; do not share/pre-register one instance across simultaneous requests or fibers. PHP's global callback stacks are process state, not request-local storage. -`ThrowableStreamFactory` captures reporter output. Empty output maps to `Internal Server Error`; `0` is a valid body. The factory cleans buffers it opens if rendering fails. Renderers must balance their own successful nested buffers and must not close the caller's buffers. `ThrowableResponseFactory` keeps integer codes 400–599, uses 500 otherwise, and lets the PSR-17 factory supply the standard reason phrase. +`ThrowableStreamFactory` captures reporter output. Empty output maps to `Internal Server Error`; `0` is a valid body. On renderer failure, cleanup removes reachable removable buffers and preserves the renderer exception. It stops if the top buffer is non-removable or cleanup makes no progress. PHP cannot remove a non-removable buffer or reach the buffers beneath it; those remain active, so renderers must never create non-removable buffers. Renderers must balance their own successful nested buffers and must not close the caller's buffers. `ThrowableResponseFactory` keeps integer codes 400–599, uses 500 otherwise, and lets the PSR-17 factory supply the standard reason phrase. Neither the middleware nor the global handler emits PSR-7 responses to a server. The application/framework owns emission, headers, content type and transport. A PHP engine fatal error bypasses normal middleware recovery; shutdown reporting cannot reliably replace an already-started HTTP response. diff --git a/docs/index.html b/docs/index.html index e8ba674..a23a43b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -57,7 +57,7 @@

Make failure
observable.

04 / Exercise the failure path

A test should survive the crash.

-
20ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, registration cycles and reporter recursion run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

+
27ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion and renderer failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

The runner checks delivery counts, ordering, throwable classes, stderr and exact exit status. Separate integration tests exercise PSR composition and real HTTP 500/503 responses through a loopback-only server.

RUN THE EVIDENCE YOURSELF
php tests/Fire/run.php
 composer --no-plugins test
diff --git a/docs/testing.md b/docs/testing.md
index b728bcc..ac7bf86 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -16,12 +16,15 @@ composer --no-plugins check
 # Core crash tests need PHP only; no Composer dependencies or extensions.
 php tests/Fire/run.php
 
+# Include the three isolated PSR renderer regressions after Composer installation.
+php tests/Fire/run.php --with-psr
+
 # PSR integration and real HTTP responses.
 composer --no-plugins test
 php tests/Http/run.php
 ```
 
-The checks run PSR-12 formatting (with the existing declare-before-license convention), PHP_CodeSniffer, PHPUnit, PHPStan at maximum level over `src/`, crash tests and loopback HTTP smoke tests. Deliberately invalid `.inc` crash fixtures are excluded from lint and autoload classmaps. PHPUnit restores its own error handler between tests; the PHP-warning integration case explicitly disables PHPUnit's error handler so the package receives the real warning.
+The checks run PSR-12 formatting (with the existing declare-before-license convention), PHP_CodeSniffer, PHPUnit, PHPStan at maximum level over `src/`, crash tests and loopback HTTP smoke tests. A narrow PHPStan stub records that `ob_end_clean()` can throw through a user-defined output callback, as demonstrated by a child regression; no analysis errors are suppressed. Deliberately invalid `.inc` crash fixtures are excluded from lint and autoload classmaps. PHPUnit restores its own error handler between tests; the PHP-warning integration case explicitly disables PHPUnit's error handler so the package receives the real warning.
 
 ## Fire-test contract
 
@@ -32,6 +35,9 @@ Every child uses `php -n`, a 16 MiB PHP memory limit, a five-second wall-clock d
 | User/native warnings | One `ErrorException`; execution continues, exit 0 |
 | `error_reporting()` and `@` | Suppressed warnings are not delivered |
 | Uncaught exception, `Error`, `TypeError`, included parse error | One original throwable, exit 1 |
+| Uncaught exception with custom/nested shutdown | Only the active custom callback runs; exit 1 |
+| Uncaught exception followed by a throwing shutdown callback | Original and shutdown throwables each delivered once; exit 1 |
+| Exception callback replaced by the reporter | Replaced instance does not receive shutdown |
 | Compile fatal, nested-handler compile fatal | One active-handler delivery, native diagnostic, exit 255 |
 | Controlled allocation failure | One fatal delivery, native diagnostic, exit 255 |
 | 25 register/unregister cycles and final registration | One shutdown callback |
@@ -42,6 +48,8 @@ Every child uses `php -n`, a 16 MiB PHP memory limit, a five-second wall-clock d
 | Warning inside the PHP-error reporter | Native diagnostic; no recursive reporter call |
 | Custom shutdown throws | Its throwable is delivered once |
 | Aggregate | Reporters run in attachment order |
+| Renderer failure with removable/non-removable nested buffer (`--with-psr`) | Original renderer failure propagates without a loop; non-removable buffers remain active |
+| Renderer and output-cleanup callback both throw (`--with-psr`) | Original renderer failure wins; reachable buffers are cleaned |
 
 The allocation case deliberately requests 32 MiB under a 16 MiB limit. It proves this failure path only. There is no memory reserve and **no guarantee** of reporting arbitrary real-world out-of-memory conditions.
 
@@ -49,16 +57,23 @@ The allocation case deliberately requests 32 MiB under a 16 MiB limit. It proves
 
 On 2026-09-15, the original `f7284cd` source passed 10 of the first 19 isolated scenarios on macOS arm64/PHP 8.5.10. Four uncaught throwable cases exited 0; 25 registration cycles caused 26 shutdown deliveries; failing reporters were called three times; recursion produced 9,389 events before the output cap terminated the child. The original Composer lock could not install on PHP 8.5 because several locked integrations excluded it.
 
-The final 20-case core suite, including nested fatal-handler coverage, passed on macOS arm64/PHP 8.5.10 and official Linux aarch64 images for PHP 8.3.33, 8.4.25 and 8.5.10. PHP 8.2.33 also passed the original 19 core cases during investigation but is outside the new support contract.
+The final 24-case core suite, including nested fatal-handler and custom shutdown coverage, passed on macOS arm64/PHP 8.5.10 and official Linux aarch64 images for PHP 8.3.33, 8.4.25 and 8.5.10. PHP 8.2.33 also passed the original 19 core cases during investigation but is outside the new support contract.
+
+Independent review exposed two regressions in the first modernization draft: custom shutdown was skipped after an uncaught exception, and cleanup looped on a non-removable output buffer. The new child tests failed before the fixes; the non-removable case was terminated by the runner's output cap. An additional regression demonstrated that a throwing output callback could replace the original renderer failure and leave a buffer open. With the fixes, the suite includes 24 core cases and three PSR renderer cases. The latter require Composer dependencies and run automatically through `composer --no-plugins check`.
 
 Native PHP 8.5.10 verification additionally runs PHPUnit 13.3.4, PHPStan 2.2.14 and three real HTTP requests through PHP's built-in server: 500, 503 and TypeError→500, all with a redacted body. The CI matrix checks lowest/highest compatible dependencies on PHP 8.3, highest on 8.4, and locked/highest on 8.5. A green result applies only to the exact checked commit; consult the [Actions run](https://github.com/coisa/error-handler/actions/workflows/ci.yml) rather than assuming that a configured matrix has passed.
 
-The full `composer --no-plugins check` command also passed in the rebuilt PHP 8.3.33 container with PHPUnit 12.5.35: 100 tests and 144 assertions, maximum-level source analysis, formatting, all 20 crash scenarios and all three HTTP responses. The runnable CLI example was checked for exit status 1, empty stdout and both expected diagnostic messages on stderr.
+The full `composer --no-plugins check` command passed on native PHP 8.5.10/PHPUnit 13.3.4 and isolated PHP 8.3.33 with both highest dependencies (PHPUnit 12.5.35) and lowest allowed dependencies (PHPUnit 12.5.8, PHPStan 2.1.0, PSR Container 1.1.0): 100 tests and 144 assertions, maximum-level source analysis, formatting, all 27 isolated scenarios and all three HTTP responses. The runnable CLI example was checked for exit status 1, empty stdout and both expected diagnostic messages on stderr.
 
 ```sh
 docker run --rm --network none --read-only --memory 128m --cpus 1 --pids-limit 32 \
   -v "$PWD:/app:ro" -w /app php:8.3-cli-alpine php tests/Fire/run.php
 docker compose run --rm --build tests
+
+# Lowest allowed dependencies, isolated from the host's vendor/ and lock.
+docker run --rm --memory 512m --cpus 2 --pids-limit 64 \
+  error-handler-modernization-tests sh -c \
+  'composer update --prefer-lowest --prefer-stable --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress && composer --no-plugins check'
 ```
 
 Container builds resolve development packages for the requested PHP version. For a specific matrix run, record the actual PHP version, resolved dependencies and image digest; floating version tags may change. The recorded image digests are:
@@ -73,6 +88,6 @@ Container builds resolve development packages for the requested PHP version. For
 - PHP-FPM, Apache modules, Windows, production load, asynchronous/concurrent runtimes, remote logging failures and process signals are not covered by the local evidence. The Linux CI runner adds its own platform evidence when it succeeds.
 - Errors before registration, startup failures and `SIGKILL` cannot be rescued by a PHP shutdown callback. A handler does not make a fatal error recoverable. Native shutdown diagnostics may still be emitted by PHP; applications must configure `display_errors`/logging appropriately.
 - A global Composer plugin can interfere with newer tooling. The documented `--no-plugins` commands isolate this project from plugins; they do not modify global configuration.
-- If unregistration throws, unwind the middleware/handler stack in reverse order. If a renderer throws, its exception propagates after library-owned buffers are cleaned. Renderers must not close buffers owned by the caller.
+- If unregistration throws, unwind the middleware/handler stack in reverse order. If a renderer throws, its exception propagates after reachable removable buffers are cleaned. Cleanup stops at a non-removable buffer because PHP cannot remove it or reach lower buffers. Renderers must use removable buffers and must not close buffers owned by the caller.
 
-Sources: [PHP supported versions](https://www.php.net/supported-versions.php), [error-handler semantics](https://www.php.net/manual/en/function.set-error-handler.php), [exception handlers](https://www.php.net/manual/en/function.set-exception-handler.php), [shutdown semantics](https://www.php.net/manual/en/function.register-shutdown-function.php).
+Sources: [PHP supported versions](https://www.php.net/supported-versions.php), [error-handler semantics](https://www.php.net/manual/en/function.set-error-handler.php), [exception handlers](https://www.php.net/manual/en/function.set-exception-handler.php), [shutdown semantics](https://www.php.net/manual/en/function.register-shutdown-function.php), [output buffer status](https://www.php.net/manual/en/function.ob-get-status.php).
diff --git a/phpstan.neon b/phpstan.neon
index 9192c76..6d4afaf 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,4 +2,6 @@ parameters:
   level: max
   paths:
     - src
+  stubFiles:
+    - tests/StaticAnalysis/output-buffer.stub
   tmpDir: tmp/phpstan
diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php
index 8eacb24..9e624a7 100644
--- a/src/ErrorHandler.php
+++ b/src/ErrorHandler.php
@@ -60,6 +60,8 @@ final class ErrorHandler implements
 
     private bool $isHandlingThrowable = false;
 
+    private bool $isHandlingUncaughtThrowable = false;
+
     private bool $handlerFailed = false;
 
     private \Closure $errorCallback;
@@ -184,7 +186,7 @@ public function handlePhpError(int $code, string $message, string $filename, int
      */
     public function handleShutdown(): void
     {
-        if (!$this->isRegistered || $this->handlerFailed || !$this->hasActiveCallbacks()) {
+        if (!$this->isRegistered || $this->handlerFailed || !$this->hasActiveCallbacks(true)) {
             return;
         }
 
@@ -202,7 +204,7 @@ private function onPhpError(int $code, string $message, string $filename, int $l
         return true;
     }
 
-    private function hasActiveCallbacks(): bool
+    private function hasActiveCallbacks(bool $duringShutdown = false): bool
     {
         // Probe without changing the existing stacks or their error-level masks.
         $currentErrorHandler = set_error_handler(static fn (): bool => false);
@@ -211,12 +213,17 @@ private function hasActiveCallbacks(): bool
         });
         restore_exception_handler();
 
-        return $currentErrorHandler === $this->errorCallback
-            && $currentExceptionHandler === $this->exceptionCallback;
+        // PHP clears the exception callback while invoking it. exit() keeps that
+        // temporary state through shutdown, but does not transfer ownership.
+        $ownsExceptionCallback = $currentExceptionHandler === $this->exceptionCallback
+            || ($duringShutdown && $this->isHandlingUncaughtThrowable && $currentExceptionHandler === null);
+
+        return $currentErrorHandler === $this->errorCallback && $ownsExceptionCallback;
     }
 
     private function onUncaughtThrowable(\Throwable $throwable): never
     {
+        $this->isHandlingUncaughtThrowable = true;
         $this->handleThrowable($throwable);
         exit(1);
     }
diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php
index 64a3545..37de5f0 100644
--- a/src/Http/Message/ThrowableStreamFactory.php
+++ b/src/Http/Message/ThrowableStreamFactory.php
@@ -68,16 +68,36 @@ public function __construct(
     public function createStreamFromThrowable(\Throwable $throwable): StreamInterface
     {
         $initialLevel = ob_get_level();
+        $failure = null;
+        $buffer = false;
         ob_start();
         try {
             $this->throwableHandler->handleThrowable($throwable);
             $buffer = ob_get_contents();
+        } catch (\Throwable $rendererFailure) {
+            $failure = $rendererFailure;
         } finally {
             while (ob_get_level() > $initialLevel) {
-                ob_end_clean();
+                $flags = ob_get_status()['flags'] ?? null;
+                if (!is_int($flags) || ($flags & PHP_OUTPUT_HANDLER_REMOVABLE) === 0) {
+                    // PHP cannot remove this buffer or reach the buffers below it.
+                    break;
+                }
+                $level = ob_get_level();
+                try {
+                    ob_end_clean();
+                } catch (\Throwable $cleanupFailure) {
+                    $failure ??= $cleanupFailure;
+                }
+                if (ob_get_level() >= $level) {
+                    break;
+                }
             }
         }
 
+        if ($failure !== null) {
+            throw $failure;
+        }
         if ($buffer === false || $buffer === '') {
             $buffer = 'Internal Server Error';
         }
diff --git a/tests/Fire/run.php b/tests/Fire/run.php
index d5f0775..b2f3fde 100644
--- a/tests/Fire/run.php
+++ b/tests/Fire/run.php
@@ -9,6 +9,11 @@
     'native-warning' => [0, ['error', 'continued'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_WARNING],
     'suppression' => [0, ['continued']],
     'exception' => [1, ['error'], RuntimeException::class],
+    'exception-shutdown' => [1, ['error', 'shutdown'], RuntimeException::class],
+    'nested-exception-shutdown' => [1, ['error', 'shutdown'], RuntimeException::class],
+    'exception-shutdown-throws' => [1, ['error', 'error'], RuntimeException::class, null,
+        [0 => ['message' => 'uncaught exception'], 1 => ['message' => 'shutdown failed']]],
+    'exception-replaced-handler' => [1, ['error'], RuntimeException::class],
     'error' => [1, ['error'], Error::class],
     'type-error' => [1, ['error'], TypeError::class],
     'parse-error' => [1, ['error'], ParseError::class],
@@ -26,6 +31,14 @@
     'shutdown-throws' => [0, ['error'], RuntimeException::class],
     'aggregate' => [0, ['first', 'second']],
 ];
+if (in_array('--with-psr', $argv, true)) {
+    $cases['render-removable'] = [0, ['renderer-failure'], RuntimeException::class, null,
+        [0 => ['message' => 'renderer failed', 'remainingBuffers' => 0]]];
+    $cases['render-non-removable'] = [0, ['renderer-failure'], RuntimeException::class, null,
+        [0 => ['message' => 'renderer failed', 'remainingBuffers' => 2]]];
+    $cases['render-cleanup-throws'] = [0, ['renderer-failure'], RuntimeException::class, null,
+        [0 => ['message' => 'renderer failed', 'remainingBuffers' => 0]]];
+}
 $failed = 0;
 printf("PHP %s | %s %s | %d isolated scenarios\n", PHP_VERSION, PHP_OS_FAMILY, php_uname('m'), count($cases));
 foreach ($cases as $name => $expected) {
@@ -80,6 +93,11 @@
     if (isset($expected[3])) {
         $ok = $ok && ($events[0]['severity'] ?? null) === $expected[3];
     }
+    foreach ($expected[4] ?? [] as $index => $fields) {
+        foreach ($fields as $field => $value) {
+            $ok = $ok && ($events[$index][$field] ?? null) === $value;
+        }
+    }
     $nativeDiagnostic = in_array($name, [
         'compile-fatal', 'nested-fatal', 'memory-limit', 'reporter-throws', 'shutdown-reporter-throws',
         'recursive-reporter', 'reporter-warning',
diff --git a/tests/Fire/scenario.php b/tests/Fire/scenario.php
index 0f2cddf..8734e31 100644
--- a/tests/Fire/scenario.php
+++ b/tests/Fire/scenario.php
@@ -42,6 +42,39 @@
     case 'exception':
         $handler->register();
         throw new RuntimeException('uncaught exception');
+    case 'exception-replaced-handler':
+        $handler = new ErrorHandler(
+            new CallableThrowableHandler(static function (Throwable $failure) use ($reporter): void {
+                $reporter->handleThrowable($failure);
+                set_exception_handler(static function (): void {
+                    event('unexpected-exception');
+                });
+            }),
+            null,
+            new CallableShutdownHandler(static function (): void {
+                event('unexpected-shutdown');
+            })
+        );
+        $handler->register();
+        throw new RuntimeException('uncaught exception');
+    case 'exception-shutdown':
+    case 'nested-exception-shutdown':
+    case 'exception-shutdown-throws':
+        if ($scenario === 'nested-exception-shutdown') {
+            (new ErrorHandler($reporter, null, new CallableShutdownHandler(static function (): void {
+                event('unexpected-outer-shutdown');
+            })))->register();
+        }
+        $handler = new ErrorHandler($reporter, null, new CallableShutdownHandler(
+            static function () use ($scenario): void {
+                if ($scenario === 'exception-shutdown-throws') {
+                    throw new RuntimeException('shutdown failed');
+                }
+                event('shutdown');
+            }
+        ));
+        $handler->register();
+        throw new RuntimeException('uncaught exception');
     case 'error':
         $handler->register();
         error_handler_nonexistent_function();
@@ -166,6 +199,38 @@ static function (Throwable $error) use (&$handler): void {
         );
         (new ErrorHandler($aggregate))->handleThrowable(new RuntimeException('chain'));
         break;
+    case 'render-removable':
+    case 'render-non-removable':
+    case 'render-cleanup-throws':
+        require dirname(__DIR__, 2) . '/vendor/autoload.php';
+        $flags = PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE;
+        if ($scenario !== 'render-non-removable') {
+            $flags |= PHP_OUTPUT_HANDLER_REMOVABLE;
+        }
+        $renderer = new CallableThrowableHandler(static function () use ($flags, $scenario): void {
+            $callback = $scenario === 'render-cleanup-throws' ? static function (): never {
+                throw new LogicException('cleanup failed');
+            } : null;
+            ob_start($callback, 0, $flags);
+            throw new RuntimeException('renderer failed');
+        });
+        $factory = new CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory(
+            new Nyholm\Psr7\Factory\Psr17Factory(),
+            $renderer
+        );
+        $initialLevel = ob_get_level();
+        try {
+            $factory->createStreamFromThrowable(new RuntimeException('original'));
+        } catch (Throwable $failure) {
+            // Bypass the deliberately retained non-removable buffer.
+            fwrite(STDOUT, json_encode([
+                'event' => 'renderer-failure',
+                'class' => get_class($failure),
+                'message' => $failure->getMessage(),
+                'remainingBuffers' => ob_get_level() - $initialLevel,
+            ], JSON_THROW_ON_ERROR) . "\n");
+        }
+        break;
     default:
         throw new InvalidArgumentException('Unknown scenario: ' . $scenario);
 }
diff --git a/tests/StaticAnalysis/output-buffer.stub b/tests/StaticAnalysis/output-buffer.stub
new file mode 100644
index 0000000..b325e7d
--- /dev/null
+++ b/tests/StaticAnalysis/output-buffer.stub
@@ -0,0 +1,9 @@
+
Date: Tue, 15 Sep 2026 21:56:21 -0300
Subject: [PATCH 03/27] feat: integrate FastForward providers and add agent
 guidance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Felipe Sayão Lobato Abreu 
---
 .../skills/error-handler-integration/SKILL.md |  94 +++
 .github/workflows/ci.yml                      |   5 +-
 AGENTS.md                                     |  71 +++
 CHANGELOG.md                                  |   3 +
 README.md                                     |  10 +
 composer.json                                 |   5 +
 composer.lock                                 | 596 +++++++++++++++++-
 docs/api.md                                   |   5 +
 docs/fast-forward.md                          | 136 ++++
 docs/index.html                               |   9 +-
 docs/migration.md                             |   7 +
 docs/testing.md                               |  36 +-
 examples/fast-forward.php                     |  41 ++
 .../ThrowableHandlerAggregateFactory.php      |  11 +-
 .../ErrorHandlerServiceProvider.php           |  57 ++
 .../Factory/ErrorHandlerMiddlewareFactory.php |  24 +
 .../ErrorHandlerServiceProviderTest.php       | 381 +++++++++++
 .../ThrowableHandlerAggregateFactoryTest.php  |  57 ++
 18 files changed, 1541 insertions(+), 7 deletions(-)
 create mode 100644 .agents/skills/error-handler-integration/SKILL.md
 create mode 100644 AGENTS.md
 create mode 100644 docs/fast-forward.md
 create mode 100644 examples/fast-forward.php
 create mode 100644 src/ServiceProvider/ErrorHandlerServiceProvider.php
 create mode 100644 src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
 create mode 100644 tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
 create mode 100644 tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php

diff --git a/.agents/skills/error-handler-integration/SKILL.md b/.agents/skills/error-handler-integration/SKILL.md
new file mode 100644
index 0000000..0697139
--- /dev/null
+++ b/.agents/skills/error-handler-integration/SKILL.md
@@ -0,0 +1,94 @@
+---
+name: error-handler-integration
+description: Integrate coisa/error-handler into PHP applications using explicit process handlers, FastForward service providers or PSR-15 middleware, and verify reporting and callback restoration.
+license: MIT
+---
+
+# Integrate Error Handler
+
+Use the installed package or selected checkout as the API source. This skill
+describes the PHP 8.3+ development line after 2.0; verify the version before
+using its provider or changed exit-status contract. A future php-fast-forward
+move is not an existing package name or namespace alias.
+
+## Choose the scope
+
+For a CLI or process bootstrap, create one handler with an application-owned
+reporter and keep it registered until process exit when it must see uncaught
+exceptions. For a callable operation whose caller catches failures, unregister
+in `finally`. PHP runs that `finally` before dispatching an uncaught exception.
+
+```php
+use CoiSA\ErrorHandler\ErrorHandler;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+
+$errors = new ErrorHandler(new CallableThrowableHandler(
+    static function (Throwable $error): void {
+        error_log($error->getMessage()); // Replace with an appropriate private sink.
+    }
+));
+$errors->register();
+```
+
+Load Composer's autoloader first. Direct `handleThrowable()` calls return;
+the registered uncaught-exception callback exits 1 after successful reporting.
+Recoverable warnings are reported and execution continues. The default error
+conversion honors suppression and `error_reporting()`.
+
+## Wire FastForward definitions
+
+Use `CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider` with
+`FastForward\Container\container()`. It implements the Interop provider contract
+(`getFactories()` and `getExtensions()`), and resolves
+`CoiSA\ErrorHandler\ErrorHandlerInterface`. To override the reporter binding
+`CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface`, merge definitions with
+`FastForward\Container\ServiceProvider\AggregateServiceProvider`: library first,
+application **last**, then pass that one provider into `container(...)`.
+
+Separate variadic providers allow FastForward to fall back to the library's
+silent default if the application's reporter fails with a PSR container error.
+Merged definitions replace that default and make required-reporter construction
+fail. FastForward may translate the PSR error to not-found; do not promise that
+its original exception cause survives. Use the executable example in the
+matching checkout for the complete composition.
+
+The factory maps do not install PHP callbacks: explicitly register the resolved
+process handler at bootstrap. An empty default reporter aggregate is silent;
+configure a reporter or an event dispatcher before expecting diagnostics.
+
+FastForward caches resolved instances, including middleware. To create a fresh
+handler, resolve `CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory` and
+invoke it with the container. For fresh middleware, resolve and invoke
+`CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`.
+Do not mistake repeated `get()` calls for a transient lifetime.
+
+## Wire HTTP deliberately
+
+Supply PSR-17 response and stream factories. The provider creates middleware
+with a handler distinct from the process service; it restores native callbacks
+after processing and after renderer failure. Create separate middleware for
+nested scopes. This library does not make callbacks local to concurrent fibers
+or requests, and it does not emit HTTP responses.
+
+Reporters and renderers have different output responsibilities. Empty captured
+reporter output becomes `Internal Server Error`; emitted text becomes the public
+body. Keep private diagnostics out of renderer output. Renderers must use
+removable, balanced buffers and must not close caller-owned buffers.
+
+## Verify the integration
+
+Exercise a reported warning, an uncaught throwable in a bounded child process,
+and callback restoration after success and failure. If using middleware, also
+exercise nesting beneath a registered process handler and renderer failure.
+Check delivery counts, response redaction and exit status, not just that a
+service can be resolved. Never run a fatal-error experiment in a user process.
+
+In the library checkout, use `composer --no-plugins check` and
+`php examples/fast-forward.php`. In a consuming application, add focused tests
+against its configured reporters and middleware; the library's passing suite
+does not prove remote logger delivery or application-specific transport behavior.
+
+For full examples, consult the matching checkout's `docs/fast-forward.md`,
+`docs/api.md` and `docs/testing.md`, or the
+[source repository](https://github.com/coisa/error-handler). Keep the observed
+version, integration changes, executed checks and remaining limits explicit.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d66e826..6d9ab23 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,8 +55,11 @@ jobs:
           result = subprocess.run(['php', 'examples/cli.php'], capture_output=True, text=True, timeout=5)
           assert result.returncode == 1 and result.stdout == ''
           assert 'Example warning' in result.stderr and 'Example uncaught exception' in result.stderr
+          provider = subprocess.run(['php', 'examples/fast-forward.php'], capture_output=True, text=True, timeout=5)
+          assert provider.returncode == 0 and provider.stderr == ''
+          assert provider.stdout == 'Reported: FastForward provider warning\n'
           PY
       - name: Verify production-only autoload
         run: |
           composer install --no-dev --no-plugins --no-scripts --no-interaction --no-progress
-          php -r 'require "vendor/autoload.php"; new CoiSA\ErrorHandler\Container\ErrorHandlerContainer();'
+          php -r 'require "vendor/autoload.php"; new CoiSA\ErrorHandler\Container\ErrorHandlerContainer(); (new CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider())->getFactories(); if (class_exists(FastForward\Container\AggregateContainer::class)) { throw new RuntimeException("FastForward must remain optional"); }'
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..836e34a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,71 @@
+# Error Handler maintenance
+
+## Purpose
+
+Maintain a small PHP error-handling library with explicit reporting, process
+lifecycle and PSR-15 response boundaries. The current Composer package is
+`coisa/error-handler`, under the `CoiSA\ErrorHandler` namespace.
+
+## Ownership
+
+The repository owns its core handlers, PSR adapters, interoperability provider,
+tests, documentation and consumer skill. Applications own reporters, PSR-14 and
+PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed.
+
+## Local Contracts
+
+- Write code, public documentation, examples and agent guidance in English.
+- Target PHP 8.3–8.5; the current branch describes an unreleased major after 2.0.
+  Do not present branch behavior as an already published Composer release.
+- Keep core handlers independent of FastForward. The service provider uses
+  `Interop\Container\ServiceProviderInterface`; FastForward is an optional
+  consumer and a development integration test dependency.
+- Constructing or discovering a service must not call `register()`. Native PHP
+  callbacks are process-wide. Unregister in reverse order and use distinct
+  handlers for nested scopes. Follow [the lifecycle contract](docs/api.md).
+- FastForward caches services. Keep the middleware handler separate from the
+  process handler and use the provider's factory services for fresh scopes.
+  Merge application overrides after library definitions in one
+  `AggregateServiceProvider`, avoiding silent reporter fallback on PSR errors.
+- Keep the Laminas ConfigProvider and PSR-11 integration working. A possible
+  move to php-fast-forward does not itself change package identity or namespace.
+- Reporters own diagnostic output; HTTP fallback bodies must not leak exception
+  details. A shutdown callback reports a fatal error and does not recover it.
+
+## Work Guidance
+
+Inspect the current branch and relevant interfaces before changes. Update
+source, meaningful regression tests, examples, affected guides and Unreleased
+changelog together. Keep the skill aligned with tested public APIs, without
+copying the full manual or adding host-specific dependencies.
+
+Run fatal-error and process-termination experiments only through bounded child
+fixtures. Reuse the existing fire/HTTP runners and their temporary data. Never
+use application credentials, production logs or a real web server as fixtures.
+
+Publishing Pages, merging, releasing or transferring the repository requires
+the owner's corresponding instruction. Preparing a PR does not perform them.
+
+## Verification
+
+Use Composer 2 with `--no-plugins --no-scripts` for dependency installation;
+then run `composer --no-plugins check`. This runs formatting, PHPUnit, maximum
+PHPStan, bounded fire tests and loopback HTTP checks. See [testing](docs/testing.md)
+for PHP-version resolution, optional dependencies and actual platform evidence.
+Run `php examples/fast-forward.php` after provider changes. Verify a production
+installation without development dependencies still loads the core and provider.
+Check documentation links and inspect the actual CI result for the tested SHA.
+
+## Child DOX Index
+
+- [README.md](README.md): installation and entrypoints for consumers.
+- [docs/api.md](docs/api.md): core lifecycle, reporters and PSR composition.
+- [docs/fast-forward.md](docs/fast-forward.md): provider definitions, overrides,
+  cached services and fresh middleware scopes.
+- [docs/testing.md](docs/testing.md): failure runners, commands and evidence limits.
+- [docs/migration.md](docs/migration.md): breaking changes from published 2.0.
+- [.agents/skills/error-handler-integration/SKILL.md](.agents/skills/error-handler-integration/SKILL.md):
+  portable consumer integration procedure; no global installation required.
+
+No subtree needs a separate maintenance contract; the skill's procedure stays
+in its SKILL.md entrypoint.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee1723f..008bef5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
 
 ### Fixed
 
+- Skip an optional callable reporter when an autowiring container advertises it but retrieval reports PSR-11 not-found; continue propagating other construction exceptions.
 - Re-registering an instance no longer adds duplicate shutdown callbacks.
 - Failing or recursive reporters are not invoked repeatedly during exception/shutdown handling.
 - Custom shutdown callbacks still run after a reported uncaught throwable; replacing the active callback continues to suppress the old registration.
@@ -20,6 +21,8 @@
 
 ### Added
 
+- An Interop service provider matching FastForward's callable definitions, explicit process-handler binding and factory services for independent middleware scopes. FastForward remains optional at runtime.
+- Integration tests using the released FastForward container, an executable provider example, English wiring/lifetime documentation, AGENTS.md and a portable consumer integration skill.
 - Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests.
 - English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment.
 - Weekly Composer and GitHub Actions update proposals through Dependabot.
diff --git a/README.md b/README.md
index d44b8ea..16b1760 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ The middleware restores handlers in `finally`, including when response rendering
 ## Documentation
 
 - [API and composition](docs/api.md): lifecycle, factories, handlers, containers and events.
+- [FastForward service provider](docs/fast-forward.md): callable definitions, application overrides and independent process/middleware handlers, with a [runnable example](examples/fast-forward.php).
 - [Testing and limitations](docs/testing.md): reproducible fire tests, exact observed platforms, isolation and failure boundaries.
 - [Migration from 2.0](docs/migration.md): breaking behavior and compatibility changes.
 - [Static documentation site](docs/index.html): install, composition and evidence at a glance. Open the file locally, or preview the `docs/` directory with a loopback static server.
@@ -62,6 +63,15 @@ GitHub Pages is prepared as a **manual** workflow restricted to `master`. After
 
 Dependabot is configured to propose Composer and GitHub Actions updates weekly after this configuration reaches the default branch. Updates remain reviewable pull requests.
 
+## Working with coding agents
+
+[AGENTS.md](AGENTS.md) is the repository maintenance entrypoint. The portable
+[error-handler-integration skill](.agents/skills/error-handler-integration/SKILL.md)
+helps an assistant choose a process or HTTP scope, wire the real provider API
+and validate reporting and callback restoration. It can be read directly or
+used as a skill folder by a compatible assistant; no global installation is
+required. Keep it with the matching package version when copying it elsewhere.
+
 ## Confidence and boundaries
 
 The test suite includes isolated real PHP failures rather than only calls to mocked handlers. Child processes have memory, time and output bounds; tests check termination, delivery counts and restoration. This is evidence for the exercised cases, not a reliability guarantee for every fatal error, PHP SAPI or downstream reporter. See the [failure matrix](docs/testing.md).
diff --git a/composer.json b/composer.json
index 44649dd..116fbfb 100644
--- a/composer.json
+++ b/composer.json
@@ -21,6 +21,7 @@
     },
     "require": {
         "php": "^8.3",
+        "container-interop/service-provider": "^0.4.1",
         "psr/container": "^1.1 || ^2.0",
         "psr/http-server-middleware": "^1.0",
         "psr/http-factory": "^1.1",
@@ -39,6 +40,7 @@
         }
     },
     "require-dev": {
+        "fast-forward/container": "^1.6",
         "friendsofphp/php-cs-fixer": "^3.95",
         "laminas/laminas-servicemanager": "^4.3",
         "nyholm/psr7": "^1.8",
@@ -47,6 +49,9 @@
         "phpunit/phpunit": "^12.5 || ^13.0",
         "squizlabs/php_codesniffer": "^4.0"
     },
+    "suggest": {
+        "fast-forward/container": "Use the service provider with FastForward (tested with ^1.6)."
+    },
     "scripts": {
         "check": [
             "@cs-check",
diff --git a/composer.lock b/composer.lock
index 8da8111..4119042 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,43 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "3fb4b5cf36d726d5fac54be222e04d90",
+    "content-hash": "e4a6b75d2104c8f62f570efb15551fc2",
     "packages": [
+        {
+            "name": "container-interop/service-provider",
+            "version": "v0.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/container-interop/service-provider.git",
+                "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/container-interop/service-provider/zipball/e04441ca21ef03e10dce70b0af29269281eec6dc",
+                "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc",
+                "shasum": ""
+            },
+            "require": {
+                "psr/container": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Interop\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Promoting container interoperability through standard service providers",
+            "homepage": "https://github.com/container-interop/service-provider",
+            "support": {
+                "issues": "https://github.com/container-interop/service-provider/issues",
+                "source": "https://github.com/container-interop/service-provider/tree/v0.4.1"
+            },
+            "time": "2023-12-14T14:50:12+00:00"
+        },
         {
             "name": "psr/container",
             "version": "2.0.2",
@@ -714,6 +749,81 @@
             ],
             "time": "2024-05-06T16:37:16+00:00"
         },
+        {
+            "name": "dflydev/dot-access-data",
+            "version": "v3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/dflydev/dflydev-dot-access-data.git",
+                "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
+                "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^0.12.42",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
+                "scrutinizer/ocular": "1.6.0",
+                "squizlabs/php_codesniffer": "^3.5",
+                "vimeo/psalm": "^4.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Dflydev\\DotAccessData\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Dragonfly Development Inc.",
+                    "email": "info@dflydev.com",
+                    "homepage": "http://dflydev.com"
+                },
+                {
+                    "name": "Beau Simensen",
+                    "email": "beau@dflydev.com",
+                    "homepage": "http://beausimensen.com"
+                },
+                {
+                    "name": "Carlos Frutos",
+                    "email": "carlos@kiwing.it",
+                    "homepage": "https://github.com/cfrutos"
+                },
+                {
+                    "name": "Colin O'Dell",
+                    "email": "colinodell@gmail.com",
+                    "homepage": "https://www.colinodell.com"
+                }
+            ],
+            "description": "Given a deep data structure, access data by dot notation.",
+            "homepage": "https://github.com/dflydev/dflydev-dot-access-data",
+            "keywords": [
+                "access",
+                "data",
+                "dot",
+                "notation"
+            ],
+            "support": {
+                "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
+                "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
+            },
+            "time": "2024-07-08T12:26:09+00:00"
+        },
         {
             "name": "ergebnis/agent-detector",
             "version": "1.2.0",
@@ -830,6 +940,123 @@
             },
             "time": "2023-08-08T05:53:35+00:00"
         },
+        {
+            "name": "fast-forward/config",
+            "version": "v1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fast-forward/config.git",
+                "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fast-forward/config/zipball/930434f7f7aaf02d551eb53ab86ed25e6511522c",
+                "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c",
+                "shasum": ""
+            },
+            "require": {
+                "dflydev/dot-access-data": "^3.0",
+                "laminas/laminas-config-aggregator": "^1.13",
+                "php": "^8.1",
+                "psr/container": "^1.0||^2.0",
+                "psr/simple-cache": "^3.0"
+            },
+            "require-dev": {
+                "coisa/php-cs-fixer": "^2.1",
+                "phpspec/prophecy-phpunit": "^2.3",
+                "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "FastForward\\Config\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Felipe Sayão Lobato Abreu",
+                    "email": "github@mentordosnerds.com"
+                }
+            ],
+            "description": "Fast Forward Config utility classes",
+            "homepage": "https://github.com/php-fast-forward",
+            "support": {
+                "issues": "https://github.com/php-fast-forward/config/issues",
+                "source": "https://github.com/php-fast-forward/config"
+            },
+            "time": "2025-06-11T14:36:02+00:00"
+        },
+        {
+            "name": "fast-forward/container",
+            "version": "v1.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fast-forward/container.git",
+                "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fast-forward/container/zipball/d9dd6d964189db80db0490cbe19dcd29841deba4",
+                "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4",
+                "shasum": ""
+            },
+            "require": {
+                "container-interop/service-provider": "^0.4.1",
+                "fast-forward/config": "^1.1",
+                "php": "^8.3",
+                "php-di/php-di": "^7.0",
+                "psr/container": "^1.0 || ^2.0"
+            },
+            "require-dev": {
+                "fast-forward/dev-tools": "dev-main"
+            },
+            "type": "library",
+            "extra": {
+                "grumphp": {
+                    "config-default-path": "vendor/fast-forward/dev-tools/grumphp.yml"
+                },
+                "branch-alias": {
+                    "dev-main": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "FastForward\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Felipe Sayão Lobato Abreu",
+                    "email": "github@mentordosnerds.com"
+                }
+            ],
+            "description": "Fast Forward PSR-11 Container utility classes",
+            "homepage": "https://github.com/php-fast-forward",
+            "support": {
+                "issues": "https://github.com/php-fast-forward/container/issues",
+                "source": "https://github.com/php-fast-forward/container"
+            },
+            "time": "2026-03-27T15:33:59+00:00"
+        },
         {
             "name": "fidry/cpu-core-counter",
             "version": "1.3.0",
@@ -995,6 +1222,74 @@
             ],
             "time": "2026-09-08T11:11:37+00:00"
         },
+        {
+            "name": "laminas/laminas-config-aggregator",
+            "version": "1.20.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laminas/laminas-config-aggregator.git",
+                "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laminas/laminas-config-aggregator/zipball/75331b17a9ba72c5fa8679988ef08a588ddd86af",
+                "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af",
+                "shasum": ""
+            },
+            "require": {
+                "brick/varexporter": "^0.5.0 || ^0.4.0 || ^0.6.0 || ^0.7.0",
+                "laminas/laminas-stdlib": "^3.18.0",
+                "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+                "webimpress/safe-writer": "^2.2.0"
+            },
+            "conflict": {
+                "nikic/php-parser": "<4.12",
+                "zendframework/zend-config-aggregator": "*"
+            },
+            "require-dev": {
+                "laminas/laminas-coding-standard": "~3.1.0",
+                "laminas/laminas-config": "^3.10.1",
+                "phpunit/phpunit": "^11.5.42",
+                "psalm/plugin-phpunit": "^0.19.5",
+                "vimeo/psalm": "^6.13.1"
+            },
+            "suggest": {
+                "laminas/laminas-config": "Allows loading configuration from XML, INI, YAML, and JSON files",
+                "laminas/laminas-config-aggregator-modulemanager": "Allows loading configuration from laminas-mvc Module classes",
+                "laminas/laminas-config-aggregator-parameters": "Allows usage of templated parameters within your configuration"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Laminas\\ConfigAggregator\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "description": "Lightweight library for collecting and merging configuration from different sources",
+            "homepage": "https://laminas.dev",
+            "keywords": [
+                "config-aggregator",
+                "laminas"
+            ],
+            "support": {
+                "chat": "https://laminas.dev/chat",
+                "docs": "https://docs.laminas.dev/laminas-config-aggregator/",
+                "forum": "https://discourse.laminas.dev",
+                "issues": "https://github.com/laminas/laminas-config-aggregator/issues",
+                "rss": "https://github.com/laminas/laminas-config-aggregator/releases.atom",
+                "source": "https://github.com/laminas/laminas-config-aggregator"
+            },
+            "funding": [
+                {
+                    "url": "https://funding.communitybridge.org/projects/laminas-project",
+                    "type": "community_bridge"
+                }
+            ],
+            "time": "2026-06-01T15:37:55+00:00"
+        },
         {
             "name": "laminas/laminas-servicemanager",
             "version": "4.5.1",
@@ -1139,6 +1434,67 @@
             ],
             "time": "2025-10-11T18:13:12+00:00"
         },
+        {
+            "name": "laravel/serializable-closure",
+            "version": "v2.0.16",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/serializable-closure.git",
+                "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
+                "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1"
+            },
+            "require-dev": {
+                "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
+                "nesbot/carbon": "^2.67|^3.0",
+                "pestphp/pest": "^2.36|^3.0|^4.0",
+                "phpstan/phpstan": "^2.0",
+                "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\SerializableClosure\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "taylor@laravel.com"
+                },
+                {
+                    "name": "Nuno Maduro",
+                    "email": "nuno@laravel.com"
+                }
+            ],
+            "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
+            "keywords": [
+                "closure",
+                "laravel",
+                "serializable"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/serializable-closure/issues",
+                "source": "https://github.com/laravel/serializable-closure"
+            },
+            "time": "2026-08-18T20:28:54+00:00"
+        },
         {
             "name": "myclabs/deep-copy",
             "version": "1.14.0",
@@ -1513,6 +1869,134 @@
             },
             "time": "2026-09-09T16:22:59+00:00"
         },
+        {
+            "name": "php-di/invoker",
+            "version": "2.3.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-DI/Invoker.git",
+                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
+                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "psr/container": "^1.0|^2.0"
+            },
+            "require-dev": {
+                "athletic/athletic": "~0.1.8",
+                "mnapoli/hard-mode": "~0.3.0",
+                "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Invoker\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Generic and extensible callable invoker",
+            "homepage": "https://github.com/PHP-DI/Invoker",
+            "keywords": [
+                "callable",
+                "dependency",
+                "dependency-injection",
+                "injection",
+                "invoke",
+                "invoker"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-DI/Invoker/issues",
+                "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/mnapoli",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-30T10:22:22+00:00"
+        },
+        {
+            "name": "php-di/php-di",
+            "version": "7.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-DI/PHP-DI.git",
+                "reference": "f88054cc052e40dbe7b383c8817c19442d480352"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
+                "reference": "f88054cc052e40dbe7b383c8817c19442d480352",
+                "shasum": ""
+            },
+            "require": {
+                "laravel/serializable-closure": "^1.0 || ^2.0",
+                "php": ">=8.0",
+                "php-di/invoker": "^2.0",
+                "psr/container": "^1.1 || ^2.0"
+            },
+            "provide": {
+                "psr/container-implementation": "^1.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^3",
+                "friendsofphp/proxy-manager-lts": "^1",
+                "mnapoli/phpunit-easymock": "^1.3",
+                "phpunit/phpunit": "^9.6 || ^10 || ^11",
+                "vimeo/psalm": "^5|^6"
+            },
+            "suggest": {
+                "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "DI\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "The dependency injection container for humans",
+            "homepage": "https://php-di.org/",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interop",
+                "dependency injection",
+                "di",
+                "ioc",
+                "psr11"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-DI/PHP-DI/issues",
+                "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/mnapoli",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-16T11:10:48+00:00"
+        },
         {
             "name": "phpstan/phpstan",
             "version": "2.2.14",
@@ -2052,6 +2536,57 @@
             ],
             "time": "2026-09-15T07:37:05+00:00"
         },
+        {
+            "name": "psr/simple-cache",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/simple-cache.git",
+                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\SimpleCache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interfaces for simple caching",
+            "keywords": [
+                "cache",
+                "caching",
+                "psr",
+                "psr-16",
+                "simple-cache"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+            },
+            "time": "2021-10-29T13:26:27+00:00"
+        },
         {
             "name": "react/cache",
             "version": "v1.2.0",
@@ -5383,6 +5918,65 @@
                 }
             ],
             "time": "2025-12-08T11:19:18+00:00"
+        },
+        {
+            "name": "webimpress/safe-writer",
+            "version": "2.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/webimpress/safe-writer.git",
+                "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/webimpress/safe-writer/zipball/9d37cc8bee20f7cb2f58f6e23e05097eab5072e6",
+                "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.3 || ^8.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5.4",
+                "vimeo/psalm": "^4.7",
+                "webimpress/coding-standard": "^1.2.2"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.2.x-dev",
+                    "dev-develop": "2.3.x-dev",
+                    "dev-release-1.0": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Webimpress\\SafeWriter\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "description": "Tool to write files safely, to avoid race conditions",
+            "keywords": [
+                "concurrent write",
+                "file writer",
+                "race condition",
+                "safe writer",
+                "webimpress"
+            ],
+            "support": {
+                "issues": "https://github.com/webimpress/safe-writer/issues",
+                "source": "https://github.com/webimpress/safe-writer/tree/2.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/michalbundyra",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-04-19T16:34:45+00:00"
         }
     ],
     "aliases": [],
diff --git a/docs/api.md b/docs/api.md
index f34bad9..47d8be7 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -57,6 +57,11 @@ Neither the middleware nor the global handler emits PSR-7 responses to a server.
 
 ## PSR-11 container integration
 
+`ServiceProvider\ErrorHandlerServiceProvider` implements the Interop contract
+used by FastForward, with callable factories and no automatic callback
+registration. See [FastForward integration](fast-forward.md) for process-service
+aliases, application overrides and fresh middleware scopes.
+
 `Container\ConfigProvider` exposes `__invoke()`, `getDependencies()`, `getAliases()` and `getFactories()` for compatible containers such as Laminas ServiceManager. `ErrorHandlerContainer` can wrap a parent PSR-11 container, delegates known services to it, and caches services built by the library factories.
 
 Provide `ThrowableHandlerInterface` for a custom reporter. Otherwise `ThrowableHandlerAggregateFactory` composes any available callable/event-dispatch reporters; an empty aggregate intentionally produces no diagnostics. HTTP factories also require PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface` services. PSR-14 integration needs `EventDispatcherInterface`. These implementations belong to the application; the package ships interfaces and adapters, not a logger, event bus or HTTP framework.
diff --git a/docs/fast-forward.md b/docs/fast-forward.md
new file mode 100644
index 0000000..835cd70
--- /dev/null
+++ b/docs/fast-forward.md
@@ -0,0 +1,136 @@
+# FastForward service provider
+
+This integration belongs to the unreleased next major of `coisa/error-handler`.
+It follows the provider contract used by FastForward Container 1.6.0 and
+Framework 1.4.0. The existing `CoiSA\ErrorHandler` namespace remains the current
+API; a possible future move to php-fast-forward has not happened.
+
+## Installation and bootstrap
+
+The library requires the small `container-interop/service-provider:^0.4.1`
+interface package. Its core and provider do not require FastForward or a full
+framework. Applications choosing FastForward install its container explicitly:
+
+```sh
+composer require fast-forward/container:^1.6
+```
+
+The development checkout already includes it in `require-dev`. After installing
+the checkout dependencies, run the complete [example](../examples/fast-forward.php):
+
+```sh
+php examples/fast-forward.php
+```
+
+It reports one warning and restores the prior PHP callbacks. Merge the library
+and application definitions into one provider, with the application last:
+
+```php
+use CoiSA\ErrorHandler\ErrorHandlerInterface;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use function FastForward\Container\container;
+
+// $application is the ServiceProviderInterface implementation in the example.
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+```
+
+For a process-wide handler, keep it registered through termination. For a scoped
+operation, use `try/finally` and unregister; the caller then owns any propagated
+exception. See [lifecycle](api.md#lifecycle) before combining these patterns.
+
+`getFactories()` declares lazy services; `getExtensions()` returns an empty
+array. Neither method, service construction nor `has()` installs PHP callbacks.
+There is no provider boot hook. The application decides when to register.
+
+## Definitions and overrides
+
+| Service | Provider behavior |
+| --- | --- |
+| `ErrorHandlerInterface` / `ErrorHandler` | The same cached process handler; initially unregistered |
+| `ThrowableHandlerInterface` | Default aggregate of available callable and PSR-14 reporters |
+| `DispatchThrowableHandler` / `DispatchErrorEventThrowableHandler` | Existing PSR-14 adapters; require an application dispatcher |
+| `ThrowableStreamFactoryInterface` / `ThrowableResponseFactoryInterface` | Existing rendering adapters; require PSR-17 stream/response factories |
+| `ErrorHandlerMiddleware` | Cached middleware with a handler separate from the process handler |
+| `Container\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation |
+| `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` | Reusable callable that creates fresh middleware and a fresh handler |
+
+Names in the table are relative to `CoiSA\ErrorHandler`; reporter classes are
+under `Handler`, HTTP adapters under `Http\Message`, and middleware under
+`Http\Middleware`.
+
+Bind `ThrowableHandlerInterface` in the application provider for an explicit
+reporter. Alternatively provide `CallableThrowableHandler` or
+`Psr\EventDispatcher\EventDispatcherInterface` to populate the aggregate. The
+PSR-14 path dispatches both the original throwable and an `ErrorEvent` wrapper;
+subscribe intentionally to avoid duplicate application reporting. An aggregate
+with no configured reporters is silent. No logger is silently installed.
+
+Some autowiring containers advertise `CallableThrowableHandler` through `has()`
+even when its required callable cannot be constructed. Optional discovery skips
+only a PSR-11 not-found result from `get()`; other construction exceptions are
+propagated. FastForward 1.6.0 can itself translate a failed concrete dependency
+into not-found, so the library cannot distinguish that from an absent optional
+reporter. Use an explicit `ThrowableHandlerInterface` binding for a required
+reporter and the merged-provider composition above; the next paragraph explains
+why both are needed.
+
+`AggregateServiceProvider` uses the last definition for each service ID, so the
+application's required reporter replaces the default. Passing separate providers
+to `container(...)` instead creates a fallback chain: FastForward may suppress
+the application's PSR container failure and use the library's silent aggregate.
+The merged form prevents that fallback and makes required-reporter failures
+fail construction of both process handler and middleware. FastForward still
+translates PSR failures to not-found; it does not preserve their original cause.
+An ordinary application exception continues to propagate.
+
+Optional `PhpErrorHandlerInterface` and `ShutdownHandlerInterface` bindings are
+passed to fresh handlers too. The provider reuses the package's PSR-11 factories
+and introduces no separate configuration format or automatic event subscriptions.
+The Laminas `ConfigProvider` and `ErrorHandlerContainer` remain available.
+
+## HTTP scopes and fresh instances
+
+Supply `Psr\Http\Message\ResponseFactoryInterface` and
+`Psr\Http\Message\StreamFactoryInterface`, for example using an application's
+Nyholm PSR-7 factory. The normal middleware service can process sequential
+requests and restores an already registered process handler after each one.
+
+FastForward caches `get()` results. For nested scopes, resolve a factory object
+and invoke it for each middleware instance:
+
+```php
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
+
+$makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class);
+$outer = $makeMiddleware($services);
+$inner = $makeMiddleware($services);
+```
+
+Likewise invoke the `Container\Factory\ErrorHandlerFactory` service to create
+fresh direct handlers. Avoid sharing the process handler with middleware and
+do not pre-register the middleware's own handler. The reusable factory pattern
+does not provide concurrent-request isolation: PHP callback stacks remain
+process-wide. Reuse instances for sequential work when practical because PHP
+retains registered shutdown callbacks until process exit.
+
+## Verification and migration readiness
+
+The integration tests use the real released FastForward container, including
+provider-class bootstrap, explicit and optional reporters, custom PHP/shutdown
+handlers, event dispatch, fresh factories, nested HTTP scopes and restoration
+after renderer failure. The full [test command](testing.md) retains the existing
+fatal-error and HTTP checks. CI also loads the provider without development
+dependencies, proving that installing the whole FastForward framework is not
+necessary to use the core library.
+
+This separates the public Interop/PSR contracts from the optional framework
+consumer. A later move still needs an explicit package/namespace decision,
+release and migration guidance; this provider is not a claim that those changes
+have already been made.
+
+Sources: [FastForward Container 1.6.0](https://github.com/php-fast-forward/container/tree/v1.6.0),
+[provider contract](https://github.com/container-interop/service-provider/tree/v0.4.1),
+[factory lifetime pattern](https://github.com/php-fast-forward/container/blob/v1.6.0/docs/advanced/special-patterns.rst).
diff --git a/docs/index.html b/docs/index.html
index a23a43b..9baa8f9 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -13,7 +13,7 @@
 
 
{!}CoiSA / Error HandlerView source ↗
- +
A small PHP library. An explicit failure contract.
@@ -50,6 +50,13 @@

Make failure
observable.

$errors->register();

Keep diagnostic output in an appropriate private sink. A reporter that emits nothing produces the generic HTTP body Internal Server Error.

+
+
Container integration

Declare services. Choose when to register.

+

The Interop service provider follows FastForward's callable factory definitions. Resolve the process handler through ErrorHandlerInterface, configure your reporter in an application provider, and explicitly register at bootstrap.

+

FastForward caches services. The middleware receives a separate handler; callable factory services create fresh instances for nested scopes. Core use and the provider remain independent of the full framework.

+
RUN THE COMPLETE PROVIDER EXAMPLE
php examples/fast-forward.php
+ +
03 / Observable behavior

Know what each boundary does.

Input or operationResult
Reportable warningOne error exception; execution continues.
@ or an excluded severityThe default reporter does not receive the warning.
Uncaught throwableReport the original object; exit 1 when reporting returns.
Direct handleThrowable()Report and return; the caller controls execution.
Nested registrationsUnwind in reverse order; only the active handler reports shutdown.
Reporter failure or recursionStop retrying that reporter and propagate the failure.
HTTP error responsePreserve integer codes 400–599; otherwise use 500. No exception text in the reason phrase.
diff --git a/docs/migration.md b/docs/migration.md index 166f184..15945ad 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2,6 +2,13 @@ This is the migration contract for the unreleased next major. It does not describe an already published Packagist release. +FastForward applications can now adopt the [Interop service provider](fast-forward.md) +without changing the existing package or namespace. Merge definitions with +`AggregateServiceProvider`, library first and application last, so a required +reporter's failure cannot fall back to the silent default. Registration remains explicit; +use factory services for fresh/nested scopes because container lookups are cached. +Existing Laminas and standalone PSR-11 integration remain supported. + 1. Upgrade the application to PHP 8.3, 8.4 or 8.5. Resolve Composer dependencies again. The checked-in development lock targets PHP 8.5; developers on 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible tools. Consumers resolve their own lock as usual. 2. Expect exit status **1** after a globally uncaught `Throwable` is reported successfully. Version 2 could exit with status 0. A reporter that explicitly terminates the process still controls its own exit code. Direct `handleThrowable()` calls do not exit. 3. Pair `register()` and `unregister()` in reverse order. A repeated `register()` on the same instance is idempotent, not reference counted. Out-of-order unregistration now throws `LogicException` instead of silently removing someone else's callback. Use separate instances for nested middleware and do not pre-register the middleware's own instance. diff --git a/docs/testing.md b/docs/testing.md index ac7bf86..e0fb5c3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -63,7 +63,7 @@ Independent review exposed two regressions in the first modernization draft: cus Native PHP 8.5.10 verification additionally runs PHPUnit 13.3.4, PHPStan 2.2.14 and three real HTTP requests through PHP's built-in server: 500, 503 and TypeError→500, all with a redacted body. The CI matrix checks lowest/highest compatible dependencies on PHP 8.3, highest on 8.4, and locked/highest on 8.5. A green result applies only to the exact checked commit; consult the [Actions run](https://github.com/coisa/error-handler/actions/workflows/ci.yml) rather than assuming that a configured matrix has passed. -The full `composer --no-plugins check` command passed on native PHP 8.5.10/PHPUnit 13.3.4 and isolated PHP 8.3.33 with both highest dependencies (PHPUnit 12.5.35) and lowest allowed dependencies (PHPUnit 12.5.8, PHPStan 2.1.0, PSR Container 1.1.0): 100 tests and 144 assertions, maximum-level source analysis, formatting, all 27 isolated scenarios and all three HTTP responses. The runnable CLI example was checked for exit status 1, empty stdout and both expected diagnostic messages on stderr. +Before the service-provider addition, the full `composer --no-plugins check` command passed on native PHP 8.5.10/PHPUnit 13.3.4 and isolated PHP 8.3.33 with both highest dependencies (PHPUnit 12.5.35) and lowest allowed dependencies (PHPUnit 12.5.8, PHPStan 2.1.0, PSR Container 1.1.0): 100 tests and 144 assertions, maximum-level source analysis, formatting, all 27 isolated scenarios and all three HTTP responses. The runnable CLI example was checked for exit status 1, empty stdout and both expected diagnostic messages on stderr. ```sh docker run --rm --network none --read-only --memory 128m --cpus 1 --pids-limit 32 \ @@ -82,6 +82,40 @@ Container builds resolve development packages for the requested PHP version. For - PHP 8.4.25: `sha256:2f389f933c3cc58cc622bd243bb4ecff7e6553e2de4387a239bca640c988be19` - PHP 8.5.10: `sha256:aa66ee4275e0472bafcd18e35142499fcbda6ac90e641e751418e052e6a0d812` +## Service-provider verification + +The provider follow-up passes the full check on macOS arm64/PHP 8.5.10 with +FastForward Container 1.6.0: **118 tests and 196 assertions**, maximum PHPStan, +formatting, the same 27 isolated scenarios and three HTTP responses. Eighteen new +cases cover real-container resolution, reporter overrides, optional discovery, +PSR-14 dispatch, custom PHP/shutdown callbacks, independent factories, nested +middleware and restoration after renderer failure. Missing optional reporters +are skipped only for PSR not-found; separate regressions require other +construction exceptions to propagate. + +Independent review reproduced silent reporter fallback when application and +library providers were passed separately to FastForward. Six real-container +regressions now cover required-reporter PSR construction/not-found failures and +ordinary application exceptions for both process handler and fully configured +middleware. Four cases failed with the old composition; all six pass with one +`AggregateServiceProvider`, library first and application last. The skill and +example use this same composition. FastForward may normalize a PSR failure to +not-found, so the contract promises failed construction rather than preservation +of the original exception cause. + +`php examples/fast-forward.php` exits 0, emits exactly +`Reported: FastForward provider warning` followed by a newline, and leaves stderr +empty. CI verifies that output. A separate production-only Composer installation +also loads the core and all 13 provider definitions without FastForward installed; +the required integration dependency is the small Interop interface package. +The five-job PHP/dependency CI matrix exercises the provider tests too; consult +the current run for commit-specific Linux evidence. + +The consumer skill's frontmatter and source references were validated, and its +integration procedure is checked against these same public APIs. Repository +maintenance guidance is in [AGENTS.md](../AGENTS.md); the detailed wiring contract +is in [FastForward integration](fast-forward.md). + ## Boundaries and troubleshooting - PHP must have `proc_open()` enabled for crash/HTTP tests. Unit tests require the normal PHPUnit extensions. The HTTP runner binds only to a dynamically selected `127.0.0.1` port, waits at most five seconds to start, gives each request two seconds, and terminates its fixture server in `finally`. diff --git a/examples/fast-forward.php b/examples/fast-forward.php new file mode 100644 index 0000000..2de5988 --- /dev/null +++ b/examples/fast-forward.php @@ -0,0 +1,41 @@ + static fn (): CallableThrowableHandler => + new CallableThrowableHandler(static function (Throwable $error): void { + printf("Reported: %s\n", $error->getMessage()); + }), + ]; + } + + public function getExtensions(): array + { + return []; + } +}; + +$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +/** @var ErrorHandlerInterface $errors */ +$errors = $services->get(ErrorHandlerInterface::class); +$errors->register(); +try { + trigger_error('FastForward provider warning', E_USER_WARNING); +} finally { + $errors->unregister(); +} diff --git a/src/Container/Factory/ThrowableHandlerAggregateFactory.php b/src/Container/Factory/ThrowableHandlerAggregateFactory.php index 23f2879..28a1067 100644 --- a/src/Container/Factory/ThrowableHandlerAggregateFactory.php +++ b/src/Container/Factory/ThrowableHandlerAggregateFactory.php @@ -21,6 +21,7 @@ use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\EventDispatcher\EventDispatcherInterface; /** @@ -50,9 +51,13 @@ public function __invoke(ContainerInterface $container): ThrowableHandlerAggrega $handler = new ThrowableHandlerAggregate(); if ($container->has(CallableThrowableHandler::class)) { - /** @var CallableThrowableHandler $callableHandler */ - $callableHandler = $container->get(CallableThrowableHandler::class); - $handler->attach($callableHandler); + try { + /** @var CallableThrowableHandler $callableHandler */ + $callableHandler = $container->get(CallableThrowableHandler::class); + $handler->attach($callableHandler); + } catch (NotFoundExceptionInterface) { + // Autowiring may advertise a class whose required callable is not configured. + } } if ($container->has(EventDispatcherInterface::class)) { diff --git a/src/ServiceProvider/ErrorHandlerServiceProvider.php b/src/ServiceProvider/ErrorHandlerServiceProvider.php new file mode 100644 index 0000000..1aaf64c --- /dev/null +++ b/src/ServiceProvider/ErrorHandlerServiceProvider.php @@ -0,0 +1,57 @@ + */ + public function getFactories(): array + { + return [ + ErrorHandlerInterface::class => new AliasFactory(ErrorHandler::class), + ErrorHandler::class => new ErrorHandlerFactory(), + ErrorHandlerFactory::class => static fn (): ErrorHandlerFactory => new ErrorHandlerFactory(), + ThrowableHandlerInterface::class => new AliasFactory(ThrowableHandlerAggregate::class), + ThrowableHandlerAggregate::class => new ThrowableHandlerAggregateFactory(), + DispatchThrowableHandler::class => new DispatchThrowableHandlerFactory(), + DispatchErrorEventThrowableHandler::class => new DispatchErrorEventThrowableHandlerFactory(), + ThrowableResponseFactoryInterface::class => new AliasFactory(ThrowableResponseFactory::class), + ThrowableResponseFactory::class => new ThrowableResponseFactoryFactory(), + ThrowableStreamFactoryInterface::class => new AliasFactory(ThrowableStreamFactory::class), + ThrowableStreamFactory::class => new ThrowableStreamFactoryFactory(), + ErrorHandlerMiddleware::class => new ErrorHandlerMiddlewareFactory(), + ErrorHandlerMiddlewareFactory::class => static fn (): ErrorHandlerMiddlewareFactory => + new ErrorHandlerMiddlewareFactory(), + ]; + } + + /** @return array */ + public function getExtensions(): array + { + return []; + } +} diff --git a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php new file mode 100644 index 0000000..c7ed5d3 --- /dev/null +++ b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php @@ -0,0 +1,24 @@ +get(ErrorHandlerFactory::class); + /** @var ThrowableResponseFactoryInterface $responses */ + $responses = $container->get(ThrowableResponseFactoryInterface::class); + + return new ErrorHandlerMiddleware($handlerFactory($container), $responses); + } +} diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php new file mode 100644 index 0000000..12ba547 --- /dev/null +++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php @@ -0,0 +1,381 @@ +has(ErrorHandlerInterface::class)); + $handler = $container->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + self::assertSame($handler, $container->get(ErrorHandler::class)); + self::assertSame($handler, $container->get(ErrorHandlerInterface::class)); + self::assertSame($before, self::callbacks()); + $handler->handleThrowable(new \RuntimeException('No reporter is configured.')); + } + + public function testApplicationReporterOverridesTheDefaultAggregate(): void + { + $received = []; + $reporter = new CallableThrowableHandler(static function (\Throwable $error) use (&$received): void { + $received[] = $error; + }); + $container = self::services([ThrowableHandlerInterface::class => $reporter]); + $error = new \RuntimeException('Custom reporter'); + + self::assertSame($reporter, $container->get(ThrowableHandlerInterface::class)); + $container->get(ErrorHandlerInterface::class)->handleThrowable($error); + + self::assertSame([$error], $received); + } + + #[DataProvider('requiredReporterFailures')] + public function testRequiredReporterFailureCannotFallBackToASilentDefault( + string $service, + \Throwable $failure, + string $expectedException + ): void { + $application = new class ($failure) implements ServiceProviderInterface { + public function __construct(private \Throwable $failure) + { + } + + public function getFactories(): array + { + return [ + ThrowableHandlerInterface::class => function (): never { + throw $this->failure; + }, + ResponseFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(), + StreamFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(), + ]; + } + + public function getExtensions(): array + { + return []; + } + }; + $container = self::withApplication($application); + $this->expectException($expectedException); + + $container->get($service); + } + + public static function requiredReporterFailures(): iterable + { + $failures = [ + 'PSR construction failure' => [ + new class ('Reporter failed') extends \RuntimeException implements ContainerExceptionInterface { + }, + ContainerExceptionInterface::class, + ], + 'PSR missing dependency' => [ + new class ('Missing dependency') extends \RuntimeException implements NotFoundExceptionInterface { + }, + ContainerExceptionInterface::class, + ], + 'application failure' => [new \LogicException('Reporter failed'), \LogicException::class], + ]; + foreach ([ErrorHandlerInterface::class, ErrorHandlerMiddleware::class] as $service) { + foreach ($failures as $label => [$failure, $expectedException]) { + yield $service . ' / ' . $label => [$service, $failure, $expectedException]; + } + } + } + + public function testAnOptionalCallableReporterIsComposedWithoutAnEventDispatcher(): void + { + $received = []; + $container = self::services([ + CallableThrowableHandler::class => new CallableThrowableHandler( + static function (\Throwable $error) use (&$received): void { + $received[] = $error; + } + ), + ]); + $error = new \RuntimeException('Callable reporter'); + + $container->get(ErrorHandlerInterface::class)->handleThrowable($error); + + self::assertSame([$error], $received); + } + + public function testOptionalPsr14DispatcherReceivesTheThrowableAndErrorEvent(): void + { + $dispatcher = new class () implements EventDispatcherInterface { + public array $events = []; + + public function dispatch(object $event): object + { + $this->events[] = $event; + return $event; + } + }; + $container = self::services([EventDispatcherInterface::class => $dispatcher]); + $error = new \RuntimeException('Dispatched'); + + $container->get(ErrorHandlerInterface::class)->handleThrowable($error); + + self::assertCount(2, $dispatcher->events); + self::assertSame($error, $dispatcher->events[0]); + self::assertInstanceOf(ErrorEvent::class, $dispatcher->events[1]); + self::assertSame($error, $dispatcher->events[1]->getThrowable()); + } + + #[WithoutErrorHandler] + public function testCustomPhpAndShutdownHandlersAreInjected(): void + { + $phpHandler = new class () implements PhpErrorHandlerInterface { + public array $messages = []; + + public function handlePhpError(int $errno, string $message, string $file, int $line): void + { + $this->messages[] = $message; + } + }; + $shutdowns = 0; + $container = self::services([ + PhpErrorHandlerInterface::class => $phpHandler, + ShutdownHandlerInterface::class => new CallableShutdownHandler( + static function () use (&$shutdowns): void { + ++$shutdowns; + } + ), + ]); + $handler = $container->get(ErrorHandler::class); + $before = self::callbacks(); + $handler->register(); + try { + trigger_error('Custom PHP handler', E_USER_WARNING); + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + + self::assertSame(['Custom PHP handler'], $phpHandler->messages); + self::assertSame(1, $shutdowns); + self::assertSame($before, self::callbacks()); + } + + public function testReusableFactoryCreatesFreshHandlersWithoutReplacingTheProcessService(): void + { + $container = self::services(); + $process = $container->get(ErrorHandlerInterface::class); + $factory = $container->get(ErrorHandlerFactory::class); + $first = $factory($container); + $second = $factory($container); + + self::assertInstanceOf(ErrorHandler::class, $first); + self::assertNotSame($first, $second); + self::assertNotSame($first, $process); + self::assertSame($process, $container->get(ErrorHandlerInterface::class)); + } + + #[WithoutErrorHandler] + public function testMiddlewareRestoresTheAlreadyRegisteredProcessHandler(): void + { + $received = []; + $container = self::httpServices($received); + $process = $container->get(ErrorHandlerInterface::class); + $process->register(); + try { + $before = self::callbacks(); + $middleware = $container->get(ErrorHandlerMiddleware::class); + self::assertSame($before, self::callbacks()); + $failure = new \RuntimeException('Private diagnostics', 503); + $response = $middleware->process(self::request(), self::failsWith($failure)); + + self::assertSame(503, $response->getStatusCode()); + self::assertSame('Internal Server Error', (string) $response->getBody()); + self::assertSame($before, self::callbacks()); + trigger_error('After middleware', E_USER_WARNING); + } finally { + $process->unregister(); + } + + self::assertCount(2, $received); + self::assertSame($failure, $received[0]); + self::assertInstanceOf(ErrorException::class, $received[1]); + self::assertSame('After middleware', $received[1]->getMessage()); + } + + #[WithoutErrorHandler] + public function testFactoryCreatesIndependentMiddlewareForNestedScopes(): void + { + $received = []; + $container = self::httpServices($received); + $factory = $container->get(ErrorHandlerMiddlewareFactory::class); + $outer = $factory($container); + $inner = $factory($container); + $failure = new \RuntimeException('Nested'); + $downstream = new class ($inner, self::failsWith($failure)) implements RequestHandlerInterface { + public function __construct( + private ErrorHandlerMiddleware $inner, + private RequestHandlerInterface $downstream + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->inner->process($request, $this->downstream); + } + }; + $process = $container->get(ErrorHandlerInterface::class); + $process->register(); + try { + $before = self::callbacks(); + $response = $outer->process(self::request(), $downstream); + self::assertSame(500, $response->getStatusCode()); + self::assertSame($before, self::callbacks()); + } finally { + $process->unregister(); + } + + self::assertNotSame($outer, $inner); + self::assertSame([$failure], $received); + } + + #[WithoutErrorHandler] + public function testRendererFailureRestoresTheProcessHandler(): void + { + $failure = new \RuntimeException('Renderer failed'); + $renderer = new class ($failure) implements ThrowableResponseFactoryInterface { + public function __construct(private \Throwable $failure) + { + } + + public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface + { + throw $this->failure; + } + }; + $container = self::services([ThrowableResponseFactoryInterface::class => $renderer]); + $process = $container->get(ErrorHandlerInterface::class); + $process->register(); + try { + $before = self::callbacks(); + $middleware = $container->get(ErrorHandlerMiddleware::class); + try { + $middleware->process(self::request(), self::failsWith(new \RuntimeException('Original'))); + self::fail('The renderer failure must propagate.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame($before, self::callbacks()); + } finally { + $process->unregister(); + } + } + + /** @param array $services */ + private static function services(array $services = []): ContainerInterface + { + $application = new class ($services) implements ServiceProviderInterface { + public function __construct(private array $services) + { + } + + public function getFactories(): array + { + return array_map(static fn (object $service): \Closure => + static fn (): object => $service, $this->services); + } + + public function getExtensions(): array + { + return []; + } + }; + + return self::withApplication($application); + } + + private static function withApplication(ServiceProviderInterface $application): ContainerInterface + { + return container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + } + + private static function httpServices(array &$received): ContainerInterface + { + $psr17 = new Psr17Factory(); + + return self::services([ + ThrowableHandlerInterface::class => new CallableThrowableHandler( + static function (\Throwable $error) use (&$received): void { + $received[] = $error; + } + ), + ResponseFactoryInterface::class => $psr17, + StreamFactoryInterface::class => $psr17, + ]); + } + + private static function request(): ServerRequestInterface + { + return (new Psr17Factory())->createServerRequest('GET', '/provider-test'); + } + + private static function failsWith(\Throwable $failure): RequestHandlerInterface + { + return new class ($failure) implements RequestHandlerInterface { + public function __construct(private \Throwable $failure) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw $this->failure; + } + }; + } + + private static function callbacks(): array + { + $error = set_error_handler(static fn (): bool => false); + restore_error_handler(); + $exception = set_exception_handler(static function (\Throwable $error): void { + }); + restore_exception_handler(); + + return [$error, $exception]; + } +} diff --git a/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php b/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php new file mode 100644 index 0000000..9e84712 --- /dev/null +++ b/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php @@ -0,0 +1,57 @@ +containerFailingWith($missing); + $aggregate = (new ThrowableHandlerAggregateFactory())($container); + + $this->expectOutputString(''); + $aggregate->handleThrowable(new \RuntimeException('No reporter')); + } + + #[DataProvider('constructionFailures')] + public function testConstructionErrorsAreNotHidden(\Throwable $failure): void + { + $container = $this->containerFailingWith($failure); + $this->expectExceptionObject($failure); + + (new ThrowableHandlerAggregateFactory())($container); + } + + public static function constructionFailures(): iterable + { + yield 'PSR construction failure' => [ + new class ('Invalid reporter') extends \RuntimeException implements ContainerExceptionInterface { + }, + ]; + yield 'application failure' => [new \LogicException('Reporter initialization failed')]; + } + + private function containerFailingWith(\Throwable $failure): ContainerInterface + { + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturnCallback( + static fn (string $id): bool => $id === CallableThrowableHandler::class + ); + $container->expects(self::once())->method('get') + ->with(CallableThrowableHandler::class)->willThrowException($failure); + + return $container; + } +} From 75137ca20aa825c6816b4c0b686a4a46fbd04003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Tue, 15 Sep 2026 22:18:56 -0300 Subject: [PATCH 04/27] docs: identify the Error Handler skill by its package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .../SKILL.md | 11 ++++++++--- AGENTS.md | 4 +++- CHANGELOG.md | 2 +- README.md | 2 +- docs/index.html | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) rename .agents/skills/{error-handler-integration => coisa-error-handler}/SKILL.md (89%) diff --git a/.agents/skills/error-handler-integration/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md similarity index 89% rename from .agents/skills/error-handler-integration/SKILL.md rename to .agents/skills/coisa-error-handler/SKILL.md index 0697139..d0cbe9a 100644 --- a/.agents/skills/error-handler-integration/SKILL.md +++ b/.agents/skills/coisa-error-handler/SKILL.md @@ -1,10 +1,15 @@ --- -name: error-handler-integration -description: Integrate coisa/error-handler into PHP applications using explicit process handlers, FastForward service providers or PSR-15 middleware, and verify reporting and callback restoration. +name: coisa-error-handler +description: Integrate CoiSA Error Handler (coisa/error-handler) into PHP applications using explicit process handlers, FastForward service providers or PSR-15 middleware, and verify reporting and callback restoration. license: MIT --- -# Integrate Error Handler +# CoiSA Error Handler integration + +Package: `coisa/error-handler`. Source and maintenance: +[coisa/error-handler on GitHub](https://github.com/coisa/error-handler). +This skill belongs to that library and retains its package identity when copied +into a consuming project. Use the installed package or selected checkout as the API source. This skill describes the PHP 8.3+ development line after 2.0; verify the version before diff --git a/AGENTS.md b/AGENTS.md index 836e34a..a597c51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed ## Local Contracts - Write code, public documentation, examples and agent guidance in English. +- Identify the public consumer skill as `coisa-error-handler`, matching its + Composer package, and retain the source repository link for copied installs. - Target PHP 8.3–8.5; the current branch describes an unreleased major after 2.0. Do not present branch behavior as an already published Composer release. - Keep core handlers independent of FastForward. The service provider uses @@ -64,7 +66,7 @@ Check documentation links and inspect the actual CI result for the tested SHA. cached services and fresh middleware scopes. - [docs/testing.md](docs/testing.md): failure runners, commands and evidence limits. - [docs/migration.md](docs/migration.md): breaking changes from published 2.0. -- [.agents/skills/error-handler-integration/SKILL.md](.agents/skills/error-handler-integration/SKILL.md): +- [.agents/skills/coisa-error-handler/SKILL.md](.agents/skills/coisa-error-handler/SKILL.md): portable consumer integration procedure; no global installation required. No subtree needs a separate maintenance contract; the skill's procedure stays diff --git a/CHANGELOG.md b/CHANGELOG.md index 008bef5..b6f7ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ ### Added - An Interop service provider matching FastForward's callable definitions, explicit process-handler binding and factory services for independent middleware scopes. FastForward remains optional at runtime. -- Integration tests using the released FastForward container, an executable provider example, English wiring/lifetime documentation, AGENTS.md and a portable consumer integration skill. +- Integration tests using the released FastForward container, an executable provider example, English wiring/lifetime documentation, AGENTS.md and the portable `coisa-error-handler` consumer skill with explicit package provenance. - Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests. - English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment. - Weekly Composer and GitHub Actions update proposals through Dependabot. diff --git a/README.md b/README.md index 16b1760..8cd5f26 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Dependabot is configured to propose Composer and GitHub Actions updates weekly a ## Working with coding agents [AGENTS.md](AGENTS.md) is the repository maintenance entrypoint. The portable -[error-handler-integration skill](.agents/skills/error-handler-integration/SKILL.md) +[coisa-error-handler skill](.agents/skills/coisa-error-handler/SKILL.md) helps an assistant choose a process or HTTP scope, wire the real provider API and validate reporting and callback restoration. It can be read directly or used as a skill folder by a compatible assistant; no global installation is diff --git a/docs/index.html b/docs/index.html index 9baa8f9..79eaace 100644 --- a/docs/index.html +++ b/docs/index.html @@ -55,7 +55,7 @@

Make failure
observable.

The Interop service provider follows FastForward's callable factory definitions. Resolve the process handler through ErrorHandlerInterface, configure your reporter in an application provider, and explicitly register at bootstrap.

FastForward caches services. The middleware receives a separate handler; callable factory services create fresh instances for nested scopes. Core use and the provider remain independent of the full framework.

RUN THE COMPLETE PROVIDER EXAMPLE
php examples/fast-forward.php
- +
03 / Observable behavior

Know what each boundary does.

From ecede6434d3b3655c4a71c9d76833f857c56c15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 14 Oct 2026 23:13:54 -0300 Subject: [PATCH 05/27] refactor: adopt FastForward autowiring and complete PHPDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone container and constructor-only factories with FastForward runtime integration while retaining explicit lifecycle factories and the Laminas adapter. Document all changed PHP declarations and cover autowiring, required renderer failures and reporter reset. Signed-off-by: Felipe Sayão Lobato Abreu --- .agents/skills/coisa-error-handler/SKILL.md | 12 + .github/workflows/ci.yml | 2 +- AGENTS.md | 11 +- CHANGELOG.md | 5 +- README.md | 7 +- composer.json | 5 +- composer.lock | 1576 ++++++++--------- docs/api.md | 12 +- docs/fast-forward.md | 41 +- docs/index.html | 4 +- docs/migration.md | 32 +- docs/testing.md | 36 +- examples/cli.php | 12 + examples/fast-forward.php | 26 +- src/Container/ConfigProvider.php | 32 +- src/Container/ErrorHandlerContainer.php | 150 -- .../Exception/ContainerException.php | 49 - src/Container/Exception/NotFoundException.php | 49 - src/Container/Factory/AliasFactory.php | 59 - ...patchErrorEventThrowableHandlerFactory.php | 51 - .../DispatchThrowableHandlerFactory.php | 50 - .../Factory/ErrorHandlerMiddlewareFactory.php | 55 - .../ThrowableHandlerAggregateFactory.php | 12 +- .../ThrowableResponseFactoryFactory.php | 56 - .../Factory/ThrowableStreamFactoryFactory.php | 56 - src/ErrorHandler.php | 53 +- src/Http/Message/ThrowableStreamFactory.php | 5 + .../ErrorHandlerServiceProvider.php | 59 +- .../Factory/ErrorHandlerMiddlewareFactory.php | 33 +- tests/Fire/bootstrap.php | 18 + tests/Fire/run.php | 14 + tests/Fire/scenario.php | 36 + ....php => ConfigProviderIntegrationTest.php} | 117 +- .../Container/ConfigProviderTest.php | 76 +- .../Middleware/ErrorHandlerCleanupTest.php | 41 + .../Middleware/ErrorHandlerMiddlewareTest.php | 20 +- .../ErrorHandlerServiceProviderTest.php | 290 ++- tests/Http/router.php | 19 + tests/Http/run.php | 12 + tests/StaticAnalysis/output-buffer.stub | 12 + .../Container/ErrorHandlerContainerTest.php | 71 - .../ThrowableHandlerAggregateFactoryTest.php | 39 + .../Message/ThrowableResponseFactoryTest.php | 42 + 43 files changed, 1791 insertions(+), 1566 deletions(-) delete mode 100644 src/Container/ErrorHandlerContainer.php delete mode 100644 src/Container/Exception/ContainerException.php delete mode 100644 src/Container/Exception/NotFoundException.php delete mode 100644 src/Container/Factory/AliasFactory.php delete mode 100644 src/Container/Factory/DispatchErrorEventThrowableHandlerFactory.php delete mode 100644 src/Container/Factory/DispatchThrowableHandlerFactory.php delete mode 100644 src/Container/Factory/ErrorHandlerMiddlewareFactory.php delete mode 100644 src/Container/Factory/ThrowableResponseFactoryFactory.php delete mode 100644 src/Container/Factory/ThrowableStreamFactoryFactory.php rename tests/Functional/Container/{ErrorHandlerContainerTest.php => ConfigProviderIntegrationTest.php} (73%) delete mode 100644 tests/Unit/Container/ErrorHandlerContainerTest.php diff --git a/.agents/skills/coisa-error-handler/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md index d0cbe9a..6c3b47c 100644 --- a/.agents/skills/coisa-error-handler/SKILL.md +++ b/.agents/skills/coisa-error-handler/SKILL.md @@ -50,6 +50,13 @@ Use `CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider` with `FastForward\Container\ServiceProvider\AggregateServiceProvider`: library first, application **last**, then pass that one provider into `container(...)`. +FastForward Container is a runtime dependency in this development line. +`CoiSA\ErrorHandler\Container\ErrorHandlerContainer` has been removed. Concrete +dispatch and HTTP adapters use constructor autowiring; the provider retains +explicit factories for optional callbacks, reporter composition and fresh scopes. +The Laminas `ConfigProvider` adapter remains available with generic FastForward +factory definitions. Consult the matching migration guide for removed factories. + Separate variadic providers allow FastForward to fall back to the library's silent default if the application's reporter fails with a PSR container error. Merged definitions replace that default and make required-reporter construction @@ -67,6 +74,11 @@ invoke it with the container. For fresh middleware, resolve and invoke `CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`. Do not mistake repeated `get()` calls for a transient lifetime. +Bind required custom renderers through `ThrowableStreamFactoryInterface` or +`ThrowableResponseFactoryInterface`. FastForward may discard a concrete class +definition's PSR construction failure and rebuild it through autowiring; explicit +interface bindings prevent that fallback. Keep application definitions last. + ## Wire HTTP deliberately Supply PSR-17 response and stream factories. The provider creates middleware diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d9ab23..e5b1092 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,4 +62,4 @@ jobs: - name: Verify production-only autoload run: | composer install --no-dev --no-plugins --no-scripts --no-interaction --no-progress - php -r 'require "vendor/autoload.php"; new CoiSA\ErrorHandler\Container\ErrorHandlerContainer(); (new CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider())->getFactories(); if (class_exists(FastForward\Container\AggregateContainer::class)) { throw new RuntimeException("FastForward must remain optional"); }' + php -r 'require "vendor/autoload.php"; $provider = new CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider(); $services = FastForward\Container\container($provider); $handler = $services->get(CoiSA\ErrorHandler\ErrorHandlerInterface::class); if (!$handler instanceof CoiSA\ErrorHandler\ErrorHandler || count($provider->getFactories()) !== 9) { throw new RuntimeException("Production provider resolution failed"); }' diff --git a/AGENTS.md b/AGENTS.md index a597c51..df89609 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed - Target PHP 8.3–8.5; the current branch describes an unreleased major after 2.0. Do not present branch behavior as an already published Composer release. - Keep core handlers independent of FastForward. The service provider uses - `Interop\Container\ServiceProviderInterface`; FastForward is an optional - consumer and a development integration test dependency. + `Interop\Container\ServiceProviderInterface` and FastForward's reusable factory + classes. FastForward Container is a required runtime dependency and owns + service resolution, caching and autowiring; do not add a parallel container. - Constructing or discovering a service must not call `register()`. Native PHP callbacks are process-wide. Unregister in reverse order and use distinct handlers for nested scopes. Follow [the lifecycle contract](docs/api.md). @@ -29,7 +30,9 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed process handler and use the provider's factory services for fresh scopes. Merge application overrides after library definitions in one `AggregateServiceProvider`, avoiding silent reporter fallback on PSR errors. -- Keep the Laminas ConfigProvider and PSR-11 integration working. A possible +- Keep the Laminas ConfigProvider adapter working. Preserve explicit factories + for reporter composition, optional handlers and fresh middleware scopes; + use autowiring for plain constructor injection. A possible move to php-fast-forward does not itself change package identity or namespace. - Reporters own diagnostic output; HTTP fallback bodies must not leak exception details. A shutdown callback reports a fatal error and does not recover it. @@ -53,7 +56,7 @@ the owner's corresponding instruction. Preparing a PR does not perform them. Use Composer 2 with `--no-plugins --no-scripts` for dependency installation; then run `composer --no-plugins check`. This runs formatting, PHPUnit, maximum PHPStan, bounded fire tests and loopback HTTP checks. See [testing](docs/testing.md) -for PHP-version resolution, optional dependencies and actual platform evidence. +for PHP-version resolution, application dependencies and actual platform evidence. Run `php examples/fast-forward.php` after provider changes. Verify a production installation without development dependencies still loads the core and provider. Check documentation links and inspect the actual CI result for the tested SHA. diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f7ead..70f55fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Reject out-of-order `unregister()` calls without removing another handler. Only the active registration handles shutdown events. - Return a generic HTTP body when a reporter emits nothing; use standard reason phrases instead of exception messages. Preserve valid error status codes through 599. - Update PHPUnit, PHPStan, PHP CS Fixer, PHP_CodeSniffer, Docker and CI. Declare PSR-11 as a runtime dependency. +- Require FastForward Container at runtime and reuse its alias/invokable factories. Remove the standalone `ErrorHandlerContainer`, its alias factory and container exceptions, and constructor-only dispatch/HTTP factories; use autowiring for plain injection. Preserve the Laminas adapter with generic factory definitions. ### Fixed @@ -21,9 +22,11 @@ ### Added -- An Interop service provider matching FastForward's callable definitions, explicit process-handler binding and factory services for independent middleware scopes. FastForward remains optional at runtime. +- An Interop service provider with nine explicit definitions, constructor autowiring, process-handler binding and factory services for independent middleware scopes. +- Complete multiline PHPDoc for changed source, tests and examples, including lifecycle state, failure contracts and typed test data. - Integration tests using the released FastForward container, an executable provider example, English wiring/lifetime documentation, AGENTS.md and the portable `coisa-error-handler` consumer skill with explicit package provenance. - Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests. +- Regression coverage for constructor autowiring, concrete adapter overrides, required renderer interface failures and reporter reset after re-registration; document FastForward's concrete-class fallback boundary. - English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment. - Weekly Composer and GitHub Actions update proposals through Dependabot. diff --git a/README.md b/README.md index 8cd5f26..369e23f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CI](https://github.com/coisa/error-handler/actions/workflows/ci.yml/badge.svg)](https://github.com/coisa/error-handler/actions/workflows/ci.yml) [![Total Downloads][ico-downloads]][link-downloads] -Small, composable PHP error handling with PSR-11 containers, PSR-14 events and PSR-15 middleware. Report warnings, handle uncaught throwables and inspect fatal errors at shutdown through the same reporter interface. +Small, composable PHP error handling with FastForward Container, PSR-14 events and PSR-15 middleware. Report warnings, handle uncaught throwables and inspect fatal errors at shutdown through the same reporter interface. **Development documentation for the next major release.** This branch requires PHP 8.3–8.5 and changes lifecycle, CLI exit and HTTP response behavior. These changes are not part of the published 2.0.0 release. Read the [migration guide](docs/migration.md) before adopting this branch. @@ -45,6 +45,11 @@ throw new RuntimeException('An uncaught exception; the process exits 1.'); Load Composer's autoloader before the snippet. A normal warning is reported and execution continues. Masked/suppressed warnings are ignored. Direct `handleThrowable()` calls do not terminate the process; only the registered uncaught-exception callback does. Reporters are responsible for their output and privacy. +FastForward Container is a runtime dependency in this development line. Use the +[service provider](docs/fast-forward.md) for container wiring; `ErrorHandlerContainer` +has been removed. Direct construction remains available, and `ConfigProvider` +continues to adapt the library to Laminas ServiceManager. + ## HTTP integration Compose `ErrorHandlerMiddleware` with a `ThrowableResponseFactory` and a PSR-17 implementation. The complete, tested [HTTP example](tests/Http/router.php) uses Nyholm PSR-7. Run its bounded smoke test with `php tests/Http/run.php`; it starts and cleans up a loopback-only server automatically. diff --git a/composer.json b/composer.json index 116fbfb..cc5f8bc 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "require": { "php": "^8.3", "container-interop/service-provider": "^0.4.1", + "fast-forward/container": "^1.6", "psr/container": "^1.1 || ^2.0", "psr/http-server-middleware": "^1.0", "psr/http-factory": "^1.1", @@ -40,7 +41,6 @@ } }, "require-dev": { - "fast-forward/container": "^1.6", "friendsofphp/php-cs-fixer": "^3.95", "laminas/laminas-servicemanager": "^4.3", "nyholm/psr7": "^1.8", @@ -49,9 +49,6 @@ "phpunit/phpunit": "^12.5 || ^13.0", "squizlabs/php_codesniffer": "^4.0" }, - "suggest": { - "fast-forward/container": "Use the service provider with FastForward (tested with ^1.6)." - }, "scripts": { "check": [ "@cs-check", diff --git a/composer.lock b/composer.lock index 4119042..029c7da 100644 --- a/composer.lock +++ b/composer.lock @@ -4,42 +4,656 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4a6b75d2104c8f62f570efb15551fc2", + "content-hash": "66fe96e6efc2b11347f556d30a1da324", "packages": [ + { + "name": "brick/varexporter", + "version": "0.6.0", + "source": { + "type": "git", + "url": "https://github.com/brick/varexporter.git", + "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/varexporter/zipball/af98bfc2b702a312abbcaff37656dbe419cec5bc", + "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "6.8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\VarExporter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", + "keywords": [ + "var_export" + ], + "support": { + "issues": "https://github.com/brick/varexporter/issues", + "source": "https://github.com/brick/varexporter/tree/0.6.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-02-20T17:42:39+00:00" + }, { "name": "container-interop/service-provider", "version": "v0.4.1", "source": { "type": "git", - "url": "https://github.com/container-interop/service-provider.git", - "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc" + "url": "https://github.com/container-interop/service-provider.git", + "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/container-interop/service-provider/zipball/e04441ca21ef03e10dce70b0af29269281eec6dc", + "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc", + "shasum": "" + }, + "require": { + "psr/container": "^1.0 || ^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Interop\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Promoting container interoperability through standard service providers", + "homepage": "https://github.com/container-interop/service-provider", + "support": { + "issues": "https://github.com/container-interop/service-provider/issues", + "source": "https://github.com/container-interop/service-provider/tree/v0.4.1" + }, + "time": "2023-12-14T14:50:12+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "fast-forward/config", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-fast-forward/config.git", + "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fast-forward/config/zipball/930434f7f7aaf02d551eb53ab86ed25e6511522c", + "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0", + "laminas/laminas-config-aggregator": "^1.13", + "php": "^8.1", + "psr/container": "^1.0||^2.0", + "psr/simple-cache": "^3.0" + }, + "require-dev": { + "coisa/php-cs-fixer": "^2.1", + "phpspec/prophecy-phpunit": "^2.3", + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastForward\\Config\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Felipe Sayão Lobato Abreu", + "email": "github@mentordosnerds.com" + } + ], + "description": "Fast Forward Config utility classes", + "homepage": "https://github.com/php-fast-forward", + "support": { + "issues": "https://github.com/php-fast-forward/config/issues", + "source": "https://github.com/php-fast-forward/config" + }, + "time": "2025-06-11T14:36:02+00:00" + }, + { + "name": "fast-forward/container", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/php-fast-forward/container.git", + "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fast-forward/container/zipball/d9dd6d964189db80db0490cbe19dcd29841deba4", + "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4", + "shasum": "" + }, + "require": { + "container-interop/service-provider": "^0.4.1", + "fast-forward/config": "^1.1", + "php": "^8.3", + "php-di/php-di": "^7.0", + "psr/container": "^1.0 || ^2.0" + }, + "require-dev": { + "fast-forward/dev-tools": "dev-main" + }, + "type": "library", + "extra": { + "grumphp": { + "config-default-path": "vendor/fast-forward/dev-tools/grumphp.yml" + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastForward\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Felipe Sayão Lobato Abreu", + "email": "github@mentordosnerds.com" + } + ], + "description": "Fast Forward PSR-11 Container utility classes", + "homepage": "https://github.com/php-fast-forward", + "support": { + "issues": "https://github.com/php-fast-forward/container/issues", + "source": "https://github.com/php-fast-forward/container" + }, + "time": "2026-03-27T15:33:59+00:00" + }, + { + "name": "laminas/laminas-config-aggregator", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-config-aggregator.git", + "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-config-aggregator/zipball/75331b17a9ba72c5fa8679988ef08a588ddd86af", + "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af", + "shasum": "" + }, + "require": { + "brick/varexporter": "^0.5.0 || ^0.4.0 || ^0.6.0 || ^0.7.0", + "laminas/laminas-stdlib": "^3.18.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "webimpress/safe-writer": "^2.2.0" + }, + "conflict": { + "nikic/php-parser": "<4.12", + "zendframework/zend-config-aggregator": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-config": "^3.10.1", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "laminas/laminas-config": "Allows loading configuration from XML, INI, YAML, and JSON files", + "laminas/laminas-config-aggregator-modulemanager": "Allows loading configuration from laminas-mvc Module classes", + "laminas/laminas-config-aggregator-parameters": "Allows usage of templated parameters within your configuration" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\ConfigAggregator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Lightweight library for collecting and merging configuration from different sources", + "homepage": "https://laminas.dev", + "keywords": [ + "config-aggregator", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-config-aggregator/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-config-aggregator/issues", + "rss": "https://github.com/laminas/laminas-config-aggregator/releases.atom", + "source": "https://github.com/laminas/laminas-config-aggregator" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2026-06-01T15:37:55+00:00" + }, + { + "name": "laminas/laminas-stdlib", + "version": "3.21.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-stdlib.git", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-stdlib": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.1.0", + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Stdlib\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "SPL extensions, array utilities, error handlers, and more", + "homepage": "https://laminas.dev", + "keywords": [ + "laminas", + "stdlib" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-stdlib/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-stdlib/issues", + "rss": "https://github.com/laminas/laminas-stdlib/releases.atom", + "source": "https://github.com/laminas/laminas-stdlib" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-11T18:13:12+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-08-18T20:28:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "php-di/invoker", + "version": "2.3.7", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2025-08-30T10:22:22+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/container-interop/service-provider/zipball/e04441ca21ef03e10dce70b0af29269281eec6dc", - "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352", "shasum": "" }, "require": { - "psr/container": "^1.0 || ^2.0" + "laravel/serializable-closure": "^1.0 || ^2.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.6 || ^10 || ^11", + "vimeo/psalm": "^5|^6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" }, "type": "library", "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "Interop\\Container\\": "src/" + "DI\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Promoting container interoperability through standard service providers", - "homepage": "https://github.com/container-interop/service-provider", + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], "support": { - "issues": "https://github.com/container-interop/service-provider/issues", - "source": "https://github.com/container-interop/service-provider/tree/v0.4.1" + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" }, - "time": "2023-12-14T14:50:12+00:00" + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2025-08-16T11:10:48+00:00" }, { "name": "psr/container", @@ -414,58 +1028,119 @@ "source": "https://github.com/php-fig/log/tree/3.0.2" }, "time": "2024-09-11T13:17:53+00:00" - } - ], - "packages-dev": [ + }, { - "name": "brick/varexporter", - "version": "0.6.0", + "name": "psr/simple-cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/brick/varexporter.git", - "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc" + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/varexporter/zipball/af98bfc2b702a312abbcaff37656dbe419cec5bc", - "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": "^8.1" + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "webimpress/safe-writer", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/webimpress/safe-writer.git", + "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webimpress/safe-writer/zipball/9d37cc8bee20f7cb2f58f6e23e05097eab5072e6", + "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "6.8.4" + "phpunit/phpunit": "^9.5.4", + "vimeo/psalm": "^4.7", + "webimpress/coding-standard": "^1.2.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2.x-dev", + "dev-develop": "2.3.x-dev", + "dev-release-1.0": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "Brick\\VarExporter\\": "src/" + "Webimpress\\SafeWriter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-2-Clause" ], - "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", + "description": "Tool to write files safely, to avoid race conditions", "keywords": [ - "var_export" + "concurrent write", + "file writer", + "race condition", + "safe writer", + "webimpress" ], "support": { - "issues": "https://github.com/brick/varexporter/issues", - "source": "https://github.com/brick/varexporter/tree/0.6.0" + "issues": "https://github.com/webimpress/safe-writer/issues", + "source": "https://github.com/webimpress/safe-writer/tree/2.2.0" }, "funding": [ { - "url": "https://github.com/BenMorel", + "url": "https://github.com/michalbundyra", "type": "github" } ], - "time": "2025-02-20T17:42:39+00:00" - }, + "time": "2021-04-19T16:34:45+00:00" + } + ], + "packages-dev": [ { "name": "clue/ndjson-react", "version": "v1.3.0", @@ -724,105 +1399,30 @@ } ], "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-05-06T16:37:16+00:00" - }, - { - "name": "dflydev/dot-access-data", - "version": "v3.0.3", - "source": { - "type": "git", - "url": "https://github.com/dflydev/dflydev-dot-access-data.git", - "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", - "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^0.12.42", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", - "scrutinizer/ocular": "1.6.0", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Dflydev\\DotAccessData\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" + "keywords": [ + "Xdebug", + "performance" ], - "authors": [ - { - "name": "Dragonfly Development Inc.", - "email": "info@dflydev.com", - "homepage": "http://dflydev.com" - }, + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ { - "name": "Beau Simensen", - "email": "beau@dflydev.com", - "homepage": "http://beausimensen.com" + "url": "https://packagist.com", + "type": "custom" }, { - "name": "Carlos Frutos", - "email": "carlos@kiwing.it", - "homepage": "https://github.com/cfrutos" + "url": "https://github.com/composer", + "type": "github" }, { - "name": "Colin O'Dell", - "email": "colinodell@gmail.com", - "homepage": "https://www.colinodell.com" + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "description": "Given a deep data structure, access data by dot notation.", - "homepage": "https://github.com/dflydev/dflydev-dot-access-data", - "keywords": [ - "access", - "data", - "dot", - "notation" - ], - "support": { - "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", - "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" - }, - "time": "2024-07-08T12:26:09+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { "name": "ergebnis/agent-detector", @@ -940,123 +1540,6 @@ }, "time": "2023-08-08T05:53:35+00:00" }, - { - "name": "fast-forward/config", - "version": "v1.4.0", - "source": { - "type": "git", - "url": "https://github.com/php-fast-forward/config.git", - "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fast-forward/config/zipball/930434f7f7aaf02d551eb53ab86ed25e6511522c", - "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c", - "shasum": "" - }, - "require": { - "dflydev/dot-access-data": "^3.0", - "laminas/laminas-config-aggregator": "^1.13", - "php": "^8.1", - "psr/container": "^1.0||^2.0", - "psr/simple-cache": "^3.0" - }, - "require-dev": { - "coisa/php-cs-fixer": "^2.1", - "phpspec/prophecy-phpunit": "^2.3", - "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "FastForward\\Config\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Felipe Sayão Lobato Abreu", - "email": "github@mentordosnerds.com" - } - ], - "description": "Fast Forward Config utility classes", - "homepage": "https://github.com/php-fast-forward", - "support": { - "issues": "https://github.com/php-fast-forward/config/issues", - "source": "https://github.com/php-fast-forward/config" - }, - "time": "2025-06-11T14:36:02+00:00" - }, - { - "name": "fast-forward/container", - "version": "v1.6.0", - "source": { - "type": "git", - "url": "https://github.com/php-fast-forward/container.git", - "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fast-forward/container/zipball/d9dd6d964189db80db0490cbe19dcd29841deba4", - "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4", - "shasum": "" - }, - "require": { - "container-interop/service-provider": "^0.4.1", - "fast-forward/config": "^1.1", - "php": "^8.3", - "php-di/php-di": "^7.0", - "psr/container": "^1.0 || ^2.0" - }, - "require-dev": { - "fast-forward/dev-tools": "dev-main" - }, - "type": "library", - "extra": { - "grumphp": { - "config-default-path": "vendor/fast-forward/dev-tools/grumphp.yml" - }, - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "FastForward\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Felipe Sayão Lobato Abreu", - "email": "github@mentordosnerds.com" - } - ], - "description": "Fast Forward PSR-11 Container utility classes", - "homepage": "https://github.com/php-fast-forward", - "support": { - "issues": "https://github.com/php-fast-forward/container/issues", - "source": "https://github.com/php-fast-forward/container" - }, - "time": "2026-03-27T15:33:59+00:00" - }, { "name": "fidry/cpu-core-counter", "version": "1.3.0", @@ -1157,138 +1640,70 @@ "symfony/polyfill-php80": "^1.37", "symfony/polyfill-php81": "^1.37", "symfony/polyfill-php84": "^1.37", - "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", - "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" - }, - "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.11.0", - "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.10.0", - "keradus/cli-executor": "^2.3", - "php-coveralls/php-coveralls": "^2.9.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", - "symfony/polyfill-php85": "^1.38", - "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", - "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" - }, - "suggest": { - "ext-dom": "For handling output formats in XML", - "ext-mbstring": "For handling non-UTF8 characters." - }, - "bin": [ - "php-cs-fixer" - ], - "type": "application", - "autoload": { - "psr-4": { - "PhpCsFixer\\": "src/" - }, - "exclude-from-classmap": [ - "src/**/Internal/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Dariusz Rumiński", - "email": "dariusz.ruminski@gmail.com" - } - ], - "description": "A tool to automatically fix PHP code style", - "keywords": [ - "Static code analysis", - "fixer", - "standards", - "static analysis" - ], - "support": { - "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" - }, - "funding": [ - { - "url": "https://github.com/keradus", - "type": "github" - } - ], - "time": "2026-09-08T11:11:37+00:00" - }, - { - "name": "laminas/laminas-config-aggregator", - "version": "1.20.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-config-aggregator.git", - "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-config-aggregator/zipball/75331b17a9ba72c5fa8679988ef08a588ddd86af", - "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af", - "shasum": "" - }, - "require": { - "brick/varexporter": "^0.5.0 || ^0.4.0 || ^0.6.0 || ^0.7.0", - "laminas/laminas-stdlib": "^3.18.0", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "webimpress/safe-writer": "^2.2.0" - }, - "conflict": { - "nikic/php-parser": "<4.12", - "zendframework/zend-config-aggregator": "*" - }, - "require-dev": { - "laminas/laminas-coding-standard": "~3.1.0", - "laminas/laminas-config": "^3.10.1", - "phpunit/phpunit": "^11.5.42", - "psalm/plugin-phpunit": "^0.19.5", - "vimeo/psalm": "^6.13.1" + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" }, "suggest": { - "laminas/laminas-config": "Allows loading configuration from XML, INI, YAML, and JSON files", - "laminas/laminas-config-aggregator-modulemanager": "Allows loading configuration from laminas-mvc Module classes", - "laminas/laminas-config-aggregator-parameters": "Allows usage of templated parameters within your configuration" + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." }, - "type": "library", + "bin": [ + "php-cs-fixer" + ], + "type": "application", "autoload": { "psr-4": { - "Laminas\\ConfigAggregator\\": "src/" - } + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "description": "Lightweight library for collecting and merging configuration from different sources", - "homepage": "https://laminas.dev", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", "keywords": [ - "config-aggregator", - "laminas" + "Static code analysis", + "fixer", + "standards", + "static analysis" ], "support": { - "chat": "https://laminas.dev/chat", - "docs": "https://docs.laminas.dev/laminas-config-aggregator/", - "forum": "https://discourse.laminas.dev", - "issues": "https://github.com/laminas/laminas-config-aggregator/issues", - "rss": "https://github.com/laminas/laminas-config-aggregator/releases.atom", - "source": "https://github.com/laminas/laminas-config-aggregator" + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" }, "funding": [ { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" + "url": "https://github.com/keradus", + "type": "github" } ], - "time": "2026-06-01T15:37:55+00:00" + "time": "2026-09-08T11:11:37+00:00" }, { "name": "laminas/laminas-servicemanager", @@ -1375,126 +1790,6 @@ ], "time": "2026-05-12T09:53:32+00:00" }, - { - "name": "laminas/laminas-stdlib", - "version": "3.21.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-stdlib.git", - "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096", - "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096", - "shasum": "" - }, - "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" - }, - "conflict": { - "zendframework/zend-stdlib": "*" - }, - "require-dev": { - "laminas/laminas-coding-standard": "^3.1.0", - "phpbench/phpbench": "^1.4.1", - "phpunit/phpunit": "^11.5.42", - "psalm/plugin-phpunit": "^0.19.5", - "vimeo/psalm": "^6.13.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "Laminas\\Stdlib\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "SPL extensions, array utilities, error handlers, and more", - "homepage": "https://laminas.dev", - "keywords": [ - "laminas", - "stdlib" - ], - "support": { - "chat": "https://laminas.dev/chat", - "docs": "https://docs.laminas.dev/laminas-stdlib/", - "forum": "https://discourse.laminas.dev", - "issues": "https://github.com/laminas/laminas-stdlib/issues", - "rss": "https://github.com/laminas/laminas-stdlib/releases.atom", - "source": "https://github.com/laminas/laminas-stdlib" - }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2025-10-11T18:13:12+00:00" - }, - { - "name": "laravel/serializable-closure", - "version": "v2.0.16", - "source": { - "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0|^4.0", - "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\SerializableClosure\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, - { - "name": "Nuno Maduro", - "email": "nuno@laravel.com" - } - ], - "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", - "keywords": [ - "closure", - "laravel", - "serializable" - ], - "support": { - "issues": "https://github.com/laravel/serializable-closure/issues", - "source": "https://github.com/laravel/serializable-closure" - }, - "time": "2026-08-18T20:28:54+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.14.0", @@ -1536,81 +1831,24 @@ "MIT" ], "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - } - ], - "time": "2026-08-11T10:17:44+00:00" - }, - { - "name": "nikic/php-parser", - "version": "v5.9.0", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", - "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, - "time": "2026-09-13T18:51:52+00:00" + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nyholm/psr7", @@ -1869,134 +2107,6 @@ }, "time": "2026-09-09T16:22:59+00:00" }, - { - "name": "php-di/invoker", - "version": "2.3.7", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/Invoker.git", - "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", - "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", - "shasum": "" - }, - "require": { - "php": ">=7.3", - "psr/container": "^1.0|^2.0" - }, - "require-dev": { - "athletic/athletic": "~0.1.8", - "mnapoli/hard-mode": "~0.3.0", - "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" - }, - "type": "library", - "autoload": { - "psr-4": { - "Invoker\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Generic and extensible callable invoker", - "homepage": "https://github.com/PHP-DI/Invoker", - "keywords": [ - "callable", - "dependency", - "dependency-injection", - "injection", - "invoke", - "invoker" - ], - "support": { - "issues": "https://github.com/PHP-DI/Invoker/issues", - "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - } - ], - "time": "2025-08-30T10:22:22+00:00" - }, - { - "name": "php-di/php-di", - "version": "7.1.1", - "source": { - "type": "git", - "url": "https://github.com/PHP-DI/PHP-DI.git", - "reference": "f88054cc052e40dbe7b383c8817c19442d480352" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", - "reference": "f88054cc052e40dbe7b383c8817c19442d480352", - "shasum": "" - }, - "require": { - "laravel/serializable-closure": "^1.0 || ^2.0", - "php": ">=8.0", - "php-di/invoker": "^2.0", - "psr/container": "^1.1 || ^2.0" - }, - "provide": { - "psr/container-implementation": "^1.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3", - "friendsofphp/proxy-manager-lts": "^1", - "mnapoli/phpunit-easymock": "^1.3", - "phpunit/phpunit": "^9.6 || ^10 || ^11", - "vimeo/psalm": "^5|^6" - }, - "suggest": { - "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "DI\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The dependency injection container for humans", - "homepage": "https://php-di.org/", - "keywords": [ - "PSR-11", - "container", - "container-interop", - "dependency injection", - "di", - "ioc", - "psr11" - ], - "support": { - "issues": "https://github.com/PHP-DI/PHP-DI/issues", - "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", - "type": "tidelift" - } - ], - "time": "2025-08-16T11:10:48+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.14", @@ -2536,57 +2646,6 @@ ], "time": "2026-09-15T07:37:05+00:00" }, - { - "name": "psr/simple-cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\SimpleCache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interfaces for simple caching", - "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" - ], - "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" - }, - "time": "2021-10-29T13:26:27+00:00" - }, { "name": "react/cache", "version": "v1.2.0", @@ -5918,65 +5977,6 @@ } ], "time": "2025-12-08T11:19:18+00:00" - }, - { - "name": "webimpress/safe-writer", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/webimpress/safe-writer.git", - "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webimpress/safe-writer/zipball/9d37cc8bee20f7cb2f58f6e23e05097eab5072e6", - "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6", - "shasum": "" - }, - "require": { - "php": "^7.3 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5.4", - "vimeo/psalm": "^4.7", - "webimpress/coding-standard": "^1.2.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.2.x-dev", - "dev-develop": "2.3.x-dev", - "dev-release-1.0": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Webimpress\\SafeWriter\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "description": "Tool to write files safely, to avoid race conditions", - "keywords": [ - "concurrent write", - "file writer", - "race condition", - "safe writer", - "webimpress" - ], - "support": { - "issues": "https://github.com/webimpress/safe-writer/issues", - "source": "https://github.com/webimpress/safe-writer/tree/2.2.0" - }, - "funding": [ - { - "url": "https://github.com/michalbundyra", - "type": "github" - } - ], - "time": "2021-04-19T16:34:45+00:00" } ], "aliases": [], diff --git a/docs/api.md b/docs/api.md index 47d8be7..15fc244 100644 --- a/docs/api.md +++ b/docs/api.md @@ -62,6 +62,16 @@ used by FastForward, with callable factories and no automatic callback registration. See [FastForward integration](fast-forward.md) for process-service aliases, application overrides and fresh middleware scopes. -`Container\ConfigProvider` exposes `__invoke()`, `getDependencies()`, `getAliases()` and `getFactories()` for compatible containers such as Laminas ServiceManager. `ErrorHandlerContainer` can wrap a parent PSR-11 container, delegates known services to it, and caches services built by the library factories. +FastForward Container is required at runtime and owns service resolution, +caching and autowiring. Concrete dispatch and HTTP adapters use constructor +autowiring. Explicit factories remain for optional PHP/shutdown handlers, reporter +composition and independent middleware scopes. `ErrorHandlerContainer` and its +private alias/exception machinery have been removed. + +`Container\ConfigProvider` exposes `__invoke()`, `getDependencies()`, `getAliases()` +and `getFactories()` for Laminas ServiceManager. It uses FastForward's generic +`InvokableFactory` for plain constructor injection, and the same lifecycle +factories as the provider. Its middleware owns a handler separate from the cached +process handler. See [migration](migration.md) for removed factory class names. Provide `ThrowableHandlerInterface` for a custom reporter. Otherwise `ThrowableHandlerAggregateFactory` composes any available callable/event-dispatch reporters; an empty aggregate intentionally produces no diagnostics. HTTP factories also require PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface` services. PSR-14 integration needs `EventDispatcherInterface`. These implementations belong to the application; the package ships interfaces and adapters, not a logger, event bus or HTTP framework. diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 835cd70..45065eb 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -7,15 +7,17 @@ API; a possible future move to php-fast-forward has not happened. ## Installation and bootstrap -The library requires the small `container-interop/service-provider:^0.4.1` -interface package. Its core and provider do not require FastForward or a full -framework. Applications choosing FastForward install its container explicitly: +The library requires `fast-forward/container:^1.6` and the +`container-interop/service-provider:^0.4.1` interface package at runtime. +Composer installs them for consumers. The provider reuses FastForward's +`AliasFactory::get()` and `InvokableFactory`; it does not ship a second container. +To install the container explicitly in an application: ```sh composer require fast-forward/container:^1.6 ``` -The development checkout already includes it in `require-dev`. After installing +The development checkout includes it in `require`. After installing the checkout dependencies, run the complete [example](../examples/fast-forward.php): ```sh @@ -57,6 +59,22 @@ There is no provider boot hook. The application decides when to register. | `Container\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation | | `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` | Reusable callable that creates fresh middleware and a fresh handler | +The provider has nine explicit definitions. Dispatch handlers and concrete +stream/response adapters are resolved through FastForward's constructor +autowiring. Their interface aliases remain explicit, and application definitions +can override either an interface or its concrete implementation. The three +library factories that remain encode optional-handler defaults, reporter +composition and fresh middleware scopes; autowiring alone does not replace those +contracts. The factory-object services use FastForward's `InvokableFactory`. + +Bind a required custom renderer through `ThrowableStreamFactoryInterface` or +`ThrowableResponseFactoryInterface`. FastForward 1.6.0 can discard a concrete +class definition's PSR-11 construction failure and fall back to autowiring that +class. The same failure on an explicitly bound interface prevents construction, +because the interface has no autowire fallback. Successful concrete overrides +work, but they do not provide the same failure contract. The integration suite +checks both successful concrete overrides and required-interface failures. + Names in the table are relative to `CoiSA\ErrorHandler`; reporter classes are under `Handler`, HTTP adapters under `Http\Message`, and middleware under `Http\Middleware`. @@ -89,7 +107,10 @@ An ordinary application exception continues to propagate. Optional `PhpErrorHandlerInterface` and `ShutdownHandlerInterface` bindings are passed to fresh handlers too. The provider reuses the package's PSR-11 factories and introduces no separate configuration format or automatic event subscriptions. -The Laminas `ConfigProvider` and `ErrorHandlerContainer` remain available. +The Laminas `ConfigProvider` remains available and uses `InvokableFactory` with +explicit dependency identifiers for containers without autowiring. +`ErrorHandlerContainer` and the constructor-only factories have been removed; +see [migration](migration.md). ## HTTP scopes and fresh instances @@ -122,12 +143,12 @@ The integration tests use the real released FastForward container, including provider-class bootstrap, explicit and optional reporters, custom PHP/shutdown handlers, event dispatch, fresh factories, nested HTTP scopes and restoration after renderer failure. The full [test command](testing.md) retains the existing -fatal-error and HTTP checks. CI also loads the provider without development -dependencies, proving that installing the whole FastForward framework is not -necessary to use the core library. +fatal-error and HTTP checks. CI also installs without development dependencies +and resolves a process handler through the provider, proving that FastForward's +factory classes and autowiring are available in production installations. -This separates the public Interop/PSR contracts from the optional framework -consumer. A later move still needs an explicit package/namespace decision, +The full FastForward framework is not required. A later package move still needs +an explicit package/namespace decision, release and migration guidance; this provider is not a claim that those changes have already been made. diff --git a/docs/index.html b/docs/index.html index 79eaace..1978219 100644 --- a/docs/index.html +++ b/docs/index.html @@ -53,7 +53,7 @@

Make failure
observable.

Container integration

Declare services. Choose when to register.

The Interop service provider follows FastForward's callable factory definitions. Resolve the process handler through ErrorHandlerInterface, configure your reporter in an application provider, and explicitly register at bootstrap.

-

FastForward caches services. The middleware receives a separate handler; callable factory services create fresh instances for nested scopes. Core use and the provider remain independent of the full framework.

+

FastForward Container is required at runtime and replaces the standalone container. Constructor autowiring resolves plain adapters; three lifecycle factories preserve reporter composition and fresh scopes. The full framework is not required. Bind required custom renderers through their interfaces to prevent construction failures from falling back to autowiring.

RUN THE COMPLETE PROVIDER EXAMPLE
php examples/fast-forward.php
@@ -64,7 +64,7 @@

Make failure
observable.

04 / Exercise the failure path

A test should survive the crash.

-
27ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion and renderer failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

+
28ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion, reporter reset and renderer failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

The runner checks delivery counts, ordering, throwable classes, stderr and exact exit status. Separate integration tests exercise PSR composition and real HTTP 500/503 responses through a loopback-only server.

RUN THE EVIDENCE YOURSELF
php tests/Fire/run.php
 composer --no-plugins test
diff --git a/docs/migration.md b/docs/migration.md
index 15945ad..353c3c5 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -7,7 +7,8 @@ without changing the existing package or namespace. Merge definitions with
 `AggregateServiceProvider`, library first and application last, so a required
 reporter's failure cannot fall back to the silent default. Registration remains explicit;
 use factory services for fresh/nested scopes because container lookups are cached.
-Existing Laminas and standalone PSR-11 integration remain supported.
+The Laminas `ConfigProvider` adapter remains supported; the standalone
+`ErrorHandlerContainer` has been removed in favor of FastForward Container.
 
 1. Upgrade the application to PHP 8.3, 8.4 or 8.5. Resolve Composer dependencies again. The checked-in development lock targets PHP 8.5; developers on 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible tools. Consumers resolve their own lock as usual.
 2. Expect exit status **1** after a globally uncaught `Throwable` is reported successfully. Version 2 could exit with status 0. A reporter that explicitly terminates the process still controls its own exit code. Direct `handleThrowable()` calls do not exit.
@@ -16,6 +17,33 @@ Existing Laminas and standalone PSR-11 integration remain supported.
 5. Review custom HTTP rendering. Empty output now produces `Internal Server Error`, instead of a stringified exception and stack trace. The string `0` is preserved. Exception messages no longer become HTTP reason phrases. Codes 400–599 map to their matching status; other integer codes and string SQLSTATE codes map to 500.
 6. If you used the old PHP 7.2 Docker setup, replace the two-service invocation with `docker compose run --rm --build tests`. It defaults to PHP 8.3 and resolves dependencies in the image, without using your host `vendor/`.
 
-Public handler and factory interfaces retain their names and method signatures. The PHP floor, CLI exit semantics, lifecycle rejection and HTTP output defaults warrant a major release even though most integration code can remain unchanged.
+7. Replace `new Container\ErrorHandlerContainer($parent)` with
+   `FastForward\Container\container(new ErrorHandlerServiceProvider(), $parent)`
+   when the parent only supplies external PSR dependencies. For application
+   overrides, use the merged provider composition above. FastForward is now a
+   runtime dependency, including its transitive packages; consumers resolve them
+   through Composer. Do not rely on the removed package-specific
+   `Container\Exception\ContainerException` or `NotFoundException`; catch the
+   corresponding PSR-11 exception interfaces instead.
+8. Replace removed constructor-only factories as follows. Names below are relative
+   to `CoiSA\ErrorHandler` except the FastForward factory classes.
+
+| Removed API | Replacement |
+| --- | --- |
+| `Container\Factory\AliasFactory` | `FastForward\Container\Factory\AliasFactory::get($serviceId)` |
+| `Container\Factory\DispatchThrowableHandlerFactory` | Autowire `Handler\DispatchThrowableHandler` |
+| `Container\Factory\DispatchErrorEventThrowableHandlerFactory` | Autowire `Handler\DispatchErrorEventThrowableHandler` |
+| `Container\Factory\ThrowableResponseFactoryFactory` | Autowire `Http\Message\ThrowableResponseFactory` |
+| `Container\Factory\ThrowableStreamFactoryFactory` | Autowire `Http\Message\ThrowableStreamFactory` |
+| `Container\Factory\ErrorHandlerMiddlewareFactory` | `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`, which creates a fresh handler |
+
+For a PSR-11 container without autowiring, use FastForward's `InvokableFactory`
+with explicit constructor service identifiers, as the Laminas adapter does.
+`Container\Factory\ErrorHandlerFactory` and `ThrowableHandlerAggregateFactory`
+remain because they encode lifecycle and reporter-composition behavior.
+
+Public handler interfaces retain their names and method signatures. The PHP
+floor, required FastForward dependency, removed container/factories, CLI exit
+semantics, lifecycle rejection and HTTP output defaults warrant a major release.
 
 Before upgrading a real application, run the [documented checks](testing.md) and application-level tests for the actual reporter, middleware stack and PHP SAPI. These isolated fixtures do not establish reliability of third-party loggers, remote services or production infrastructure.
diff --git a/docs/testing.md b/docs/testing.md
index e0fb5c3..d2a9a81 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -105,9 +105,12 @@ of the original exception cause.
 
 `php examples/fast-forward.php` exits 0, emits exactly
 `Reported: FastForward provider warning` followed by a newline, and leaves stderr
-empty. CI verifies that output. A separate production-only Composer installation
-also loads the core and all 13 provider definitions without FastForward installed;
-the required integration dependency is the small Interop interface package.
+empty. CI verifies that output. That original provider revision also loaded its
+13 definitions without FastForward installed. The review follow-up makes
+FastForward a runtime dependency, replaces the standalone container, removes
+constructor-only factories and reduces the provider to nine definitions.
+Production-only CI now resolves a process handler through FastForward after
+`composer install --no-dev`; it no longer asserts FastForward is absent.
 The five-job PHP/dependency CI matrix exercises the provider tests too; consult
 the current run for commit-specific Linux evidence.
 
@@ -116,6 +119,33 @@ integration procedure is checked against these same public APIs. Repository
 maintenance guidance is in [AGENTS.md](../AGENTS.md); the detailed wiring contract
 is in [FastForward integration](fast-forward.md).
 
+## Review follow-up verification
+
+The FastForward-required/autowire revision passes the full check on macOS arm64
+with PHP 8.5.11, PHPUnit 13.3.4 and FastForward Container 1.6.0: **89 tests and
+173 assertions**, maximum PHPStan over `src/`, formatting, **28** isolated scenarios
+and three HTTP responses. The count replaces tests exclusive to the removed
+standalone container with retained Laminas integration tests and new autowire,
+required-renderer and reporter-reset coverage; it is not the old 118-case suite.
+
+The 25 dependency-free core scenarios also pass on PHP 8.4.26 with `-n`.
+An isolated production-only installation resolves the provider/process alias on
+PHP 8.5.11 and PHP 8.4.26, without PHPUnit. The existing package versions were
+preserved; moving FastForward and its transitive requirements to runtime grows
+the locked production set from 8 to 20 packages. Both executable examples retain
+their exact output and exit contracts.
+
+The additional reporter-reset child verifies that a failed reporter is not
+retried until unregister/register starts a new lifecycle. Six required-renderer
+cases cover both HTTP interfaces against PSR construction, PSR not-found and
+ordinary application failures. A separate bounded reproduction on FastForward
+1.6.0 showed that a concrete renderer override's PSR failure can fall back to
+autowiring, while an interface override fails construction. Required custom
+renderers therefore use explicit interface bindings in the documented contract.
+
+These local results do not establish PHP 8.3 or new Linux matrix results for
+this revision. Use the CI run attached to the exact follow-up commit for those.
+
 ## Boundaries and troubleshooting
 
 - PHP must have `proc_open()` enabled for crash/HTTP tests. Unit tests require the normal PHPUnit extensions. The HTTP runner binds only to a dynamically selected `127.0.0.1` port, waits at most five seconds to start, gives each request two seconds, and terminates its fixture server in `finally`.
diff --git a/examples/cli.php b/examples/cli.php
index 4b752bf..871b027 100644
--- a/examples/cli.php
+++ b/examples/cli.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
 
diff --git a/examples/fast-forward.php b/examples/fast-forward.php
index 2de5988..9687a92 100644
--- a/examples/fast-forward.php
+++ b/examples/fast-forward.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 use CoiSA\ErrorHandler\ErrorHandlerInterface;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
 use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
@@ -14,6 +26,11 @@
 require dirname(__DIR__) . '/vendor/autoload.php';
 
 $application = new class () implements ServiceProviderInterface {
+    /**
+     * Supplies application service definitions for the integration fixture.
+     *
+     * @return array Application service factories.
+     */
     public function getFactories(): array
     {
         return [
@@ -24,6 +41,11 @@ public function getFactories(): array
         ];
     }
 
+    /**
+     * Declares no application service extensions.
+     *
+     * @return array An empty extension map.
+     */
     public function getExtensions(): array
     {
         return [];
@@ -31,7 +53,9 @@ public function getExtensions(): array
 };
 
 $services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
-/** @var ErrorHandlerInterface $errors */
+/**
+ * @var ErrorHandlerInterface $errors
+ */
 $errors = $services->get(ErrorHandlerInterface::class);
 $errors->register();
 try {
diff --git a/src/Container/ConfigProvider.php b/src/Container/ConfigProvider.php
index cbf5f89..6dac675 100644
--- a/src/Container/ConfigProvider.php
+++ b/src/Container/ConfigProvider.php
@@ -26,6 +26,11 @@
 use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
 use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
+use FastForward\Container\Factory\InvokableFactory;
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\StreamFactoryInterface;
 
 /**
  * Class ConfigProvider
@@ -35,7 +40,7 @@
  * required for the proper functioning of the error-handler library.
  *
  * @package CoiSA\ErrorHandler\Container
- * @phpstan-type Factories array
+ * @phpstan-type Factories array
  * @phpstan-type Dependencies array{aliases: array, factories: Factories}
  */
 final class ConfigProvider
@@ -101,12 +106,27 @@ public function getFactories(): array
     {
         return [
             ErrorHandler::class                       => Factory\ErrorHandlerFactory::class,
-            ErrorHandlerMiddleware::class             => Factory\ErrorHandlerMiddlewareFactory::class,
-            DispatchErrorEventThrowableHandler::class => Factory\DispatchErrorEventThrowableHandlerFactory::class,
-            DispatchThrowableHandler::class           => Factory\DispatchThrowableHandlerFactory::class,
+            ErrorHandlerMiddleware::class             => ErrorHandlerMiddlewareFactory::class,
+            Factory\ErrorHandlerFactory::class        => new InvokableFactory(Factory\ErrorHandlerFactory::class),
+            DispatchErrorEventThrowableHandler::class => new InvokableFactory(
+                DispatchErrorEventThrowableHandler::class,
+                EventDispatcherInterface::class
+            ),
+            DispatchThrowableHandler::class           => new InvokableFactory(
+                DispatchThrowableHandler::class,
+                EventDispatcherInterface::class
+            ),
             ThrowableHandlerAggregate::class          => Factory\ThrowableHandlerAggregateFactory::class,
-            ThrowableResponseFactory::class           => Factory\ThrowableResponseFactoryFactory::class,
-            ThrowableStreamFactory::class             => Factory\ThrowableStreamFactoryFactory::class,
+            ThrowableResponseFactory::class           => new InvokableFactory(
+                ThrowableResponseFactory::class,
+                ThrowableStreamFactoryInterface::class,
+                ResponseFactoryInterface::class
+            ),
+            ThrowableStreamFactory::class             => new InvokableFactory(
+                ThrowableStreamFactory::class,
+                StreamFactoryInterface::class,
+                ThrowableHandlerInterface::class
+            ),
         ];
     }
 }
diff --git a/src/Container/ErrorHandlerContainer.php b/src/Container/ErrorHandlerContainer.php
deleted file mode 100644
index 03a688d..0000000
--- a/src/Container/ErrorHandlerContainer.php
+++ /dev/null
@@ -1,150 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container;
-
-use Psr\Container\ContainerInterface;
-use CoiSA\ErrorHandler\Container\Exception\ContainerException;
-use CoiSA\ErrorHandler\Container\Exception\NotFoundException;
-
-/**
- * Class ErrorHandlerContainer
- *
- * A custom container implementation for managing error handler services and dependencies.
- * This container SHALL resolve services using factories, aliases, or delegate resolution
- * to a parent container when configured.
- *
- * @package CoiSA\ErrorHandler\Container
- * @phpstan-import-type Factories from ConfigProvider
- */
-final class ErrorHandlerContainer implements ContainerInterface
-{
-    /**
-     * @var ContainerInterface|null A parent container for delegation.
-     */
-    private ?ContainerInterface $container;
-
-    /**
-     * @var Factories List of factories mapped to their service IDs.
-     */
-    private array $factories;
-
-    /**
-     * @var string[] List of aliases mapped to their service IDs.
-     */
-    private array $aliases;
-
-    /**
-     * @var array Cached services, including values delegated through aliases.
-     */
-    private array $instances = [];
-
-    /**
-     * Constructs an ErrorHandlerContainer.
-     *
-     * @param ContainerInterface|null $container An optional parent container for delegation.
-     */
-    public function __construct(?ContainerInterface $container = null)
-    {
-        $configProvider = new ConfigProvider();
-
-        $this->factories = $configProvider->getFactories();
-        $this->aliases   = $configProvider->getAliases();
-        $this->container = $container;
-    }
-
-    /**
-     * Checks if the container can resolve the given service ID.
-     *
-     * This method SHALL return true if the service ID can be resolved
-     * either by delegation, aliases, or factories.
-     *
-     * @param string $id The identifier of the entry to look for.
-     *
-     * @return bool True if the service ID can be resolved, false otherwise.
-     */
-    public function has(string $id): bool
-    {
-        return ($this->container && $this->container->has($id))
-            || array_key_exists($id, $this->aliases)
-            || array_key_exists($id, $this->factories);
-    }
-
-    /**
-     * Retrieves a service instance by its identifier.
-     *
-     * This method SHALL resolve the service by:
-     * - Delegating to the parent container (if available)
-     * - Using an alias to resolve the actual service
-     * - Instantiating via a factory
-     *
-     * @param string $id The identifier of the entry to retrieve.
-     *
-     * @throws ContainerException If an error occurs during instantiation.
-     * @throws NotFoundException If no entry was found for the given identifier.
-     *
-     * @return mixed The resolved service instance.
-     */
-    public function get(string $id): mixed
-    {
-        if ($this->container && $this->container->has($id)) {
-            return $this->container->get($id);
-        }
-
-        if (!isset($this->instances[$id])) {
-            try {
-                $this->instances[$id] = ($this->getFactory($id))($this);
-            } catch (NotFoundException $notFoundException) {
-                throw $notFoundException;
-            } catch (\Throwable $throwable) {
-                throw ContainerException::createFromThrowable($throwable);
-            }
-        }
-
-        return $this->instances[$id];
-    }
-
-    /**
-     * Resolves a factory for the given service identifier.
-     *
-     * This method SHALL resolve the service factory based on:
-     * - Aliases mapping to another service
-     * - Direct mapping to a factory class
-     *
-     * @param string $id The identifier of the entry to resolve.
-     *
-     * @throws NotFoundException If no factory or alias is found for the given identifier.
-     *
-     * @return callable A callable factory responsible for creating the service instance.
-     */
-    private function getFactory(string $id): callable
-    {
-        if (!$this->has($id)) {
-            throw new NotFoundException(sprintf('Factory for class %s was not found', $id));
-        }
-
-        if (isset($this->aliases[$id])) {
-            return new Factory\AliasFactory($this->aliases[$id]);
-        }
-
-        $factory = new $this->factories[$id]();
-        if (!is_callable($factory)) {
-            throw new ContainerException(sprintf('Factory for class %s is not callable', $id));
-        }
-
-        return $factory;
-    }
-}
diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php
deleted file mode 100644
index 57d0f78..0000000
--- a/src/Container/Exception/ContainerException.php
+++ /dev/null
@@ -1,49 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Exception;
-
-use Psr\Container\ContainerExceptionInterface;
-
-/**
- * Class ContainerException
- *
- * Represents a container exception specific to the error handler.
- * This exception SHOULD be used to wrap any throwable related to container operations.
- *
- * @package CoiSA\ErrorHandler\Container\Exception
- */
-class ContainerException extends \Exception implements ContainerExceptionInterface
-{
-    /**
-     * Creates a new instance of ContainerException from a throwable.
-     *
-     * This factory method SHALL wrap a throwable into a ContainerException
-     * to provide more context about container-related errors.
-     *
-     * @param \Throwable $throwable The original throwable to wrap.
-     *
-     * @return self A new instance of ContainerException.
-     */
-    public static function createFromThrowable(\Throwable $throwable): self
-    {
-        return new self(
-            $throwable->getMessage(),
-            $throwable->getCode(),
-            $throwable
-        );
-    }
-}
diff --git a/src/Container/Exception/NotFoundException.php b/src/Container/Exception/NotFoundException.php
deleted file mode 100644
index e1ae800..0000000
--- a/src/Container/Exception/NotFoundException.php
+++ /dev/null
@@ -1,49 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Exception;
-
-use Psr\Container\NotFoundExceptionInterface;
-
-/**
- * Class NotFoundException
- *
- * Represents an exception thrown when a requested entry is not found in the container.
- * This exception MUST be used in cases where a container cannot resolve or find an entry.
- *
- * @package CoiSA\ErrorHandler\Container\Exception
- */
-final class NotFoundException extends ContainerException implements NotFoundExceptionInterface
-{
-    /**
-     * Creates a new instance of NotFoundException from a throwable.
-     *
-     * This factory method SHALL wrap a throwable into a NotFoundException
-     * to provide more context about the missing entry.
-     *
-     * @param \Throwable $throwable The original throwable to wrap.
-     *
-     * @return self A new instance of NotFoundException.
-     */
-    public static function createFromThrowable(\Throwable $throwable): self
-    {
-        return new self(
-            $throwable->getMessage(),
-            $throwable->getCode(),
-            $throwable
-        );
-    }
-}
diff --git a/src/Container/Factory/AliasFactory.php b/src/Container/Factory/AliasFactory.php
deleted file mode 100644
index 23b9857..0000000
--- a/src/Container/Factory/AliasFactory.php
+++ /dev/null
@@ -1,59 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Factory;
-
-use Psr\Container\ContainerInterface;
-
-/**
- * Class AliasFactory
- *
- * A factory class responsible for resolving a service alias from a container.
- * This class SHALL retrieve the service instance associated with the provided alias.
- *
- * @package CoiSA\ErrorHandler\Container\Factory
- */
-final class AliasFactory
-{
-    /**
-     * @var string The alias name of the service to retrieve.
-     */
-    private string $alias;
-
-    /**
-     * Constructs the AliasFactory with a service alias.
-     *
-     * @param string $alias The alias of the service to be retrieved from the container.
-     */
-    public function __construct(string $alias)
-    {
-        $this->alias = $alias;
-    }
-
-    /**
-     * Invokes the factory to retrieve the service instance from the container.
-     *
-     * This method SHALL use the alias to fetch the corresponding service from the container.
-     *
-     * @param ContainerInterface $container The PSR-11 container instance.
-     *
-     * @return mixed The resolved service instance associated with the alias.
-     */
-    public function __invoke(ContainerInterface $container): mixed
-    {
-        return $container->get($this->alias);
-    }
-}
diff --git a/src/Container/Factory/DispatchErrorEventThrowableHandlerFactory.php b/src/Container/Factory/DispatchErrorEventThrowableHandlerFactory.php
deleted file mode 100644
index 8783ca9..0000000
--- a/src/Container/Factory/DispatchErrorEventThrowableHandlerFactory.php
+++ /dev/null
@@ -1,51 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Factory;
-
-use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler;
-use Psr\Container\ContainerInterface;
-use Psr\EventDispatcher\EventDispatcherInterface;
-
-/**
- * Class DispatchErrorEventThrowableHandlerFactory
- *
- * A factory responsible for creating instances of DispatchErrorEventThrowableHandler.
- * This factory SHALL retrieve the EventDispatcherInterface from the container
- * to ensure proper dependency injection.
- *
- * @package CoiSA\ErrorHandler\Container\Factory
- */
-final class DispatchErrorEventThrowableHandlerFactory
-{
-    /**
-     * Creates an instance of DispatchErrorEventThrowableHandler.
-     *
-     * This method SHALL retrieve an EventDispatcherInterface from the container
-     * and pass it to the DispatchErrorEventThrowableHandler constructor.
-     *
-     * @param ContainerInterface $container The PSR-11 container instance.
-     *
-     * @return DispatchErrorEventThrowableHandler The configured handler instance.
-     */
-    public function __invoke(ContainerInterface $container): DispatchErrorEventThrowableHandler
-    {
-        /** @var EventDispatcherInterface $eventDispatcher */
-        $eventDispatcher = $container->get(EventDispatcherInterface::class);
-
-        return new DispatchErrorEventThrowableHandler($eventDispatcher);
-    }
-}
diff --git a/src/Container/Factory/DispatchThrowableHandlerFactory.php b/src/Container/Factory/DispatchThrowableHandlerFactory.php
deleted file mode 100644
index 0b62332..0000000
--- a/src/Container/Factory/DispatchThrowableHandlerFactory.php
+++ /dev/null
@@ -1,50 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Factory;
-
-use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler;
-use Psr\Container\ContainerInterface;
-use Psr\EventDispatcher\EventDispatcherInterface;
-
-/**
- * Class DispatchThrowableHandlerFactory
- *
- * A factory responsible for creating instances of DispatchThrowableHandler.
- * This factory SHALL ensure proper dependency injection of the EventDispatcherInterface.
- *
- * @package CoiSA\ErrorHandler\Container\Factory
- */
-final class DispatchThrowableHandlerFactory
-{
-    /**
-     * Creates an instance of DispatchThrowableHandler.
-     *
-     * This method SHALL retrieve an EventDispatcherInterface from the container
-     * and pass it to the DispatchThrowableHandler constructor.
-     *
-     * @param ContainerInterface $container The PSR-11 container instance.
-     *
-     * @return DispatchThrowableHandler The configured handler instance.
-     */
-    public function __invoke(ContainerInterface $container): DispatchThrowableHandler
-    {
-        /** @var EventDispatcherInterface $eventDispatcher */
-        $eventDispatcher = $container->get(EventDispatcherInterface::class);
-
-        return new DispatchThrowableHandler($eventDispatcher);
-    }
-}
diff --git a/src/Container/Factory/ErrorHandlerMiddlewareFactory.php b/src/Container/Factory/ErrorHandlerMiddlewareFactory.php
deleted file mode 100644
index 332d3fa..0000000
--- a/src/Container/Factory/ErrorHandlerMiddlewareFactory.php
+++ /dev/null
@@ -1,55 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Factory;
-
-use CoiSA\ErrorHandler\ErrorHandler;
-use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface;
-use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
-use Psr\Container\ContainerInterface;
-
-/**
- * Class ErrorHandlerMiddlewareFactory
- *
- * Factory responsible for creating instances of ErrorHandlerMiddleware.
- * This factory SHALL ensure proper dependency injection of ErrorHandler and
- * ThrowableResponseFactoryInterface to configure the middleware appropriately.
- *
- * @package CoiSA\ErrorHandler\Container\Factory
- */
-final class ErrorHandlerMiddlewareFactory
-{
-    /**
-     * Creates an instance of ErrorHandlerMiddleware.
-     *
-     * This method SHALL retrieve the required dependencies from the container
-     * and inject them into the ErrorHandlerMiddleware constructor.
-     *
-     * @param ContainerInterface $container The PSR-11 container instance.
-     *
-     * @return ErrorHandlerMiddleware The configured middleware instance.
-     */
-    public function __invoke(ContainerInterface $container): ErrorHandlerMiddleware
-    {
-        /** @var ErrorHandler $errorHandler */
-        $errorHandler = $container->get(ErrorHandler::class);
-
-        /** @var ThrowableResponseFactoryInterface $throwableResponseFactory */
-        $throwableResponseFactory = $container->get(ThrowableResponseFactoryInterface::class);
-
-        return new ErrorHandlerMiddleware($errorHandler, $throwableResponseFactory);
-    }
-}
diff --git a/src/Container/Factory/ThrowableHandlerAggregateFactory.php b/src/Container/Factory/ThrowableHandlerAggregateFactory.php
index 28a1067..8f5e063 100644
--- a/src/Container/Factory/ThrowableHandlerAggregateFactory.php
+++ b/src/Container/Factory/ThrowableHandlerAggregateFactory.php
@@ -52,7 +52,9 @@ public function __invoke(ContainerInterface $container): ThrowableHandlerAggrega
 
         if ($container->has(CallableThrowableHandler::class)) {
             try {
-                /** @var CallableThrowableHandler $callableHandler */
+                /**
+                 * @var CallableThrowableHandler $callableHandler
+                 */
                 $callableHandler = $container->get(CallableThrowableHandler::class);
                 $handler->attach($callableHandler);
             } catch (NotFoundExceptionInterface) {
@@ -62,13 +64,17 @@ public function __invoke(ContainerInterface $container): ThrowableHandlerAggrega
 
         if ($container->has(EventDispatcherInterface::class)) {
             if ($container->has(DispatchThrowableHandler::class)) {
-                /** @var DispatchThrowableHandler $dispatchHandler */
+                /**
+                 * @var DispatchThrowableHandler $dispatchHandler
+                 */
                 $dispatchHandler = $container->get(DispatchThrowableHandler::class);
                 $handler->attach($dispatchHandler);
             }
 
             if ($container->has(DispatchErrorEventThrowableHandler::class)) {
-                /** @var DispatchErrorEventThrowableHandler $dispatchErrorHandler */
+                /**
+                 * @var DispatchErrorEventThrowableHandler $dispatchErrorHandler
+                 */
                 $dispatchErrorHandler = $container->get(DispatchErrorEventThrowableHandler::class);
                 $handler->attach($dispatchErrorHandler);
             }
diff --git a/src/Container/Factory/ThrowableResponseFactoryFactory.php b/src/Container/Factory/ThrowableResponseFactoryFactory.php
deleted file mode 100644
index f8fbeae..0000000
--- a/src/Container/Factory/ThrowableResponseFactoryFactory.php
+++ /dev/null
@@ -1,56 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Factory;
-
-use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
-use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
-use Psr\Container\ContainerInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-
-/**
- * Class ThrowableResponseFactoryFactory
- *
- * Factory responsible for creating instances of ThrowableResponseFactory.
- * This factory SHALL ensure proper dependency injection of ThrowableStreamFactoryInterface
- * and ResponseFactoryInterface for configuring the ThrowableResponseFactory.
- *
- * @package CoiSA\ErrorHandler\Container\Factory
- */
-final class ThrowableResponseFactoryFactory
-{
-    /**
-     * Creates an instance of ThrowableResponseFactory.
-     *
-     * This method SHALL retrieve the required dependencies from the container:
-     * - ThrowableStreamFactoryInterface
-     * - ResponseFactoryInterface
-     *
-     * @param ContainerInterface $container The PSR-11 container instance.
-     *
-     * @return ThrowableResponseFactory The configured ThrowableResponseFactory instance.
-     */
-    public function __invoke(ContainerInterface $container): ThrowableResponseFactory
-    {
-        /** @var ThrowableStreamFactoryInterface $throwableStreamFactory */
-        $throwableStreamFactory = $container->get(ThrowableStreamFactoryInterface::class);
-
-        /** @var ResponseFactoryInterface $responseFactory */
-        $responseFactory = $container->get(ResponseFactoryInterface::class);
-
-        return new ThrowableResponseFactory($throwableStreamFactory, $responseFactory);
-    }
-}
diff --git a/src/Container/Factory/ThrowableStreamFactoryFactory.php b/src/Container/Factory/ThrowableStreamFactoryFactory.php
deleted file mode 100644
index cd55877..0000000
--- a/src/Container/Factory/ThrowableStreamFactoryFactory.php
+++ /dev/null
@@ -1,56 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container\Factory;
-
-use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
-use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
-use Psr\Container\ContainerInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-
-/**
- * Class ThrowableStreamFactoryFactory
- *
- * Factory responsible for creating instances of ThrowableStreamFactory.
- * This factory SHALL ensure proper dependency injection of StreamFactoryInterface
- * and ThrowableHandlerInterface for configuring the ThrowableStreamFactory.
- *
- * @package CoiSA\ErrorHandler\Container\Factory
- */
-final class ThrowableStreamFactoryFactory
-{
-    /**
-     * Creates an instance of ThrowableStreamFactory.
-     *
-     * This method SHALL retrieve the required dependencies from the container:
-     * - StreamFactoryInterface
-     * - ThrowableHandlerInterface
-     *
-     * @param ContainerInterface $container The PSR-11 container instance.
-     *
-     * @return ThrowableStreamFactory The configured ThrowableStreamFactory instance.
-     */
-    public function __invoke(ContainerInterface $container): ThrowableStreamFactory
-    {
-        /** @var StreamFactoryInterface $streamFactory */
-        $streamFactory = $container->get(StreamFactoryInterface::class);
-
-        /** @var ThrowableHandlerInterface $throwableHandler */
-        $throwableHandler = $container->get(ThrowableHandlerInterface::class);
-
-        return new ThrowableStreamFactory($streamFactory, $throwableHandler);
-    }
-}
diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php
index 9e624a7..ade629e 100644
--- a/src/ErrorHandler.php
+++ b/src/ErrorHandler.php
@@ -56,16 +56,34 @@ final class ErrorHandler implements
      */
     private bool $isRegistered = false;
 
+    /**
+     * @var bool Whether this instance already has a retained PHP shutdown callback.
+     */
     private bool $shutdownRegistered = false;
 
+    /**
+     * @var bool Prevents recursive delivery through the same reporter.
+     */
     private bool $isHandlingThrowable = false;
 
+    /**
+     * @var bool Tracks PHP's temporary exception-callback removal during termination.
+     */
     private bool $isHandlingUncaughtThrowable = false;
 
+    /**
+     * @var bool Prevents retrying a reporter that failed during the current registration.
+     */
     private bool $handlerFailed = false;
 
+    /**
+     * @var \Closure(int, string, string, int): bool Stable callback identity for native PHP errors.
+     */
     private \Closure $errorCallback;
 
+    /**
+     * @var \Closure(\Throwable): never Stable callback identity for uncaught throwables.
+     */
     private \Closure $exceptionCallback;
 
     /**
@@ -116,6 +134,8 @@ public function register(): void
      * Unregisters the error handler.
      *
      * This method SHALL restore the previous error and exception handlers.
+     *
+     * @throws \LogicException If another integration owns either active callback.
      */
     public function unregister(): void
     {
@@ -137,6 +157,9 @@ public function unregister(): void
      * Handles throwable exceptions.
      *
      * @param \Throwable $throwable The throwable exception to handle.
+     *
+     * @throws \LogicException If the reporter recursively invokes this handler.
+     * @throws \Throwable If reporting fails or the current registration already failed.
      */
     public function handleThrowable(\Throwable $throwable): void
     {
@@ -168,7 +191,7 @@ public function handleThrowable(\Throwable $throwable): void
      * @param string $filename The filename where the error occurred.
      * @param int $line The line number where the error occurred.
      *
-     * @throws \ErrorException When a PHP error is escalated.
+     * @throws \Throwable If the PHP handler or reporter fails to process the error.
      */
     public function handlePhpError(int $code, string $message, string $filename, int $line): void
     {
@@ -197,6 +220,18 @@ public function handleShutdown(): void
         }
     }
 
+    /**
+     * Adapts PHP's error callback to the public error-handling contract.
+     *
+     * @param int $code The PHP error level.
+     * @param string $message The error message.
+     * @param string $filename The source filename.
+     * @param int $line The source line number.
+     *
+     * @return bool True to suppress PHP's default handler after successful processing.
+     *
+     * @throws \Throwable If the PHP handler or reporter fails.
+     */
     private function onPhpError(int $code, string $message, string $filename, int $line): bool
     {
         $this->handlePhpError($code, $message, $filename, $line);
@@ -204,6 +239,13 @@ private function onPhpError(int $code, string $message, string $filename, int $l
         return true;
     }
 
+    /**
+     * Checks ownership without changing the native callback stacks or error masks.
+     *
+     * @param bool $duringShutdown Whether to allow PHP's cleared callback on the terminating path.
+     *
+     * @return bool True when this instance still owns both active callbacks.
+     */
     private function hasActiveCallbacks(bool $duringShutdown = false): bool
     {
         // Probe without changing the existing stacks or their error-level masks.
@@ -221,6 +263,15 @@ private function hasActiveCallbacks(bool $duringShutdown = false): bool
         return $currentErrorHandler === $this->errorCallback && $ownsExceptionCallback;
     }
 
+    /**
+     * Reports an uncaught throwable and terminates with a failure exit status.
+     *
+     * @param \Throwable $throwable The original uncaught throwable.
+     *
+     * @return never Successful reporting terminates the process with exit status 1.
+     *
+     * @throws \Throwable If reporting fails and PHP must take its native failure path.
+     */
     private function onUncaughtThrowable(\Throwable $throwable): never
     {
         $this->isHandlingUncaughtThrowable = true;
diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php
index 37de5f0..54a7e80 100644
--- a/src/Http/Message/ThrowableStreamFactory.php
+++ b/src/Http/Message/ThrowableStreamFactory.php
@@ -60,10 +60,15 @@ public function __construct(
      *
      * This method SHALL process the throwable using the handler and convert
      * its output into a PSR-7 compatible stream.
+     * It SHALL preserve the body "0" and use a generic fallback for empty output.
+     * Renderers MUST balance removable buffers and MUST NOT close caller-owned buffers.
      *
      * @param \Throwable $throwable The throwable instance to process.
      *
      * @return StreamInterface The rendered response body or a generic fallback.
+     *
+     * @throws \Throwable If rendering, buffer cleanup or stream construction fails.
+     *     A renderer failure takes precedence over any later cleanup failure.
      */
     public function createStreamFromThrowable(\Throwable $throwable): StreamInterface
     {
diff --git a/src/ServiceProvider/ErrorHandlerServiceProvider.php b/src/ServiceProvider/ErrorHandlerServiceProvider.php
index 1aaf64c..ee3f649 100644
--- a/src/ServiceProvider/ErrorHandlerServiceProvider.php
+++ b/src/ServiceProvider/ErrorHandlerServiceProvider.php
@@ -2,19 +2,24 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 namespace CoiSA\ErrorHandler\ServiceProvider;
 
-use CoiSA\ErrorHandler\Container\Factory\AliasFactory;
-use CoiSA\ErrorHandler\Container\Factory\DispatchErrorEventThrowableHandlerFactory;
-use CoiSA\ErrorHandler\Container\Factory\DispatchThrowableHandlerFactory;
 use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory;
 use CoiSA\ErrorHandler\Container\Factory\ThrowableHandlerAggregateFactory;
-use CoiSA\ErrorHandler\Container\Factory\ThrowableResponseFactoryFactory;
-use CoiSA\ErrorHandler\Container\Factory\ThrowableStreamFactoryFactory;
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\ErrorHandlerInterface;
-use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler;
-use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler;
 use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;
 use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
 use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
@@ -23,33 +28,45 @@
 use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
+use FastForward\Container\Factory\AliasFactory;
+use FastForward\Container\Factory\InvokableFactory;
 use Interop\Container\ServiceProviderInterface;
 
-/** Declares services without installing PHP's process-wide callbacks. */
+/**
+ * Declares lifecycle services and interface aliases for FastForward autowiring.
+ *
+ * Resolving a service MUST NOT install PHP's process-wide callbacks.
+ * Concrete dispatch and HTTP adapters SHALL be resolved through autowiring.
+ *
+ * @package CoiSA\ErrorHandler\ServiceProvider
+ */
 final class ErrorHandlerServiceProvider implements ServiceProviderInterface
 {
-    /** @return array */
+    /**
+     * Supplies aliases and factories that define reporter composition and handler lifetimes.
+     *
+     * @return array Lazy service definitions.
+     */
     public function getFactories(): array
     {
         return [
-            ErrorHandlerInterface::class => new AliasFactory(ErrorHandler::class),
+            ErrorHandlerInterface::class => AliasFactory::get(ErrorHandler::class),
             ErrorHandler::class => new ErrorHandlerFactory(),
-            ErrorHandlerFactory::class => static fn (): ErrorHandlerFactory => new ErrorHandlerFactory(),
-            ThrowableHandlerInterface::class => new AliasFactory(ThrowableHandlerAggregate::class),
+            ErrorHandlerFactory::class => new InvokableFactory(ErrorHandlerFactory::class),
+            ThrowableHandlerInterface::class => AliasFactory::get(ThrowableHandlerAggregate::class),
             ThrowableHandlerAggregate::class => new ThrowableHandlerAggregateFactory(),
-            DispatchThrowableHandler::class => new DispatchThrowableHandlerFactory(),
-            DispatchErrorEventThrowableHandler::class => new DispatchErrorEventThrowableHandlerFactory(),
-            ThrowableResponseFactoryInterface::class => new AliasFactory(ThrowableResponseFactory::class),
-            ThrowableResponseFactory::class => new ThrowableResponseFactoryFactory(),
-            ThrowableStreamFactoryInterface::class => new AliasFactory(ThrowableStreamFactory::class),
-            ThrowableStreamFactory::class => new ThrowableStreamFactoryFactory(),
+            ThrowableResponseFactoryInterface::class => AliasFactory::get(ThrowableResponseFactory::class),
+            ThrowableStreamFactoryInterface::class => AliasFactory::get(ThrowableStreamFactory::class),
             ErrorHandlerMiddleware::class => new ErrorHandlerMiddlewareFactory(),
-            ErrorHandlerMiddlewareFactory::class => static fn (): ErrorHandlerMiddlewareFactory =>
-                new ErrorHandlerMiddlewareFactory(),
+            ErrorHandlerMiddlewareFactory::class => new InvokableFactory(ErrorHandlerMiddlewareFactory::class),
         ];
     }
 
-    /** @return array */
+    /**
+     * Supplies no service extensions; applications own registration and decoration.
+     *
+     * @return array An empty extension map.
+     */
     public function getExtensions(): array
     {
         return [];
diff --git a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
index c7ed5d3..3de3e46 100644
--- a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
+++ b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 namespace CoiSA\ErrorHandler\ServiceProvider\Factory;
 
 use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory;
@@ -9,14 +21,29 @@
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use Psr\Container\ContainerInterface;
 
-/** Builds middleware with its own handler, separate from the process singleton. */
+/**
+ * Builds middleware with a fresh handler separate from the process service.
+ *
+ * @package CoiSA\ErrorHandler\ServiceProvider\Factory
+ */
 final class ErrorHandlerMiddlewareFactory
 {
+    /**
+     * Creates an independent middleware scope without registering PHP callbacks.
+     *
+     * @param ContainerInterface $container The container supplying a reusable handler factory and responses.
+     *
+     * @return ErrorHandlerMiddleware Middleware owning a fresh, initially unregistered handler.
+     */
     public function __invoke(ContainerInterface $container): ErrorHandlerMiddleware
     {
-        /** @var ErrorHandlerFactory $handlerFactory */
+        /**
+         * @var ErrorHandlerFactory $handlerFactory
+         */
         $handlerFactory = $container->get(ErrorHandlerFactory::class);
-        /** @var ThrowableResponseFactoryInterface $responses */
+        /**
+         * @var ThrowableResponseFactoryInterface $responses
+         */
         $responses = $container->get(ThrowableResponseFactoryInterface::class);
 
         return new ErrorHandlerMiddleware($handlerFactory($container), $responses);
diff --git a/tests/Fire/bootstrap.php b/tests/Fire/bootstrap.php
index 47ef4c8..d3ee29d 100644
--- a/tests/Fire/bootstrap.php
+++ b/tests/Fire/bootstrap.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 // Keep the crash tests independent from PHPUnit and optional integrations.
 spl_autoload_register(static function (string $class): void {
     $prefix = 'CoiSA\\ErrorHandler\\';
@@ -10,6 +22,12 @@
     }
 });
 
+/**
+ * Emits one structured child-process observation for the bounded fire-test runner.
+ *
+ * @param string $name The observation name expected by the parent runner.
+ * @param array $data Synthetic observation fields.
+ */
 function event(string $name, array $data = []): void
 {
     echo json_encode(['event' => $name] + $data, JSON_THROW_ON_ERROR) . "\n";
diff --git a/tests/Fire/run.php b/tests/Fire/run.php
index b2f3fde..7ba8800 100644
--- a/tests/Fire/run.php
+++ b/tests/Fire/run.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 // No PHPUnit/application state is shared with the crash scenarios.
 // Each child has a five-second deadline, a 16 MiB limit and a 256 KiB output cap.
 $cases = [
@@ -27,6 +39,8 @@
     'reporter-throws' => [255, ['reporter-called']],
     'shutdown-reporter-throws' => [255, ['reporter-called']],
     'recursive-reporter' => [255, ['reporter-called']],
+    'reporter-reset' => [0, ['reporter-called', 'first-failure', 'suppressed-retry', 'reporter-called'], null, null,
+        [1 => ['message' => 'reporter failed'], 2 => ['message' => 'second throwable']]],
     'reporter-warning' => [0, ['reporter-called', 'continued']],
     'shutdown-throws' => [0, ['error'], RuntimeException::class],
     'aggregate' => [0, ['first', 'second']],
diff --git a/tests/Fire/scenario.php b/tests/Fire/scenario.php
index 8734e31..27e6eb6 100644
--- a/tests/Fire/scenario.php
+++ b/tests/Fire/scenario.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\Handler\CallableShutdownHandler;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
@@ -173,6 +185,30 @@ static function (Throwable $error) use (&$handler): void {
         ));
         $handler->register();
         throw new RuntimeException('original failure');
+    case 'reporter-reset':
+        $calls = 0;
+        $handler = new ErrorHandler(new CallableThrowableHandler(static function () use (&$calls): void {
+            event('reporter-called');
+            if (++$calls === 1) {
+                throw new RuntimeException('reporter failed');
+            }
+        }));
+        $handler->register();
+        try {
+            $handler->handleThrowable(new RuntimeException('first throwable'));
+        } catch (RuntimeException $failure) {
+            event('first-failure', ['message' => $failure->getMessage()]);
+        }
+        try {
+            $handler->handleThrowable(new RuntimeException('second throwable'));
+        } catch (RuntimeException $failure) {
+            event('suppressed-retry', ['message' => $failure->getMessage()]);
+        }
+        $handler->unregister();
+        $handler->register();
+        $handler->handleThrowable(new RuntimeException('fresh lifecycle'));
+        $handler->unregister();
+        break;
     case 'reporter-warning':
         $handler = new ErrorHandler(new CallableThrowableHandler(static function (): void {
             event('reporter-called');
diff --git a/tests/Functional/Container/ErrorHandlerContainerTest.php b/tests/Functional/Container/ConfigProviderIntegrationTest.php
similarity index 73%
rename from tests/Functional/Container/ErrorHandlerContainerTest.php
rename to tests/Functional/Container/ConfigProviderIntegrationTest.php
index 535f4a6..29ea492 100644
--- a/tests/Functional/Container/ErrorHandlerContainerTest.php
+++ b/tests/Functional/Container/ConfigProviderIntegrationTest.php
@@ -17,7 +17,6 @@
 namespace CoiSA\ErrorHandler\Test\Functional\Container;
 
 use CoiSA\ErrorHandler\Container\ConfigProvider;
-use CoiSA\ErrorHandler\Container\ErrorHandlerContainer;
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent;
 use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener;
@@ -47,31 +46,36 @@
 use Psr\Http\Server\RequestHandlerInterface;
 
 /**
- * Class ErrorHandlerContainerTest.
+ * Verifies the Laminas configuration adapter's reporting and lifecycle behavior.
  *
  * @package CoiSA\ErrorHandler\Test\Functional\Container
  *
  * @internal
  * @coversNothing
  */
-final class ErrorHandlerContainerTest extends TestCase
+final class ConfigProviderIntegrationTest extends TestCase
 {
-    /** @var ServiceManager */
+    /**
+     * @var ServiceManager
+     */
     private $serviceManager;
 
-    /** @var ErrorHandlerContainer */
-    private $container;
-
-    /** @var EventDispatcher */
+    /**
+     * @var EventDispatcher
+     */
     private $eventDispatcher;
 
-    /** @var ListenerProviderAggregate */
+    /**
+     * @var ListenerProviderAggregate
+     */
     private $listenerProvider;
 
+    /**
+     * Creates isolated container and reporting collaborators for each test.
+     */
     protected function setUp(): void
     {
-        $this->serviceManager = new ServiceManager();
-        $this->container      = new ErrorHandlerContainer($this->serviceManager);
+        $this->serviceManager = new ServiceManager((new ConfigProvider())->getDependencies());
 
         $this->listenerProvider = new ListenerProviderAggregate();
         $this->eventDispatcher  = new EventDispatcher($this->listenerProvider);
@@ -82,11 +86,19 @@ protected function setUp(): void
         $this->serviceManager->setService(StreamFactoryInterface::class, $factory);
     }
 
+    /**
+     * Restores native PHP callbacks after each integration test.
+     */
     protected function tearDown(): void
     {
-        $this->container->get(ErrorHandler::class)->unregister();
+        $this->serviceManager->get(ErrorHandler::class)->unregister();
     }
 
+    /**
+     * Provides the concrete services declared by the Laminas adapter.
+     *
+     * @return list Concrete service-identifier cases.
+     */
     public static function provideServiceProviderClassNames(): array
     {
         $factories = (new ConfigProvider())->getFactories();
@@ -94,31 +106,47 @@ public static function provideServiceProviderClassNames(): array
         return array_chunk(array_keys($factories), 1);
     }
 
+    /**
+     * Verifies the configured container advertises every library service.
+     *
+     * @param class-string $service The service identifier under test.
+     */
     #[DataProvider('provideServiceProviderClassNames')]
     public function testContainerHasConfigProviderFactories(string $service): void
     {
-        static::assertTrue($this->container->has($service));
+        static::assertTrue($this->serviceManager->has($service));
     }
 
+    /**
+     * Verifies service construction with application HTTP and event dependencies.
+     *
+     * @param class-string $service The service identifier under test.
+     */
     #[DataProvider('provideServiceProviderClassNames')]
     public function testContainerProvideConfigProviderFactories(string $service): void
     {
         $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher);
-        static::assertInstanceOf($service, $this->container->get($service));
+        static::assertInstanceOf($service, $this->serviceManager->get($service));
     }
 
+    /**
+     * Verifies an empty aggregate intentionally produces no diagnostics.
+     */
     public function testErrorHandlerWithoutHandlerWillDoNothing(): void
     {
         $message   = uniqid('test', true);
         $exception = new \InvalidArgumentException($message);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $this->expectNotToPerformAssertions();
         $errorHandler->handleThrowable($exception);
     }
 
+    /**
+     * Verifies an explicit reporter receives the original throwable.
+     */
     public function testCallableHandlerWillHandleThrowable(): void
     {
         $message   = uniqid('test', true);
@@ -129,12 +157,15 @@ public function testCallableHandlerWillHandleThrowable(): void
         });
         $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleThrowable($exception);
     }
 
+    /**
+     * Verifies optional callable reporting through the default aggregate.
+     */
     public function testCallableHandlerFromAggregationWillHandleThrowable(): void
     {
         $message   = uniqid('test', true);
@@ -145,12 +176,15 @@ public function testCallableHandlerFromAggregationWillHandleThrowable(): void
         });
         $this->serviceManager->setService(CallableThrowableHandler::class, $callableThrowableHandler);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleThrowable($exception);
     }
 
+    /**
+     * Verifies throwable and error-event listeners receive the original diagnostics.
+     */
     public function testEventDispatcherCallableHandlerWillHandleThrowable(): void
     {
         $message   = uniqid('test', true);
@@ -181,12 +215,15 @@ public function testEventDispatcherCallableHandlerWillHandleThrowable(): void
 
         $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleThrowable($exception);
     }
 
+    /**
+     * Verifies masked PHP notices do not reach the reporter.
+     */
     public function testErrorHandlerRespectErrorReporting(): void
     {
         $callableThrowableHandler = new CallableThrowableHandler(function ($throwable): void {
@@ -194,7 +231,7 @@ public function testErrorHandlerRespectErrorReporting(): void
         });
         $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $oldLevel = error_reporting(E_ALL ^ E_USER_NOTICE);
@@ -204,6 +241,9 @@ public function testErrorHandlerRespectErrorReporting(): void
         static::assertTrue(true);
     }
 
+    /**
+     * Verifies reportable PHP warnings are converted to ErrorException.
+     */
     #[WithoutErrorHandler]
     public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): void
     {
@@ -212,12 +252,15 @@ public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): voi
         });
         $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         trigger_error('Test user warning', E_USER_WARNING);
     }
 
+    /**
+     * Verifies middleware captures downstream exceptions and renders an application response.
+     */
     public function testErrorHandlerMiddlewareHandleRequestException(): void
     {
         $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500));
@@ -230,25 +273,40 @@ public function testErrorHandlerMiddlewareHandleRequestException(): void
         });
         $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $serverRequest  = (new Psr17Factory())->createServerRequest('GET', '/');
         $requestHandler = new class ($exception) implements RequestHandlerInterface {
+            /**
+             * @var \Throwable The downstream failure to propagate.
+             */
             private $exception;
 
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param \Throwable $exception The downstream throwable.
+             */
             public function __construct(\Throwable $exception)
             {
                 $this->exception = $exception;
             }
 
+            /**
+             * Executes the synthetic downstream behavior for the requested test route.
+             *
+             * @param ServerRequestInterface $serverRequest The synthetic incoming request.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function handle(ServerRequestInterface $serverRequest): ResponseInterface
             {
                 throw $this->exception;
             }
         };
 
-        $middleware = $this->container->get(ErrorHandlerMiddleware::class);
+        $middleware = $this->serviceManager->get(ErrorHandlerMiddleware::class);
         $response   = $middleware->process($serverRequest, $requestHandler);
 
         static::assertInstanceOf(ResponseInterface::class, $response);
@@ -261,13 +319,19 @@ public function handle(ServerRequestInterface $serverRequest): ResponseInterface
         static::assertSame($exception->getMessage(), $body['message']);
     }
 
+    /**
+     * Verifies an inactive handler does not process shutdown notifications.
+     */
     public function testUnregisteredErrorHandlerHandleShutdownWillDoNothing(): void
     {
         $this->expectOutputString('');
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->handleShutdown();
     }
 
+    /**
+     * Verifies a custom shutdown failure reaches the configured reporter.
+     */
     public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHandler(): void
     {
         $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500));
@@ -283,15 +347,18 @@ public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHan
         $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
         $this->serviceManager->setService(ShutdownHandlerInterface::class, $callableShutdownHandler);
 
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleShutdown();
     }
 
+    /**
+     * Verifies unregistering an inactive handler is idempotent.
+     */
     public function testUnregisterNotRegisteredErrorHandlerShouldNotThrowExceptions(): void
     {
-        $errorHandler = $this->container->get(ErrorHandler::class);
+        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
         $errorHandler->unregister();
         static::assertTrue(true);
     }
diff --git a/tests/Functional/Container/ConfigProviderTest.php b/tests/Functional/Container/ConfigProviderTest.php
index 536163e..12c18a3 100644
--- a/tests/Functional/Container/ConfigProviderTest.php
+++ b/tests/Functional/Container/ConfigProviderTest.php
@@ -17,7 +17,6 @@
 namespace CoiSA\ErrorHandler\Test\Functional\Container;
 
 use CoiSA\ErrorHandler\Container\ConfigProvider;
-use CoiSA\ErrorHandler\Container\ErrorHandlerContainer;
 use Laminas\ServiceManager\ServiceManager;
 use Nyholm\Psr7\Factory\Psr17Factory;
 use Phly\EventDispatcher\EventDispatcher;
@@ -38,26 +37,34 @@
  */
 final class ConfigProviderTest extends TestCase
 {
-    /** @var ServiceManager */
+    /**
+     * @var ServiceManager
+     */
     private $serviceManager;
 
-    /** @var ErrorHandlerContainer */
-    private $container;
-
-    /** @var ConfigProvider */
+    /**
+     * @var ConfigProvider
+     */
     private $configProvider;
 
+    /**
+     * Creates isolated container and reporting collaborators for each test.
+     */
     protected function setUp(): void
     {
         $this->configProvider = new ConfigProvider();
         $configs              = ($this->configProvider)();
 
         $this->serviceManager = new ServiceManager($configs['dependencies']);
-        $this->container      = new ErrorHandlerContainer($this->serviceManager);
 
         $this->setUpServiceManager($this->serviceManager);
     }
 
+    /**
+     * Supplies application-owned PSR-14 and PSR-17 implementations.
+     *
+     * @param ServiceManager $serviceManager Service Manager for the fixture.
+     */
     public function setUpServiceManager(ServiceManager $serviceManager): void
     {
         $listenerProvider = new ListenerProviderAggregate();
@@ -69,6 +76,11 @@ public function setUpServiceManager(ServiceManager $serviceManager): void
         $serviceManager->setService(StreamFactoryInterface::class, $factory);
     }
 
+    /**
+     * Provides every service identifier declared by the Laminas adapter.
+     *
+     * @return list Independent service-identifier cases.
+     */
     public static function provideContainerDependenciesClassNames(): array
     {
         $dependencies = (new ConfigProvider())->getDependencies();
@@ -76,25 +88,22 @@ public static function provideContainerDependenciesClassNames(): array
         return array_chunk(array_keys(array_merge(...array_values($dependencies))), 1);
     }
 
+    /**
+     * Verifies Laminas advertises each configured service identifier.
+     *
+     * @param class-string $className The configured service identifier.
+     */
     #[DataProvider('provideContainerDependenciesClassNames')]
     public function testServiceManagerHasConfigProviderDependencies(string $className): void
     {
         static::assertTrue($this->serviceManager->has($className));
     }
 
-    #[DataProvider('provideContainerDependenciesClassNames')]
-    public function testContainerWithServiceManagerHasConfigProviderDependencies(string $className): void
-    {
-        static::assertTrue($this->container->has($className));
-    }
-
-    #[DataProvider('provideContainerDependenciesClassNames')]
-    public function testContainerWithoutServiceManagerHasConfigProviderDependencies(string $className): void
-    {
-        $container = new ErrorHandlerContainer();
-        static::assertTrue($container->has($className));
-    }
-
+    /**
+     * Provides service identifiers and their expected concrete types.
+     *
+     * @return list Service identifiers and expected types.
+     */
     public static function provideContainerDependenciesWithInstanceType(): array
     {
         $dependencies = (new ConfigProvider())->getDependencies();
@@ -110,31 +119,16 @@ public static function provideContainerDependenciesWithInstanceType(): array
         }, $classNames);
     }
 
+    /**
+     * Verifies Laminas resolves each alias and factory to its expected type.
+     *
+     * @param class-string $className The configured service identifier.
+     * @param class-string $instanceOf The expected resolved service type.
+     */
     #[DataProvider('provideContainerDependenciesWithInstanceType')]
     public function testServiceManagerCanCreateDependecyThatImplements(string $className, string $instanceOf): void
     {
         $object = $this->serviceManager->get($className);
         static::assertInstanceOf($instanceOf, $object);
     }
-
-    #[DataProvider('provideContainerDependenciesWithInstanceType')]
-    public function testContainerCanCreateDependecyThatImplements(string $className, string $instanceOf): void
-    {
-        $object = $this->container->get($className);
-        static::assertInstanceOf($instanceOf, $object);
-    }
-
-    #[DataProvider('provideContainerDependenciesWithInstanceType')]
-    public function testWithCleanServiceManagerCanCreateDependecyThatImplements(
-        string $className,
-        string $instanceOf
-    ): void {
-        $serviceManager = new ServiceManager();
-        $container      = new ErrorHandlerContainer($serviceManager);
-
-        $this->setUpServiceManager($serviceManager);
-
-        $object = $container->get($className);
-        static::assertInstanceOf($instanceOf, $object);
-    }
 }
diff --git a/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php
index 55ed6d8..956fb66 100644
--- a/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php
+++ b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 namespace CoiSA\ErrorHandler\Test\Functional\Http\Middleware;
 
 use CoiSA\ErrorHandler\ErrorHandler;
@@ -14,8 +26,18 @@
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\RequestHandlerInterface;
 
+/**
+ * Verifies native callback restoration after response construction fails.
+ *
+ * @package CoiSA\ErrorHandler\Test\Functional\Http\Middleware
+ *
+ * @internal
+ */
 final class ErrorHandlerCleanupTest extends TestCase
 {
+    /**
+     * Verifies response failures preserve the previous error and exception callbacks.
+     */
     public function testRestoresHandlersEvenWhenTheResponseFactoryThrows(): void
     {
         $previousError = static fn (): bool => true;
@@ -25,15 +47,34 @@ public function testRestoresHandlersEvenWhenTheResponseFactoryThrows(): void
         set_exception_handler($previousException);
         $failure = new \RuntimeException('response factory failed');
         $responses = new class ($failure) implements ThrowableResponseFactoryInterface {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param \Throwable $failure The synthetic failure to propagate.
+             */
             public function __construct(private \Throwable $failure)
             {
             }
+            /**
+             * Throws the configured rendering failure to exercise callback restoration.
+             *
+             * @param \Throwable $throwable The throwable being rendered.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface
             {
                 throw $this->failure;
             }
         };
         $next = new class () implements RequestHandlerInterface {
+            /**
+             * Executes the synthetic downstream behavior for the requested test route.
+             *
+             * @param ServerRequestInterface $request The synthetic incoming request.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function handle(ServerRequestInterface $request): ResponseInterface
             {
                 throw new \TypeError('request failed');
diff --git a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php
index 0be46ca..aa96f99 100644
--- a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php
+++ b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php
@@ -30,13 +30,16 @@
 /**
  * Class ErrorHandlerMiddlewareTest.
  *
- * @package CoiSA\ErrorHandler\Test\Functional\Container
+ * @package CoiSA\ErrorHandler\Test\Functional\Http\Middleware
  *
  * @internal
  * @coversNothing
  */
 final class ErrorHandlerMiddlewareTest extends TestCase
 {
+    /**
+     * Verifies middleware captures downstream exceptions and renders an application response.
+     */
     public function testErrorHandlerMiddlewareHandleRequestException(): void
     {
         $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500));
@@ -52,13 +55,28 @@ public function testErrorHandlerMiddlewareHandleRequestException(): void
         $serverRequest  = $streamFactory->createServerRequest('GET', 'http://localhost');
 
         $requestHandler = new class ($exception) implements RequestHandlerInterface {
+            /**
+             * @var \Throwable The downstream failure to propagate.
+             */
             private $exception;
 
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param \Throwable $exception The downstream throwable.
+             */
             public function __construct(\Throwable $exception)
             {
                 $this->exception = $exception;
             }
 
+            /**
+             * Executes the synthetic downstream behavior for the requested test route.
+             *
+             * @param ServerRequestInterface $request The synthetic incoming request.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function handle(ServerRequestInterface $request): ResponseInterface
             {
                 throw $this->exception;
diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
index 12ba547..9d7810d 100644
--- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
+++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 namespace CoiSA\ErrorHandler\Test\Functional\ServiceProvider;
 
 use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory;
@@ -11,14 +23,20 @@
 use CoiSA\ErrorHandler\Exception\ErrorException;
 use CoiSA\ErrorHandler\Handler\CallableShutdownHandler;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler;
+use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler;
 use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface;
 use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface;
 use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
 use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface;
+use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
+use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
+use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
 use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
 use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use FastForward\Container\ServiceProvider\ArrayServiceProvider;
 use Interop\Container\ServiceProviderInterface;
 use Nyholm\Psr7\Factory\Psr17Factory;
 use PHPUnit\Framework\Attributes\WithoutErrorHandler;
@@ -36,8 +54,110 @@
 
 use function FastForward\Container\container;
 
+/**
+ * Verifies real FastForward wiring, reporting failures and independent middleware scopes.
+ *
+ * @package CoiSA\ErrorHandler\Test\Functional\ServiceProvider
+ *
+ * @internal
+ */
 final class ErrorHandlerServiceProviderTest extends TestCase
 {
+    /**
+     * Verifies constructor autowiring preserves the provider's public interface aliases.
+     */
+    public function testHttpAdaptersAreAutowiredAndShareTheirInterfaceServices(): void
+    {
+        $received = [];
+        $container = self::httpServices($received);
+        self::assertInstanceOf(ThrowableStreamFactory::class, $container->get(ThrowableStreamFactory::class));
+        self::assertSame(
+            $container->get(ThrowableStreamFactory::class),
+            $container->get(ThrowableStreamFactoryInterface::class)
+        );
+        self::assertInstanceOf(ThrowableResponseFactory::class, $container->get(ThrowableResponseFactory::class));
+        self::assertSame(
+            $container->get(ThrowableResponseFactory::class),
+            $container->get(ThrowableResponseFactoryInterface::class)
+        );
+    }
+
+    /**
+     * Verifies explicit concrete adapter overrides remain effective under autowiring.
+     */
+    public function testApplicationCanOverrideAnAutowiredConcreteAdapter(): void
+    {
+        $renderer = new ThrowableStreamFactory(new Psr17Factory(), new CallableThrowableHandler(
+            static function (): void {
+                echo 'Application response';
+            }
+        ));
+        $container = self::services([ThrowableStreamFactory::class => $renderer]);
+
+        self::assertSame($renderer, $container->get(ThrowableStreamFactoryInterface::class));
+        self::assertSame(
+            'Application response',
+            (string) $renderer->createStreamFromThrowable(new \RuntimeException())
+        );
+    }
+
+    /**
+     * Verifies missing required HTTP dependencies fail construction instead of returning partial middleware.
+     */
+    public function testMissingPsr17FactoriesPreventMiddlewareConstruction(): void
+    {
+        $container = self::services();
+        $this->expectException(ContainerExceptionInterface::class);
+        $container->get(ErrorHandlerMiddleware::class);
+    }
+
+    /**
+     * Verifies required renderer interface failures cannot fall back to an autowired renderer.
+     *
+     * @param class-string $service The required renderer interface.
+     * @param \Throwable $failure The synthetic construction failure.
+     * @param class-string<\Throwable> $expectedException The expected propagated failure category.
+     */
+    #[DataProvider('requiredRendererFailures')]
+    public function testRequiredRendererFailurePreventsMiddlewareConstruction(
+        string $service,
+        \Throwable $failure,
+        string $expectedException
+    ): void {
+        $application = new ArrayServiceProvider([
+            $service => static function () use ($failure): never {
+                throw $failure;
+            },
+            ResponseFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
+            StreamFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
+        ]);
+        $container = self::withApplication($application);
+        $this->expectException($expectedException);
+
+        $container->get(ErrorHandlerMiddleware::class);
+    }
+
+    /**
+     * Provides PSR and application failures for both required renderer interfaces.
+     *
+     * @return iterable}>
+     *     Required renderer construction failures.
+     */
+    public static function requiredRendererFailures(): iterable
+    {
+        foreach (self::requiredReporterFailures() as $label => [$service, $failure, $expectedException]) {
+            if ($service !== ErrorHandlerInterface::class) {
+                continue;
+            }
+            foreach ([ThrowableStreamFactoryInterface::class, ThrowableResponseFactoryInterface::class] as $renderer) {
+                yield $renderer . ' / ' . $label => [$renderer, $failure, $expectedException];
+            }
+        }
+    }
+
+    /**
+     * Verifies provider bootstrap is lazy and preserves process-handler alias identity.
+     */
     public function testProviderClassBootstrapIsLazyAndAliasesTheProcessHandler(): void
     {
         $before = self::callbacks();
@@ -52,6 +172,9 @@ public function testProviderClassBootstrapIsLazyAndAliasesTheProcessHandler(): v
         $handler->handleThrowable(new \RuntimeException('No reporter is configured.'));
     }
 
+    /**
+     * Verifies application definitions replace the silent default reporter.
+     */
     public function testApplicationReporterOverridesTheDefaultAggregate(): void
     {
         $received = [];
@@ -67,6 +190,13 @@ public function testApplicationReporterOverridesTheDefaultAggregate(): void
         self::assertSame([$error], $received);
     }
 
+    /**
+     * Verifies required reporter failures prevent process and middleware construction.
+     *
+     * @param class-string $service The service identifier under test.
+     * @param \Throwable $failure The synthetic failure to propagate.
+     * @param class-string<\Throwable> $expectedException The expected propagated failure type.
+     */
     #[DataProvider('requiredReporterFailures')]
     public function testRequiredReporterFailureCannotFallBackToASilentDefault(
         string $service,
@@ -74,10 +204,20 @@ public function testRequiredReporterFailureCannotFallBackToASilentDefault(
         string $expectedException
     ): void {
         $application = new class ($failure) implements ServiceProviderInterface {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param \Throwable $failure The synthetic failure to propagate.
+             */
             public function __construct(private \Throwable $failure)
             {
             }
 
+            /**
+             * Supplies application service definitions for the integration fixture.
+             *
+             * @return array Application service factories.
+             */
             public function getFactories(): array
             {
                 return [
@@ -89,6 +229,11 @@ public function getFactories(): array
                 ];
             }
 
+            /**
+             * Declares no application service extensions.
+             *
+             * @return array An empty extension map.
+             */
             public function getExtensions(): array
             {
                 return [];
@@ -100,6 +245,12 @@ public function getExtensions(): array
         $container->get($service);
     }
 
+    /**
+     * Provides required-reporter failure categories for both lifecycle services.
+     *
+     * @return iterable}>
+     *     Required-reporter failure cases.
+     */
     public static function requiredReporterFailures(): iterable
     {
         $failures = [
@@ -122,6 +273,9 @@ public static function requiredReporterFailures(): iterable
         }
     }
 
+    /**
+     * Verifies callable reporting works without a PSR-14 dispatcher.
+     */
     public function testAnOptionalCallableReporterIsComposedWithoutAnEventDispatcher(): void
     {
         $received = [];
@@ -139,11 +293,24 @@ static function (\Throwable $error) use (&$received): void {
         self::assertSame([$error], $received);
     }
 
+    /**
+     * Verifies autowired dispatch adapters deliver both supported event forms.
+     */
     public function testOptionalPsr14DispatcherReceivesTheThrowableAndErrorEvent(): void
     {
         $dispatcher = new class () implements EventDispatcherInterface {
+            /**
+             * @var list Events received by the recording dispatcher.
+             */
             public array $events = [];
 
+            /**
+             * Records the event and returns the same object to the caller.
+             *
+             * @param object $event Event for the fixture.
+             *
+             * @return object The fixture result.
+             */
             public function dispatch(object $event): object
             {
                 $this->events[] = $event;
@@ -153,6 +320,12 @@ public function dispatch(object $event): object
         $container = self::services([EventDispatcherInterface::class => $dispatcher]);
         $error = new \RuntimeException('Dispatched');
 
+        self::assertInstanceOf(DispatchThrowableHandler::class, $container->get(DispatchThrowableHandler::class));
+        self::assertInstanceOf(
+            DispatchErrorEventThrowableHandler::class,
+            $container->get(DispatchErrorEventThrowableHandler::class)
+        );
+
         $container->get(ErrorHandlerInterface::class)->handleThrowable($error);
 
         self::assertCount(2, $dispatcher->events);
@@ -161,12 +334,26 @@ public function dispatch(object $event): object
         self::assertSame($error, $dispatcher->events[1]->getThrowable());
     }
 
+    /**
+     * Verifies optional application callbacks are injected and previous callbacks restored.
+     */
     #[WithoutErrorHandler]
     public function testCustomPhpAndShutdownHandlersAreInjected(): void
     {
         $phpHandler = new class () implements PhpErrorHandlerInterface {
+            /**
+             * @var list Messages received by the synthetic PHP error handler.
+             */
             public array $messages = [];
 
+            /**
+             * Records a PHP error message without escalating the synthetic warning.
+             *
+             * @param int $errno Errno for the fixture.
+             * @param string $message Message for the fixture.
+             * @param string $file File for the fixture.
+             * @param int $line Line for the fixture.
+             */
             public function handlePhpError(int $errno, string $message, string $file, int $line): void
             {
                 $this->messages[] = $message;
@@ -196,6 +383,9 @@ static function () use (&$shutdowns): void {
         self::assertSame($before, self::callbacks());
     }
 
+    /**
+     * Verifies factory invocations create independent handlers while process lookups remain cached.
+     */
     public function testReusableFactoryCreatesFreshHandlersWithoutReplacingTheProcessService(): void
     {
         $container = self::services();
@@ -210,6 +400,9 @@ public function testReusableFactoryCreatesFreshHandlersWithoutReplacingTheProces
         self::assertSame($process, $container->get(ErrorHandlerInterface::class));
     }
 
+    /**
+     * Verifies middleware restores the active process handler after a downstream failure.
+     */
     #[WithoutErrorHandler]
     public function testMiddlewareRestoresTheAlreadyRegisteredProcessHandler(): void
     {
@@ -238,6 +431,9 @@ public function testMiddlewareRestoresTheAlreadyRegisteredProcessHandler(): void
         self::assertSame('After middleware', $received[1]->getMessage());
     }
 
+    /**
+     * Verifies nested middleware uses independent handlers and restores the process scope.
+     */
     #[WithoutErrorHandler]
     public function testFactoryCreatesIndependentMiddlewareForNestedScopes(): void
     {
@@ -248,12 +444,25 @@ public function testFactoryCreatesIndependentMiddlewareForNestedScopes(): void
         $inner = $factory($container);
         $failure = new \RuntimeException('Nested');
         $downstream = new class ($inner, self::failsWith($failure)) implements RequestHandlerInterface {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param ErrorHandlerMiddleware $inner Inner for the fixture.
+             * @param RequestHandlerInterface $downstream Downstream for the fixture.
+             */
             public function __construct(
                 private ErrorHandlerMiddleware $inner,
                 private RequestHandlerInterface $downstream
             ) {
             }
 
+            /**
+             * Executes the synthetic downstream behavior for the requested test route.
+             *
+             * @param ServerRequestInterface $request The synthetic incoming request.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function handle(ServerRequestInterface $request): ResponseInterface
             {
                 return $this->inner->process($request, $this->downstream);
@@ -274,15 +483,30 @@ public function handle(ServerRequestInterface $request): ResponseInterface
         self::assertSame([$failure], $received);
     }
 
+    /**
+     * Verifies renderer failures propagate after restoring the active process callbacks.
+     */
     #[WithoutErrorHandler]
     public function testRendererFailureRestoresTheProcessHandler(): void
     {
         $failure = new \RuntimeException('Renderer failed');
         $renderer = new class ($failure) implements ThrowableResponseFactoryInterface {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param \Throwable $failure The synthetic failure to propagate.
+             */
             public function __construct(private \Throwable $failure)
             {
             }
 
+            /**
+             * Throws the configured rendering failure to exercise callback restoration.
+             *
+             * @param \Throwable $throwable The throwable being rendered.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface
             {
                 throw $this->failure;
@@ -306,20 +530,41 @@ public function createResponseFromThrowable(\Throwable $throwable): ResponseInte
         }
     }
 
-    /** @param array $services */
+    /**
+     * Composes library definitions with application service instances.
+     *
+     * @param array $services Application service overrides.
+     *
+     * @return ContainerInterface The container with application definitions merged last.
+     */
     private static function services(array $services = []): ContainerInterface
     {
         $application = new class ($services) implements ServiceProviderInterface {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param array $services Application service instances.
+             */
             public function __construct(private array $services)
             {
             }
 
+            /**
+             * Supplies application service definitions for the integration fixture.
+             *
+             * @return array Application service factories.
+             */
             public function getFactories(): array
             {
                 return array_map(static fn (object $service): \Closure =>
                     static fn (): object => $service, $this->services);
             }
 
+            /**
+             * Declares no application service extensions.
+             *
+             * @return array An empty extension map.
+             */
             public function getExtensions(): array
             {
                 return [];
@@ -329,11 +574,25 @@ public function getExtensions(): array
         return self::withApplication($application);
     }
 
+    /**
+     * Merges application definitions last to prevent silent required-reporter fallback.
+     *
+     * @param ServiceProviderInterface $application Application for the fixture.
+     *
+     * @return ContainerInterface The fixture result.
+     */
     private static function withApplication(ServiceProviderInterface $application): ContainerInterface
     {
         return container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
     }
 
+    /**
+     * Supplies HTTP factories and records private reporting for middleware tests.
+     *
+     * @param list<\Throwable> $received Receives privately reported throwables.
+     *
+     * @return ContainerInterface The fixture result.
+     */
     private static function httpServices(array &$received): ContainerInterface
     {
         $psr17 = new Psr17Factory();
@@ -349,18 +608,42 @@ static function (\Throwable $error) use (&$received): void {
         ]);
     }
 
+    /**
+     * Creates a synthetic request for the provider integration tests.
+     *
+     * @return ServerRequestInterface The fixture result.
+     */
     private static function request(): ServerRequestInterface
     {
         return (new Psr17Factory())->createServerRequest('GET', '/provider-test');
     }
 
+    /**
+     * Creates a downstream handler that throws the supplied failure.
+     *
+     * @param \Throwable $failure The synthetic failure to propagate.
+     *
+     * @return RequestHandlerInterface The fixture result.
+     */
     private static function failsWith(\Throwable $failure): RequestHandlerInterface
     {
         return new class ($failure) implements RequestHandlerInterface {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             *
+             * @param \Throwable $failure The synthetic failure to propagate.
+             */
             public function __construct(private \Throwable $failure)
             {
             }
 
+            /**
+             * Executes the synthetic downstream behavior for the requested test route.
+             *
+             * @param ServerRequestInterface $request The synthetic incoming request.
+             *
+             * @return ResponseInterface The fixture result.
+             */
             public function handle(ServerRequestInterface $request): ResponseInterface
             {
                 throw $this->failure;
@@ -368,6 +651,11 @@ public function handle(ServerRequestInterface $request): ResponseInterface
         };
     }
 
+    /**
+     * Snapshots native PHP callbacks while preserving their stacks and error masks.
+     *
+     * @return array{0: callable|null, 1: callable|null} Current error and exception callbacks.
+     */
     private static function callbacks(): array
     {
         $error = set_error_handler(static fn (): bool => false);
diff --git a/tests/Http/router.php b/tests/Http/router.php
index f6f4b23..ff09d5f 100644
--- a/tests/Http/router.php
+++ b/tests/Http/router.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
 use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
@@ -23,6 +35,13 @@
     $factory
 ));
 $next = new class () implements RequestHandlerInterface {
+    /**
+     * Executes the synthetic downstream behavior for the requested test route.
+     *
+     * @param ServerRequestInterface $request The synthetic incoming request.
+     *
+     * @return ResponseInterface The fixture result.
+     */
     public function handle(ServerRequestInterface $request): ResponseInterface
     {
         if ($request->getUri()->getPath() === '/unavailable') {
diff --git a/tests/Http/run.php b/tests/Http/run.php
index 1a9368d..d5e2a6e 100644
--- a/tests/Http/run.php
+++ b/tests/Http/run.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 // Reserve a loopback port; every fixture server and request has a bounded lifetime.
 $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
 if ($socket === false) {
diff --git a/tests/StaticAnalysis/output-buffer.stub b/tests/StaticAnalysis/output-buffer.stub
index b325e7d..e64eaeb 100644
--- a/tests/StaticAnalysis/output-buffer.stub
+++ b/tests/StaticAnalysis/output-buffer.stub
@@ -1,5 +1,17 @@
 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 /**
  * A user-defined output callback can throw during buffer removal.
  * Proven by the isolated render-cleanup-throws regression.
diff --git a/tests/Unit/Container/ErrorHandlerContainerTest.php b/tests/Unit/Container/ErrorHandlerContainerTest.php
deleted file mode 100644
index 969f679..0000000
--- a/tests/Unit/Container/ErrorHandlerContainerTest.php
+++ /dev/null
@@ -1,71 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Test\Unit\Container;
-
-use CoiSA\ErrorHandler\Container\ErrorHandlerContainer;
-use CoiSA\ErrorHandler\Container\Exception\ContainerException;
-use CoiSA\ErrorHandler\Container\Exception\NotFoundException;
-use CoiSA\ErrorHandler\ErrorHandler;
-use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
-use PHPUnit\Framework\TestCase;
-use PHPUnit\Framework\MockObject\Stub;
-use Psr\Container\ContainerInterface;
-
-/**
- * Class ErrorHandlerContainerTest.
- *
- * @package CoiSA\ErrorHandler\Test\Unit\Container
- *
- * @internal
- * @coversNothing
- */
-final class ErrorHandlerContainerTest extends TestCase
-{
-    /** @var ContainerInterface&Stub */
-    private $innerContainer;
-
-    /** @var ErrorHandlerContainer */
-    private $container;
-
-    protected function setUp(): void
-    {
-        $this->innerContainer = $this->createStub(ContainerInterface::class);
-        $this->container      = new ErrorHandlerContainer($this->innerContainer);
-    }
-
-    public function testGetWithInvalidServiceWillThrowNotFoundException(): void
-    {
-        $this->expectException(NotFoundException::class);
-        $index = uniqid('test', true);
-        $this->innerContainer->method('has')->willReturn(false);
-        $this->container->get($index);
-    }
-
-    public function testGetWithServiceThatThrowsExceptionWillThrowContainerException(): void
-    {
-        $this->innerContainer->method('has')->willReturnMap([
-            [ErrorHandler::class, false],
-            [ThrowableHandlerInterface::class, true],
-        ]);
-        $this->innerContainer->method('get')->willThrowException(new \UnexpectedValueException());
-
-        $container = new ErrorHandlerContainer($this->innerContainer);
-
-        $this->expectException(ContainerException::class);
-        $container->get(ErrorHandler::class);
-    }
-}
diff --git a/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php b/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php
index 9e84712..b1d6ceb 100644
--- a/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php
+++ b/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 namespace CoiSA\ErrorHandler\Test\Unit\Container;
 
 use CoiSA\ErrorHandler\Container\Factory\ThrowableHandlerAggregateFactory;
@@ -12,8 +24,18 @@
 use Psr\Container\ContainerInterface;
 use Psr\Container\NotFoundExceptionInterface;
 
+/**
+ * Verifies optional reporter discovery and propagation of construction failures.
+ *
+ * @package CoiSA\ErrorHandler\Test\Unit\Container
+ *
+ * @internal
+ */
 final class ThrowableHandlerAggregateFactoryTest extends TestCase
 {
+    /**
+     * Verifies an unconstructible optional callable does not prevent aggregate construction.
+     */
     public function testAnAdvertisedButMissingOptionalCallableIsSkipped(): void
     {
         $missing = new class ('Missing callable') extends \RuntimeException implements NotFoundExceptionInterface {
@@ -25,6 +47,11 @@ public function testAnAdvertisedButMissingOptionalCallableIsSkipped(): void
         $aggregate->handleThrowable(new \RuntimeException('No reporter'));
     }
 
+    /**
+     * Verifies reporter construction failures other than not-found propagate.
+     *
+     * @param \Throwable $failure The synthetic failure to propagate.
+     */
     #[DataProvider('constructionFailures')]
     public function testConstructionErrorsAreNotHidden(\Throwable $failure): void
     {
@@ -34,6 +61,11 @@ public function testConstructionErrorsAreNotHidden(\Throwable $failure): void
         (new ThrowableHandlerAggregateFactory())($container);
     }
 
+    /**
+     * Provides PSR container and ordinary application construction failures.
+     *
+     * @return iterable Synthetic construction failures.
+     */
     public static function constructionFailures(): iterable
     {
         yield 'PSR construction failure' => [
@@ -43,6 +75,13 @@ public static function constructionFailures(): iterable
         yield 'application failure' => [new \LogicException('Reporter initialization failed')];
     }
 
+    /**
+     * Creates a container double that advertises a reporter but fails its lookup.
+     *
+     * @param \Throwable $failure The synthetic failure to propagate.
+     *
+     * @return ContainerInterface The fixture result.
+     */
     private function containerFailingWith(\Throwable $failure): ContainerInterface
     {
         $container = $this->createMock(ContainerInterface::class);
diff --git a/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php b/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php
index 39e6ff5..3b2dbad 100644
--- a/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php
+++ b/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php
@@ -2,6 +2,18 @@
 
 declare(strict_types=1);
 
+/**
+ * This file is part of coisa/error-handler.
+ *
+ * This source file is subject to the license that is bundled
+ * with this source code in the file LICENSE.
+ *
+ * @link      https://github.com/coisa/error-handler
+ *
+ * @copyright Copyright (c) 2022-2026 Felipe Sayão Lobato Abreu 
+ * @license   https://opensource.org/licenses/MIT MIT License
+ */
+
 namespace CoiSA\ErrorHandler\Test\Unit\Http\Message;
 
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
@@ -11,14 +23,32 @@
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\TestCase;
 
+/**
+ * Verifies safe HTTP status mapping, response bodies and output-buffer cleanup.
+ *
+ * @package CoiSA\ErrorHandler\Test\Unit\Http\Message
+ *
+ * @internal
+ */
 final class ThrowableResponseFactoryTest extends TestCase
 {
+    /**
+     * Provides HTTP boundary codes and their expected response statuses.
+     *
+     * @return list Input and expected HTTP status pairs.
+     */
     public static function statusCodes(): array
     {
         return [[0, 500], [200, 500], [399, 500], [400, 400], [404, 404], [500, 500],
             [503, 503], [599, 599], [600, 500]];
     }
 
+    /**
+     * Verifies HTTP status mapping, generic bodies and private throwable delivery.
+     *
+     * @param int $code The throwable code to map.
+     * @param int $expected The expected HTTP response status.
+     */
     #[DataProvider('statusCodes')]
     public function testMapsHttpErrorCodesAndDoesNotDiscloseExceptionDetails(int $code, int $expected): void
     {
@@ -37,9 +67,15 @@ public function testMapsHttpErrorCodesAndDoesNotDiscloseExceptionDetails(int $co
         self::assertSame([$error], $reported);
     }
 
+    /**
+     * Verifies a string PDO SQLSTATE produces HTTP 500 without a secondary type error.
+     */
     public function testStringSqlStateFallsBackTo500(): void
     {
         $error = new class () extends \PDOException {
+            /**
+             * Stores synthetic collaborators or failures for this test fixture.
+             */
             public function __construct()
             {
                 $this->code = 'HY000';
@@ -52,6 +88,9 @@ public function __construct()
         self::assertSame(500, $responses->createResponseFromThrowable($error)->getStatusCode());
     }
 
+    /**
+     * Verifies the rendered string zero is preserved as a response body.
+     */
     public function testZeroIsAValidRenderedBody(): void
     {
         $reporter = new CallableThrowableHandler(static function (): void {
@@ -61,6 +100,9 @@ public function testZeroIsAValidRenderedBody(): void
         self::assertSame('0', (string) $streams->createStreamFromThrowable(new \RuntimeException('private')));
     }
 
+    /**
+     * Verifies renderer failure cleans nested buffers while preserving the caller buffer.
+     */
     public function testRendererFailureCleansItsBuffersAndPreservesCallerBuffer(): void
     {
         $failure = new \RuntimeException('renderer failed');

From fb4652b3b4c3e6ead7b24e3453110eef9c3410b7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?=
 
Date: Wed, 14 Oct 2026 23:20:55 -0300
Subject: [PATCH 06/27] test: verify required service failures across
 FastForward versions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Assert failed construction after reaching required factories while allowing the original throwable or PSR-11 normalization. Document construction retries and validate both the locked 1.6.0 and highest 1.8.0 dependency sets.

Signed-off-by: Felipe Sayão Lobato Abreu 
---
 .agents/skills/coisa-error-handler/SKILL.md   |   5 +
 docs/fast-forward.md                          |   8 +-
 docs/testing.md                               |  19 ++-
 src/ErrorHandlerInterface.php                 |   4 +-
 .../ErrorHandlerServiceProviderTest.php       | 117 ++++++++----------
 5 files changed, 81 insertions(+), 72 deletions(-)

diff --git a/.agents/skills/coisa-error-handler/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md
index 6c3b47c..7f62bf0 100644
--- a/.agents/skills/coisa-error-handler/SKILL.md
+++ b/.agents/skills/coisa-error-handler/SKILL.md
@@ -64,6 +64,11 @@ fail. FastForward may translate the PSR error to not-found; do not promise that
 its original exception cause survives. Use the executable example in the
 matching checkout for the complete composition.
 
+Container versions may normalize ordinary application failures too, and may
+attempt construction more than once through delegation/autowiring. Require failed
+construction, not an exact exception type or a single factory invocation. This
+does not change the handler's no-retry contract after a reporter itself fails.
+
 The factory maps do not install PHP callbacks: explicitly register the resolved
 process handler at bootstrap. An empty default reporter aggregate is silent;
 configure a reporter or an event dispatcher before expecting diagnostics.
diff --git a/docs/fast-forward.md b/docs/fast-forward.md
index 45065eb..a21336d 100644
--- a/docs/fast-forward.md
+++ b/docs/fast-forward.md
@@ -102,7 +102,13 @@ the application's PSR container failure and use the library's silent aggregate.
 The merged form prevents that fallback and makes required-reporter failures
 fail construction of both process handler and middleware. FastForward still
 translates PSR failures to not-found; it does not preserve their original cause.
-An ordinary application exception continues to propagate.
+Container 1.6.0 propagates ordinary application exceptions directly; newer
+versions such as 1.8.0 normalize those too. The supported contract is failed
+construction after reaching the required factory, without silent fallback;
+it does not guarantee exception type, message or cause preservation.
+Container resolution may attempt a failing construction more than once while
+trying its delegates and autowiring. Application factories must tolerate those
+attempts; this differs from the handler's no-retry contract after reporting fails.
 
 Optional `PhpErrorHandlerInterface` and `ShutdownHandlerInterface` bindings are
 passed to fresh handlers too. The provider reuses the package's PSR-11 factories
diff --git a/docs/testing.md b/docs/testing.md
index d2a9a81..14c83fa 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -123,7 +123,7 @@ is in [FastForward integration](fast-forward.md).
 
 The FastForward-required/autowire revision passes the full check on macOS arm64
 with PHP 8.5.11, PHPUnit 13.3.4 and FastForward Container 1.6.0: **89 tests and
-173 assertions**, maximum PHPStan over `src/`, formatting, **28** isolated scenarios
+185 assertions**, maximum PHPStan over `src/`, formatting, **28** isolated scenarios
 and three HTTP responses. The count replaces tests exclusive to the removed
 standalone container with retained Laminas integration tests and new autowire,
 required-renderer and reporter-reset coverage; it is not the old 118-case suite.
@@ -143,8 +143,21 @@ ordinary application failures. A separate bounded reproduction on FastForward
 autowiring, while an interface override fails construction. Required custom
 renderers therefore use explicit interface bindings in the documented contract.
 
-These local results do not establish PHP 8.3 or new Linux matrix results for
-this revision. Use the CI run attached to the exact follow-up commit for those.
+Highest-dependency CI exposed a version difference: FastForward 1.8.0 normalizes
+ordinary application construction failures as well as PSR failures. The tests
+therefore require the explicitly failing factory to be reached and construction
+to fail with the original throwable or a PSR-11 normalization; they do not promise
+original exception identity or a single construction attempt. Handler delivery
+after a reporter fails remains a separate no-retry contract.
+
+An isolated highest-dependency installation also passes the full check on PHP
+8.5.11 with FastForward 1.8.0, PHPUnit 13.4.2 and PHPStan 2.3.1: 89 tests / 185
+assertions, formatting, all 28 child scenarios and the three HTTP responses.
+That installation changes only its disposable copy; the repository lock keeps
+FastForward 1.6.0 and all existing package versions.
+
+These local results do not establish PHP 8.3 or new Linux matrix results for this
+revision. Use the CI run attached to the exact follow-up commit for those.
 
 ## Boundaries and troubleshooting
 
diff --git a/src/ErrorHandlerInterface.php b/src/ErrorHandlerInterface.php
index 7428b71..c981ac9 100644
--- a/src/ErrorHandlerInterface.php
+++ b/src/ErrorHandlerInterface.php
@@ -42,8 +42,8 @@ public function register(): void;
      * Unregisters the error handler.
      *
      * Implementing classes MUST ensure that this method restores
-     * previous error and exception handlers and deactivate shutdown handling. If the handler
-     * was never registered, this method SHOULD do nothing.
+     * previous error and exception handlers and deactivates shutdown handling.
+     * If the handler was never registered, this method SHOULD do nothing.
      *
      * @return void
      */
diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
index 9d7810d..6613ecd 100644
--- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
+++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
@@ -116,41 +116,29 @@ public function testMissingPsr17FactoriesPreventMiddlewareConstruction(): void
      *
      * @param class-string $service The required renderer interface.
      * @param \Throwable $failure The synthetic construction failure.
-     * @param class-string<\Throwable> $expectedException The expected propagated failure category.
      */
     #[DataProvider('requiredRendererFailures')]
     public function testRequiredRendererFailurePreventsMiddlewareConstruction(
         string $service,
-        \Throwable $failure,
-        string $expectedException
+        \Throwable $failure
     ): void {
-        $application = new ArrayServiceProvider([
-            $service => static function () use ($failure): never {
-                throw $failure;
-            },
-            ResponseFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
-            StreamFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
-        ]);
-        $container = self::withApplication($application);
-        $this->expectException($expectedException);
-
-        $container->get(ErrorHandlerMiddleware::class);
+        self::assertRequiredServiceFails(ErrorHandlerMiddleware::class, $service, $failure);
     }
 
     /**
      * Provides PSR and application failures for both required renderer interfaces.
      *
-     * @return iterable}>
+     * @return iterable
      *     Required renderer construction failures.
      */
     public static function requiredRendererFailures(): iterable
     {
-        foreach (self::requiredReporterFailures() as $label => [$service, $failure, $expectedException]) {
+        foreach (self::requiredReporterFailures() as $label => [$service, $failure]) {
             if ($service !== ErrorHandlerInterface::class) {
                 continue;
             }
             foreach ([ThrowableStreamFactoryInterface::class, ThrowableResponseFactoryInterface::class] as $renderer) {
-                yield $renderer . ' / ' . $label => [$renderer, $failure, $expectedException];
+                yield $renderer . ' / ' . $label => [$renderer, $failure];
             }
         }
     }
@@ -195,60 +183,19 @@ public function testApplicationReporterOverridesTheDefaultAggregate(): void
      *
      * @param class-string $service The service identifier under test.
      * @param \Throwable $failure The synthetic failure to propagate.
-     * @param class-string<\Throwable> $expectedException The expected propagated failure type.
      */
     #[DataProvider('requiredReporterFailures')]
     public function testRequiredReporterFailureCannotFallBackToASilentDefault(
         string $service,
-        \Throwable $failure,
-        string $expectedException
+        \Throwable $failure
     ): void {
-        $application = new class ($failure) implements ServiceProviderInterface {
-            /**
-             * Stores synthetic collaborators or failures for this test fixture.
-             *
-             * @param \Throwable $failure The synthetic failure to propagate.
-             */
-            public function __construct(private \Throwable $failure)
-            {
-            }
-
-            /**
-             * Supplies application service definitions for the integration fixture.
-             *
-             * @return array Application service factories.
-             */
-            public function getFactories(): array
-            {
-                return [
-                    ThrowableHandlerInterface::class => function (): never {
-                        throw $this->failure;
-                    },
-                    ResponseFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
-                    StreamFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
-                ];
-            }
-
-            /**
-             * Declares no application service extensions.
-             *
-             * @return array An empty extension map.
-             */
-            public function getExtensions(): array
-            {
-                return [];
-            }
-        };
-        $container = self::withApplication($application);
-        $this->expectException($expectedException);
-
-        $container->get($service);
+        self::assertRequiredServiceFails($service, ThrowableHandlerInterface::class, $failure);
     }
 
     /**
      * Provides required-reporter failure categories for both lifecycle services.
      *
-     * @return iterable}>
+     * @return iterable
      *     Required-reporter failure cases.
      */
     public static function requiredReporterFailures(): iterable
@@ -257,18 +204,16 @@ public static function requiredReporterFailures(): iterable
             'PSR construction failure' => [
                 new class ('Reporter failed') extends \RuntimeException implements ContainerExceptionInterface {
                 },
-                ContainerExceptionInterface::class,
             ],
             'PSR missing dependency' => [
                 new class ('Missing dependency') extends \RuntimeException implements NotFoundExceptionInterface {
                 },
-                ContainerExceptionInterface::class,
             ],
-            'application failure' => [new \LogicException('Reporter failed'), \LogicException::class],
+            'application failure' => [new \LogicException('Reporter failed')],
         ];
         foreach ([ErrorHandlerInterface::class, ErrorHandlerMiddleware::class] as $service) {
-            foreach ($failures as $label => [$failure, $expectedException]) {
-                yield $service . ' / ' . $label => [$service, $failure, $expectedException];
+            foreach ($failures as $label => [$failure]) {
+                yield $service . ' / ' . $label => [$service, $failure];
             }
         }
     }
@@ -530,6 +475,46 @@ public function createResponseFromThrowable(\Throwable $throwable): ResponseInte
         }
     }
 
+    /**
+     * Verifies the required factory is reached and construction cannot silently fall back.
+     *
+     * FastForward may propagate the original failure or normalize it to a PSR-11 exception.
+     *
+     * @param class-string $requestedService The process or middleware service being constructed.
+     * @param class-string $failedService The explicitly required reporter or renderer interface.
+     * @param \Throwable $failure The exact failure thrown by the application factory.
+     */
+    private static function assertRequiredServiceFails(
+        string $requestedService,
+        string $failedService,
+        \Throwable $failure
+    ): void {
+        $attempts = 0;
+        $application = new ArrayServiceProvider([
+            $failedService => static function () use ($failure, &$attempts): never {
+                ++$attempts;
+                throw $failure;
+            },
+            ResponseFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
+            StreamFactoryInterface::class => static fn (): Psr17Factory => new Psr17Factory(),
+        ]);
+        $container = self::withApplication($application);
+
+        try {
+            $container->get($requestedService);
+        } catch (\Throwable $caught) {
+            self::assertTrue(
+                $caught === $failure || $caught instanceof ContainerExceptionInterface,
+                'Construction must propagate the original failure or a PSR-11 normalization.'
+            );
+            self::assertGreaterThan(0, $attempts, 'The explicitly configured failing factory must be reached.');
+
+            return;
+        }
+
+        self::fail('A required service failure must prevent construction instead of falling back.');
+    }
+
     /**
      * Composes library definitions with application service instances.
      *

From c4cd54d4b2c511bdb68f404f30fb8c7b17849f0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?=
 
Date: Thu, 15 Oct 2026 00:28:30 -0300
Subject: [PATCH 07/27] refactor: consolidate Interop wiring and isolate buffer
 cleanup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Remove the native Laminas ConfigProvider and move remaining factories under ServiceProvider. Migrate composition tests to Interop and separate buffer cleanup in ThrowableStreamFactory without adding another class. Preserve renderer failure precedence with balanced-rendering and cleanup-only regressions.

Signed-off-by: Felipe Sayão Lobato Abreu 
---
 .agents/skills/coisa-error-handler/SKILL.md   |   8 +-
 AGENTS.md                                     |   8 +-
 CHANGELOG.md                                  |   4 +-
 README.md                                     |   4 +-
 composer.json                                 |   6 -
 composer.lock                                 |  87 +-----------
 docs/api.md                                   |  13 +-
 docs/fast-forward.md                          |  13 +-
 docs/index.html                               |   2 +-
 docs/migration.md                             |  21 ++-
 docs/testing.md                               |  33 ++++-
 src/Container/ConfigProvider.php              | 132 -----------------
 src/Http/Message/ThrowableStreamFactory.php   |  56 +++++---
 .../ErrorHandlerServiceProvider.php           |   4 +-
 .../Factory/ErrorHandlerFactory.php           |  16 ++-
 .../Factory/ErrorHandlerMiddlewareFactory.php |   1 -
 .../ThrowableHandlerAggregateFactory.php      |   4 +-
 tests/Fire/run.php                            |   2 +
 tests/Fire/scenario.php                       |   7 +-
 .../Container/ConfigProviderTest.php          | 134 ------------------
 .../ErrorHandlerCompositionTest.php}          | 106 ++++++++------
 .../ErrorHandlerServiceProviderTest.php       |   2 +-
 .../Message/ThrowableResponseFactoryTest.php  |  26 ++++
 .../ThrowableHandlerAggregateFactoryTest.php  |   6 +-
 24 files changed, 234 insertions(+), 461 deletions(-)
 delete mode 100644 src/Container/ConfigProvider.php
 rename src/{Container => ServiceProvider}/Factory/ErrorHandlerFactory.php (83%)
 rename src/{Container => ServiceProvider}/Factory/ThrowableHandlerAggregateFactory.php (96%)
 delete mode 100644 tests/Functional/Container/ConfigProviderTest.php
 rename tests/Functional/{Container/ConfigProviderIntegrationTest.php => ServiceProvider/ErrorHandlerCompositionTest.php} (74%)
 rename tests/Unit/{Container => ServiceProvider/Factory}/ThrowableHandlerAggregateFactoryTest.php (93%)

diff --git a/.agents/skills/coisa-error-handler/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md
index 7f62bf0..5e65d80 100644
--- a/.agents/skills/coisa-error-handler/SKILL.md
+++ b/.agents/skills/coisa-error-handler/SKILL.md
@@ -54,8 +54,10 @@ FastForward Container is a runtime dependency in this development line.
 `CoiSA\ErrorHandler\Container\ErrorHandlerContainer` has been removed. Concrete
 dispatch and HTTP adapters use constructor autowiring; the provider retains
 explicit factories for optional callbacks, reporter composition and fresh scopes.
-The Laminas `ConfigProvider` adapter remains available with generic FastForward
-factory definitions. Consult the matching migration guide for removed factories.
+The Interop provider is the single container integration entrypoint;
+`ConfigProvider` and its Laminas discovery metadata have been removed. Provider
+factories live under `ServiceProvider\Factory`. Consult the migration guide for
+removed and moved class names.
 
 Separate variadic providers allow FastForward to fall back to the library's
 silent default if the application's reporter fails with a PSR container error.
@@ -74,7 +76,7 @@ process handler at bootstrap. An empty default reporter aggregate is silent;
 configure a reporter or an event dispatcher before expecting diagnostics.
 
 FastForward caches resolved instances, including middleware. To create a fresh
-handler, resolve `CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory` and
+handler, resolve `CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory` and
 invoke it with the container. For fresh middleware, resolve and invoke
 `CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`.
 Do not mistake repeated `get()` calls for a transient lifetime.
diff --git a/AGENTS.md b/AGENTS.md
index df89609..d617752 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,9 +30,11 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed
   process handler and use the provider's factory services for fresh scopes.
   Merge application overrides after library definitions in one
   `AggregateServiceProvider`, avoiding silent reporter fallback on PSR errors.
-- Keep the Laminas ConfigProvider adapter working. Preserve explicit factories
-  for reporter composition, optional handlers and fresh middleware scopes;
-  use autowiring for plain constructor injection. A possible
+- Support container integration through the Interop service provider only;
+  do not restore the removed ConfigProvider or framework-specific adapters.
+  Keep all provider factories under `ServiceProvider\Factory`. Preserve explicit
+  factories for reporter composition, optional handlers and fresh middleware
+  scopes; use autowiring for plain constructor injection. A possible
   move to php-fast-forward does not itself change package identity or namespace.
 - Reporters own diagnostic output; HTTP fallback bodies must not leak exception
   details. A shutdown callback reports a fatal error and does not recover it.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70f55fc..8552a64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,9 @@
 - Reject out-of-order `unregister()` calls without removing another handler. Only the active registration handles shutdown events.
 - Return a generic HTTP body when a reporter emits nothing; use standard reason phrases instead of exception messages. Preserve valid error status codes through 599.
 - Update PHPUnit, PHPStan, PHP CS Fixer, PHP_CodeSniffer, Docker and CI. Declare PSR-11 as a runtime dependency.
-- Require FastForward Container at runtime and reuse its alias/invokable factories. Remove the standalone `ErrorHandlerContainer`, its alias factory and container exceptions, and constructor-only dispatch/HTTP factories; use autowiring for plain injection. Preserve the Laminas adapter with generic factory definitions.
+- Require FastForward Container at runtime and reuse its alias/invokable factories. Remove the standalone `ErrorHandlerContainer`, its alias factory and container exceptions, and constructor-only dispatch/HTTP factories; use autowiring for plain injection.
+- Keep the Interop service provider as the single container integration entrypoint. Remove the Laminas `ConfigProvider`, discovery metadata and ServiceManager development dependency; move both remaining `Container\Factory` classes to `ServiceProvider\Factory` and migrate their behavior tests.
+- Separate throwable output capture from buffer cleanup through private methods in `ThrowableStreamFactory`, preserving renderer-failure precedence and caller-owned buffers.
 
 ### Fixed
 
diff --git a/README.md b/README.md
index 369e23f..936fc0e 100644
--- a/README.md
+++ b/README.md
@@ -47,8 +47,8 @@ Load Composer's autoloader before the snippet. A normal warning is reported and
 
 FastForward Container is a runtime dependency in this development line. Use the
 [service provider](docs/fast-forward.md) for container wiring; `ErrorHandlerContainer`
-has been removed. Direct construction remains available, and `ConfigProvider`
-continues to adapt the library to Laminas ServiceManager.
+and the Laminas `ConfigProvider` have been removed. The Interop service provider
+is the single container integration entrypoint. Direct construction remains available.
 
 ## HTTP integration
 
diff --git a/composer.json b/composer.json
index cc5f8bc..f81831e 100644
--- a/composer.json
+++ b/composer.json
@@ -14,11 +14,6 @@
         "prefer-stable": true,
         "optimize-autoloader": true
     },
-    "extra": {
-        "laminas": {
-            "config-provider": "CoiSA\\ErrorHandler\\Container\\ConfigProvider"
-        }
-    },
     "require": {
         "php": "^8.3",
         "container-interop/service-provider": "^0.4.1",
@@ -42,7 +37,6 @@
     },
     "require-dev": {
         "friendsofphp/php-cs-fixer": "^3.95",
-        "laminas/laminas-servicemanager": "^4.3",
         "nyholm/psr7": "^1.8",
         "phly/phly-event-dispatcher": "^1.0",
         "phpstan/phpstan": "^2.1",
diff --git a/composer.lock b/composer.lock
index 029c7da..9d88a2c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "66fe96e6efc2b11347f556d30a1da324",
+    "content-hash": "2aafd0e1101697f930aa5a8c7540dca2",
     "packages": [
         {
             "name": "brick/varexporter",
@@ -1705,91 +1705,6 @@
             ],
             "time": "2026-09-08T11:11:37+00:00"
         },
-        {
-            "name": "laminas/laminas-servicemanager",
-            "version": "4.5.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laminas/laminas-servicemanager.git",
-                "reference": "11192d588876ad04ba2988984c77b4ecb5c771c2"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-servicemanager/zipball/11192d588876ad04ba2988984c77b4ecb5c771c2",
-                "reference": "11192d588876ad04ba2988984c77b4ecb5c771c2",
-                "shasum": ""
-            },
-            "require": {
-                "brick/varexporter": "^0.3.8 || ^0.4.0 || ^0.5.0 || ^0.6.0",
-                "laminas/laminas-stdlib": "^3.19",
-                "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
-                "psr/container": "^1.1 || ^2.0"
-            },
-            "conflict": {
-                "laminas/laminas-code": "<4.10.0",
-                "zendframework/zend-code": "<3.3.1"
-            },
-            "provide": {
-                "psr/container-implementation": "^1.0 || ^2.0"
-            },
-            "require-dev": {
-                "composer/package-versions-deprecated": "^1.11.99.5",
-                "friendsofphp/proxy-manager-lts": "^1.0.18",
-                "laminas/laminas-cli": "^1.11",
-                "laminas/laminas-coding-standard": "~3.1.0",
-                "laminas/laminas-container-config-test": "^1.1",
-                "mikey179/vfsstream": "^1.6.12",
-                "phpbench/phpbench": "^1.4.1",
-                "phpunit/phpunit": "^10.5.58",
-                "psalm/plugin-phpunit": "^0.19.5",
-                "symfony/console": "^6.4.17 || ^7.3.4",
-                "vimeo/psalm": "^6.13.1"
-            },
-            "suggest": {
-                "friendsofphp/proxy-manager-lts": "To handle lazy initialization of services",
-                "laminas/laminas-cli": "To consume CLI commands provided by this component"
-            },
-            "type": "library",
-            "extra": {
-                "laminas": {
-                    "module": "Laminas\\ServiceManager",
-                    "config-provider": "Laminas\\ServiceManager\\ConfigProvider"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Laminas\\ServiceManager\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "description": "Factory-Driven Dependency Injection Container",
-            "homepage": "https://laminas.dev",
-            "keywords": [
-                "PSR-11",
-                "dependency-injection",
-                "di",
-                "dic",
-                "laminas",
-                "service-manager",
-                "servicemanager"
-            ],
-            "support": {
-                "chat": "https://laminas.dev/chat",
-                "forum": "https://discourse.laminas.dev",
-                "issues": "https://github.com/laminas/laminas-servicemanager/issues",
-                "source": "https://github.com/laminas/laminas-servicemanager/tree/4.5.1"
-            },
-            "funding": [
-                {
-                    "url": "https://funding.communitybridge.org/projects/laminas-project",
-                    "type": "community_bridge"
-                }
-            ],
-            "time": "2026-05-12T09:53:32+00:00"
-        },
         {
             "name": "myclabs/deep-copy",
             "version": "1.14.0",
diff --git a/docs/api.md b/docs/api.md
index 15fc244..c7201b2 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -55,7 +55,7 @@ The middleware returns normal downstream responses unchanged and catches downstr
 
 Neither the middleware nor the global handler emits PSR-7 responses to a server. The application/framework owns emission, headers, content type and transport. A PHP engine fatal error bypasses normal middleware recovery; shutdown reporting cannot reliably replace an already-started HTTP response.
 
-## PSR-11 container integration
+## Interop service-provider integration
 
 `ServiceProvider\ErrorHandlerServiceProvider` implements the Interop contract
 used by FastForward, with callable factories and no automatic callback
@@ -68,10 +68,11 @@ autowiring. Explicit factories remain for optional PHP/shutdown handlers, report
 composition and independent middleware scopes. `ErrorHandlerContainer` and its
 private alias/exception machinery have been removed.
 
-`Container\ConfigProvider` exposes `__invoke()`, `getDependencies()`, `getAliases()`
-and `getFactories()` for Laminas ServiceManager. It uses FastForward's generic
-`InvokableFactory` for plain constructor injection, and the same lifecycle
-factories as the provider. Its middleware owns a handler separate from the cached
-process handler. See [migration](migration.md) for removed factory class names.
+The Interop provider is the single supported container integration entrypoint.
+`Container\ConfigProvider` and its Laminas discovery metadata have been removed.
+The three lifecycle factories live under `ServiceProvider\Factory`. Consumers
+combine the provider with constructor autowiring, as FastForward's `container()`
+does; PSR-11 alone does not define provider registration or autowiring.
+See [migration](migration.md) for removed and moved class names.
 
 Provide `ThrowableHandlerInterface` for a custom reporter. Otherwise `ThrowableHandlerAggregateFactory` composes any available callable/event-dispatch reporters; an empty aggregate intentionally produces no diagnostics. HTTP factories also require PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface` services. PSR-14 integration needs `EventDispatcherInterface`. These implementations belong to the application; the package ships interfaces and adapters, not a logger, event bus or HTTP framework.
diff --git a/docs/fast-forward.md b/docs/fast-forward.md
index a21336d..d0cec3c 100644
--- a/docs/fast-forward.md
+++ b/docs/fast-forward.md
@@ -56,7 +56,7 @@ There is no provider boot hook. The application decides when to register.
 | `DispatchThrowableHandler` / `DispatchErrorEventThrowableHandler` | Existing PSR-14 adapters; require an application dispatcher |
 | `ThrowableStreamFactoryInterface` / `ThrowableResponseFactoryInterface` | Existing rendering adapters; require PSR-17 stream/response factories |
 | `ErrorHandlerMiddleware` | Cached middleware with a handler separate from the process handler |
-| `Container\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation |
+| `ServiceProvider\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation |
 | `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` | Reusable callable that creates fresh middleware and a fresh handler |
 
 The provider has nine explicit definitions. Dispatch handlers and concrete
@@ -113,10 +113,11 @@ attempts; this differs from the handler's no-retry contract after reporting fail
 Optional `PhpErrorHandlerInterface` and `ShutdownHandlerInterface` bindings are
 passed to fresh handlers too. The provider reuses the package's PSR-11 factories
 and introduces no separate configuration format or automatic event subscriptions.
-The Laminas `ConfigProvider` remains available and uses `InvokableFactory` with
-explicit dependency identifiers for containers without autowiring.
-`ErrorHandlerContainer` and the constructor-only factories have been removed;
-see [migration](migration.md).
+The Interop service provider is the only supported container integration.
+`ConfigProvider`, its Laminas discovery metadata, `ErrorHandlerContainer` and the
+constructor-only factories have been removed. Provider factories all live under
+`ServiceProvider\Factory`; see [migration](migration.md). Provider consumers must
+support constructor autowiring, as FastForward's `container()` does.
 
 ## HTTP scopes and fresh instances
 
@@ -136,7 +137,7 @@ $outer = $makeMiddleware($services);
 $inner = $makeMiddleware($services);
 ```
 
-Likewise invoke the `Container\Factory\ErrorHandlerFactory` service to create
+Likewise invoke the `ServiceProvider\Factory\ErrorHandlerFactory` service to create
 fresh direct handlers. Avoid sharing the process handler with middleware and
 do not pre-register the middleware's own handler. The reusable factory pattern
 does not provide concurrent-request isolation: PHP callback stacks remain
diff --git a/docs/index.html b/docs/index.html
index 1978219..fbc673c 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -64,7 +64,7 @@ 

Make failure
observable.

04 / Exercise the failure path

A test should survive the crash.

-
28ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion, reporter reset and renderer failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

+
29ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion, reporter reset and renderer/cleanup failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

The runner checks delivery counts, ordering, throwable classes, stderr and exact exit status. Separate integration tests exercise PSR composition and real HTTP 500/503 responses through a loopback-only server.

RUN THE EVIDENCE YOURSELF
php tests/Fire/run.php
 composer --no-plugins test
diff --git a/docs/migration.md b/docs/migration.md
index 353c3c5..22efa94 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -7,8 +7,9 @@ without changing the existing package or namespace. Merge definitions with
 `AggregateServiceProvider`, library first and application last, so a required
 reporter's failure cannot fall back to the silent default. Registration remains explicit;
 use factory services for fresh/nested scopes because container lookups are cached.
-The Laminas `ConfigProvider` adapter remains supported; the standalone
-`ErrorHandlerContainer` has been removed in favor of FastForward Container.
+The Interop provider is the single container integration entrypoint.
+The Laminas `ConfigProvider` adapter and standalone `ErrorHandlerContainer`
+have been removed in favor of FastForward Container with this provider.
 
 1. Upgrade the application to PHP 8.3, 8.4 or 8.5. Resolve Composer dependencies again. The checked-in development lock targets PHP 8.5; developers on 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible tools. Consumers resolve their own lock as usual.
 2. Expect exit status **1** after a globally uncaught `Throwable` is reported successfully. Version 2 could exit with status 0. A reporter that explicitly terminates the process still controls its own exit code. Direct `handleThrowable()` calls do not exit.
@@ -36,11 +37,17 @@ The Laminas `ConfigProvider` adapter remains supported; the standalone
 | `Container\Factory\ThrowableResponseFactoryFactory` | Autowire `Http\Message\ThrowableResponseFactory` |
 | `Container\Factory\ThrowableStreamFactoryFactory` | Autowire `Http\Message\ThrowableStreamFactory` |
 | `Container\Factory\ErrorHandlerMiddlewareFactory` | `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`, which creates a fresh handler |
-
-For a PSR-11 container without autowiring, use FastForward's `InvokableFactory`
-with explicit constructor service identifiers, as the Laminas adapter does.
-`Container\Factory\ErrorHandlerFactory` and `ThrowableHandlerAggregateFactory`
-remain because they encode lifecycle and reporter-composition behavior.
+| `Container\ConfigProvider` | `ServiceProvider\ErrorHandlerServiceProvider`; remove Laminas `config-provider` discovery and register the Interop provider with FastForward |
+| `Container\Factory\ErrorHandlerFactory` | `ServiceProvider\Factory\ErrorHandlerFactory` |
+| `Container\Factory\ThrowableHandlerAggregateFactory` | `ServiceProvider\Factory\ThrowableHandlerAggregateFactory` |
+
+Update factory imports and service identifiers to the new namespaces; no aliases
+for the old names are provided. All three remaining factories live under
+`ServiceProvider\Factory` because they encode lifecycle and reporter-composition
+behavior. Use the Interop provider with constructor autowiring; a framework-native
+configuration adapter is no longer shipped. `laminas/laminas-servicemanager` is
+no longer a development dependency; unrelated transitive Laminas packages may
+still be required by FastForward.
 
 Public handler interfaces retain their names and method signatures. The PHP
 floor, required FastForward dependency, removed container/factories, CLI exit
diff --git a/docs/testing.md b/docs/testing.md
index 14c83fa..ac0e6e7 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -16,7 +16,7 @@ composer --no-plugins check
 # Core crash tests need PHP only; no Composer dependencies or extensions.
 php tests/Fire/run.php
 
-# Include the three isolated PSR renderer regressions after Composer installation.
+# Include the four isolated PSR renderer regressions after Composer installation.
 php tests/Fire/run.php --with-psr
 
 # PSR integration and real HTTP responses.
@@ -50,6 +50,7 @@ Every child uses `php -n`, a 16 MiB PHP memory limit, a five-second wall-clock d
 | Aggregate | Reporters run in attachment order |
 | Renderer failure with removable/non-removable nested buffer (`--with-psr`) | Original renderer failure propagates without a loop; non-removable buffers remain active |
 | Renderer and output-cleanup callback both throw (`--with-psr`) | Original renderer failure wins; reachable buffers are cleaned |
+| Output-cleanup callback throws after successful rendering (`--with-psr`) | Cleanup failure propagates and reachable buffers are cleaned |
 
 The allocation case deliberately requests 32 MiB under a 16 MiB limit. It proves this failure path only. There is no memory reserve and **no guarantee** of reporting arbitrary real-world out-of-memory conditions.
 
@@ -121,7 +122,7 @@ is in [FastForward integration](fast-forward.md).
 
 ## Review follow-up verification
 
-The FastForward-required/autowire revision passes the full check on macOS arm64
+The first FastForward-required/autowire revision passed the full check on macOS arm64
 with PHP 8.5.11, PHPUnit 13.3.4 and FastForward Container 1.6.0: **89 tests and
 185 assertions**, maximum PHPStan over `src/`, formatting, **28** isolated scenarios
 and three HTTP responses. The count replaces tests exclusive to the removed
@@ -159,6 +160,34 @@ FastForward 1.6.0 and all existing package versions.
 These local results do not establish PHP 8.3 or new Linux matrix results for this
 revision. Use the CI run attached to the exact follow-up commit for those.
 
+## Interop-only and stream-cleanup follow-up
+
+Container integration now uses only `ServiceProvider\ErrorHandlerServiceProvider`.
+The native Laminas ConfigProvider, its Composer discovery metadata and the
+ServiceManager development dependency are removed. The former Laminas behavior
+tests now exercise the same reporting, event/listener and HTTP behavior through
+FastForward and the Interop provider; adapter-specific configuration tests are
+retired. The two remaining container factories and their unit tests move under
+`ServiceProvider\Factory`.
+
+`ThrowableStreamFactory` keeps output capture and cleanup in the same class,
+using a private cleanup method. Characterization tests passed before extraction:
+balanced nested output preserves the caller's buffer, a renderer failure wins
+over a simultaneous cleanup failure, and a cleanup-only failure propagates. The
+non-removable-buffer and no-progress guards remain in place.
+
+The full check passes on macOS arm64/PHP 8.5.11 with the locked FastForward 1.6.0:
+**70 tests / 168 assertions**, maximum source PHPStan, formatting, **29 isolated
+scenarios** and three loopback HTTP responses. Only ServiceManager was removed
+from the lock; retained dependency versions and the 20-package runtime set are
+unchanged. CI on the exact follow-up head supplies the PHP 8.3–8.5 matrix evidence.
+
+The same full check also passes in an isolated highest-dependency installation
+with FastForward 1.8.0, PHPUnit 13.4.2 and PHPStan 2.3.1. The production-only
+installation resolves the relocated handler factory and creates independent
+handlers, with the old ConfigProvider, old factory namespace and ServiceManager
+absent. Both executable examples retain their exact output and exit contracts.
+
 ## Boundaries and troubleshooting
 
 - PHP must have `proc_open()` enabled for crash/HTTP tests. Unit tests require the normal PHPUnit extensions. The HTTP runner binds only to a dynamically selected `127.0.0.1` port, waits at most five seconds to start, gives each request two seconds, and terminates its fixture server in `finally`.
diff --git a/src/Container/ConfigProvider.php b/src/Container/ConfigProvider.php
deleted file mode 100644
index 6dac675..0000000
--- a/src/Container/ConfigProvider.php
+++ /dev/null
@@ -1,132 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Container;
-
-use CoiSA\ErrorHandler\ErrorHandler;
-use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler;
-use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler;
-use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;
-use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
-use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
-use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface;
-use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
-use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
-use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
-use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
-use FastForward\Container\Factory\InvokableFactory;
-use Psr\EventDispatcher\EventDispatcherInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-
-/**
- * Class ConfigProvider
- *
- * Provides configuration for dependency injection containers.
- * This class SHALL define aliases, factories, and other dependencies
- * required for the proper functioning of the error-handler library.
- *
- * @package CoiSA\ErrorHandler\Container
- * @phpstan-type Factories array
- * @phpstan-type Dependencies array{aliases: array, factories: Factories}
- */
-final class ConfigProvider
-{
-    /**
-     * Invokes the configuration provider.
-     *
-     * This method SHALL return an array containing the configuration
-     * for dependency injection containers, including dependencies, aliases,
-     * and factories.
-     *
-     * @return array{dependencies: Dependencies} The configuration array.
-     */
-    public function __invoke(): array
-    {
-        return [
-            'dependencies' => $this->getDependencies(),
-        ];
-    }
-
-    /**
-     * Retrieves the dependencies configuration.
-     *
-     * This method SHALL define aliases and factories used for resolving
-     * dependencies in the container.
-     *
-     * @return Dependencies An array containing aliases and factories.
-     */
-    public function getDependencies(): array
-    {
-        return [
-            'aliases'   => $this->getAliases(),
-            'factories' => $this->getFactories(),
-        ];
-    }
-
-    /**
-     * Retrieves service aliases.
-     *
-     * This method SHALL define class aliases to allow easier resolution
-     * of services in the dependency injection container.
-     *
-     * @return array An associative array of aliases.
-     */
-    public function getAliases(): array
-    {
-        return [
-            ThrowableHandlerInterface::class         => ThrowableHandlerAggregate::class,
-            ThrowableResponseFactoryInterface::class => ThrowableResponseFactory::class,
-            ThrowableStreamFactoryInterface::class   => ThrowableStreamFactory::class,
-        ];
-    }
-
-    /**
-     * Retrieves service factories.
-     *
-     * This method SHALL define factories responsible for instantiating
-     * and configuring services in the dependency injection container.
-     *
-     * @return Factories An associative array of service factories.
-     */
-    public function getFactories(): array
-    {
-        return [
-            ErrorHandler::class                       => Factory\ErrorHandlerFactory::class,
-            ErrorHandlerMiddleware::class             => ErrorHandlerMiddlewareFactory::class,
-            Factory\ErrorHandlerFactory::class        => new InvokableFactory(Factory\ErrorHandlerFactory::class),
-            DispatchErrorEventThrowableHandler::class => new InvokableFactory(
-                DispatchErrorEventThrowableHandler::class,
-                EventDispatcherInterface::class
-            ),
-            DispatchThrowableHandler::class           => new InvokableFactory(
-                DispatchThrowableHandler::class,
-                EventDispatcherInterface::class
-            ),
-            ThrowableHandlerAggregate::class          => Factory\ThrowableHandlerAggregateFactory::class,
-            ThrowableResponseFactory::class           => new InvokableFactory(
-                ThrowableResponseFactory::class,
-                ThrowableStreamFactoryInterface::class,
-                ResponseFactoryInterface::class
-            ),
-            ThrowableStreamFactory::class             => new InvokableFactory(
-                ThrowableStreamFactory::class,
-                StreamFactoryInterface::class,
-                ThrowableHandlerInterface::class
-            ),
-        ];
-    }
-}
diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php
index 54a7e80..8d7780d 100644
--- a/src/Http/Message/ThrowableStreamFactory.php
+++ b/src/Http/Message/ThrowableStreamFactory.php
@@ -82,31 +82,55 @@ public function createStreamFromThrowable(\Throwable $throwable): StreamInterfac
         } catch (\Throwable $rendererFailure) {
             $failure = $rendererFailure;
         } finally {
-            while (ob_get_level() > $initialLevel) {
-                $flags = ob_get_status()['flags'] ?? null;
-                if (!is_int($flags) || ($flags & PHP_OUTPUT_HANDLER_REMOVABLE) === 0) {
-                    // PHP cannot remove this buffer or reach the buffers below it.
-                    break;
-                }
-                $level = ob_get_level();
-                try {
-                    ob_end_clean();
-                } catch (\Throwable $cleanupFailure) {
-                    $failure ??= $cleanupFailure;
-                }
-                if (ob_get_level() >= $level) {
-                    break;
-                }
-            }
+            $cleanupFailure = $this->cleanOutputBuffers($initialLevel);
         }
 
         if ($failure !== null) {
             throw $failure;
         }
+        if ($cleanupFailure !== null) {
+            throw $cleanupFailure;
+        }
         if ($buffer === false || $buffer === '') {
             $buffer = 'Internal Server Error';
         }
 
         return $this->streamFactory->createStream($buffer);
     }
+
+    /**
+     * Discards reachable removable buffers created during rendering.
+     *
+     * Cleanup MUST stop at caller-owned or non-removable buffers and when removal
+     * makes no progress. Its first failure is returned so the caller can preserve
+     * a renderer failure in preference to a later cleanup failure.
+     *
+     * @param int $initialLevel The caller's output-buffer depth before rendering.
+     *
+     * @return \Throwable|null The first cleanup failure, or null when no removal failed.
+     */
+    private function cleanOutputBuffers(int $initialLevel): ?\Throwable
+    {
+        $failure = null;
+
+        while (ob_get_level() > $initialLevel) {
+            $flags = ob_get_status()['flags'] ?? null;
+            if (!is_int($flags) || ($flags & PHP_OUTPUT_HANDLER_REMOVABLE) === 0) {
+                break;
+            }
+
+            $level = ob_get_level();
+            try {
+                ob_end_clean();
+            } catch (\Throwable $cleanupFailure) {
+                $failure ??= $cleanupFailure;
+            }
+
+            if (ob_get_level() >= $level) {
+                break;
+            }
+        }
+
+        return $failure;
+    }
 }
diff --git a/src/ServiceProvider/ErrorHandlerServiceProvider.php b/src/ServiceProvider/ErrorHandlerServiceProvider.php
index ee3f649..ea2800f 100644
--- a/src/ServiceProvider/ErrorHandlerServiceProvider.php
+++ b/src/ServiceProvider/ErrorHandlerServiceProvider.php
@@ -16,8 +16,6 @@
 
 namespace CoiSA\ErrorHandler\ServiceProvider;
 
-use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory;
-use CoiSA\ErrorHandler\Container\Factory\ThrowableHandlerAggregateFactory;
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\ErrorHandlerInterface;
 use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;
@@ -28,6 +26,8 @@
 use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ThrowableHandlerAggregateFactory;
 use FastForward\Container\Factory\AliasFactory;
 use FastForward\Container\Factory\InvokableFactory;
 use Interop\Container\ServiceProviderInterface;
diff --git a/src/Container/Factory/ErrorHandlerFactory.php b/src/ServiceProvider/Factory/ErrorHandlerFactory.php
similarity index 83%
rename from src/Container/Factory/ErrorHandlerFactory.php
rename to src/ServiceProvider/Factory/ErrorHandlerFactory.php
index b8cda52..feea2fc 100644
--- a/src/Container/Factory/ErrorHandlerFactory.php
+++ b/src/ServiceProvider/Factory/ErrorHandlerFactory.php
@@ -14,7 +14,7 @@
  * @license   https://opensource.org/licenses/MIT MIT License
  */
 
-namespace CoiSA\ErrorHandler\Container\Factory;
+namespace CoiSA\ErrorHandler\ServiceProvider\Factory;
 
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface;
@@ -29,7 +29,7 @@
  * This factory SHALL ensure proper dependency injection of ThrowableHandlerInterface,
  * PhpErrorHandlerInterface, and ShutdownHandlerInterface.
  *
- * @package CoiSA\ErrorHandler\Container\Factory
+ * @package CoiSA\ErrorHandler\ServiceProvider\Factory
  */
 final class ErrorHandlerFactory
 {
@@ -45,15 +45,21 @@ final class ErrorHandlerFactory
      */
     public function __invoke(ContainerInterface $container): ErrorHandler
     {
-        /** @var ThrowableHandlerInterface $throwableHandler */
+        /**
+         * @var ThrowableHandlerInterface $throwableHandler
+         */
         $throwableHandler = $container->get(ThrowableHandlerInterface::class);
 
-        /** @var PhpErrorHandlerInterface|null $phpErrorHandler */
+        /**
+         * @var PhpErrorHandlerInterface|null $phpErrorHandler
+         */
         $phpErrorHandler = $container->has(PhpErrorHandlerInterface::class)
             ? $container->get(PhpErrorHandlerInterface::class)
             : null;
 
-        /** @var ShutdownHandlerInterface|null $shutdownHandler */
+        /**
+         * @var ShutdownHandlerInterface|null $shutdownHandler
+         */
         $shutdownHandler = $container->has(ShutdownHandlerInterface::class)
             ? $container->get(ShutdownHandlerInterface::class)
             : null;
diff --git a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
index 3de3e46..1192ccc 100644
--- a/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
+++ b/src/ServiceProvider/Factory/ErrorHandlerMiddlewareFactory.php
@@ -16,7 +16,6 @@
 
 namespace CoiSA\ErrorHandler\ServiceProvider\Factory;
 
-use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory;
 use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface;
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use Psr\Container\ContainerInterface;
diff --git a/src/Container/Factory/ThrowableHandlerAggregateFactory.php b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php
similarity index 96%
rename from src/Container/Factory/ThrowableHandlerAggregateFactory.php
rename to src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php
index 8f5e063..2e465d2 100644
--- a/src/Container/Factory/ThrowableHandlerAggregateFactory.php
+++ b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php
@@ -14,7 +14,7 @@
  * @license   https://opensource.org/licenses/MIT MIT License
  */
 
-namespace CoiSA\ErrorHandler\Container\Factory;
+namespace CoiSA\ErrorHandler\ServiceProvider\Factory;
 
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
 use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler;
@@ -30,7 +30,7 @@
  * Factory responsible for creating and configuring an instance of ThrowableHandlerAggregate.
  * This factory SHALL attach available handlers from the container to the aggregate handler.
  *
- * @package CoiSA\ErrorHandler\Container\Factory
+ * @package CoiSA\ErrorHandler\ServiceProvider\Factory
  */
 final class ThrowableHandlerAggregateFactory
 {
diff --git a/tests/Fire/run.php b/tests/Fire/run.php
index 7ba8800..3623ce5 100644
--- a/tests/Fire/run.php
+++ b/tests/Fire/run.php
@@ -52,6 +52,8 @@
         [0 => ['message' => 'renderer failed', 'remainingBuffers' => 2]]];
     $cases['render-cleanup-throws'] = [0, ['renderer-failure'], RuntimeException::class, null,
         [0 => ['message' => 'renderer failed', 'remainingBuffers' => 0]]];
+    $cases['render-cleanup-only-throws'] = [0, ['renderer-failure'], LogicException::class, null,
+        [0 => ['message' => 'cleanup failed', 'remainingBuffers' => 0]]];
 }
 $failed = 0;
 printf("PHP %s | %s %s | %d isolated scenarios\n", PHP_VERSION, PHP_OS_FAMILY, php_uname('m'), count($cases));
diff --git a/tests/Fire/scenario.php b/tests/Fire/scenario.php
index 27e6eb6..452e459 100644
--- a/tests/Fire/scenario.php
+++ b/tests/Fire/scenario.php
@@ -238,17 +238,20 @@ static function (Throwable $error) use (&$handler): void {
     case 'render-removable':
     case 'render-non-removable':
     case 'render-cleanup-throws':
+    case 'render-cleanup-only-throws':
         require dirname(__DIR__, 2) . '/vendor/autoload.php';
         $flags = PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE;
         if ($scenario !== 'render-non-removable') {
             $flags |= PHP_OUTPUT_HANDLER_REMOVABLE;
         }
         $renderer = new CallableThrowableHandler(static function () use ($flags, $scenario): void {
-            $callback = $scenario === 'render-cleanup-throws' ? static function (): never {
+            $callback = str_starts_with($scenario, 'render-cleanup-') ? static function (): never {
                 throw new LogicException('cleanup failed');
             } : null;
             ob_start($callback, 0, $flags);
-            throw new RuntimeException('renderer failed');
+            if ($scenario !== 'render-cleanup-only-throws') {
+                throw new RuntimeException('renderer failed');
+            }
         });
         $factory = new CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory(
             new Nyholm\Psr7\Factory\Psr17Factory(),
diff --git a/tests/Functional/Container/ConfigProviderTest.php b/tests/Functional/Container/ConfigProviderTest.php
deleted file mode 100644
index 12c18a3..0000000
--- a/tests/Functional/Container/ConfigProviderTest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-
- * @license   https://opensource.org/licenses/MIT MIT License
- */
-
-namespace CoiSA\ErrorHandler\Test\Functional\Container;
-
-use CoiSA\ErrorHandler\Container\ConfigProvider;
-use Laminas\ServiceManager\ServiceManager;
-use Nyholm\Psr7\Factory\Psr17Factory;
-use Phly\EventDispatcher\EventDispatcher;
-use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate;
-use PHPUnit\Framework\TestCase;
-use PHPUnit\Framework\Attributes\DataProvider;
-use Psr\EventDispatcher\EventDispatcherInterface;
-use Psr\Http\Message\ResponseFactoryInterface;
-use Psr\Http\Message\StreamFactoryInterface;
-
-/**
- * Class ConfigProviderTest.
- *
- * @package CoiSA\ErrorHandler\Test\Functional\Container
- *
- * @internal
- * @coversNothing
- */
-final class ConfigProviderTest extends TestCase
-{
-    /**
-     * @var ServiceManager
-     */
-    private $serviceManager;
-
-    /**
-     * @var ConfigProvider
-     */
-    private $configProvider;
-
-    /**
-     * Creates isolated container and reporting collaborators for each test.
-     */
-    protected function setUp(): void
-    {
-        $this->configProvider = new ConfigProvider();
-        $configs              = ($this->configProvider)();
-
-        $this->serviceManager = new ServiceManager($configs['dependencies']);
-
-        $this->setUpServiceManager($this->serviceManager);
-    }
-
-    /**
-     * Supplies application-owned PSR-14 and PSR-17 implementations.
-     *
-     * @param ServiceManager $serviceManager Service Manager for the fixture.
-     */
-    public function setUpServiceManager(ServiceManager $serviceManager): void
-    {
-        $listenerProvider = new ListenerProviderAggregate();
-        $eventDispatcher  = new EventDispatcher($listenerProvider);
-        $factory          = new Psr17Factory();
-
-        $serviceManager->setService(EventDispatcherInterface::class, $eventDispatcher);
-        $serviceManager->setService(ResponseFactoryInterface::class, $factory);
-        $serviceManager->setService(StreamFactoryInterface::class, $factory);
-    }
-
-    /**
-     * Provides every service identifier declared by the Laminas adapter.
-     *
-     * @return list Independent service-identifier cases.
-     */
-    public static function provideContainerDependenciesClassNames(): array
-    {
-        $dependencies = (new ConfigProvider())->getDependencies();
-
-        return array_chunk(array_keys(array_merge(...array_values($dependencies))), 1);
-    }
-
-    /**
-     * Verifies Laminas advertises each configured service identifier.
-     *
-     * @param class-string $className The configured service identifier.
-     */
-    #[DataProvider('provideContainerDependenciesClassNames')]
-    public function testServiceManagerHasConfigProviderDependencies(string $className): void
-    {
-        static::assertTrue($this->serviceManager->has($className));
-    }
-
-    /**
-     * Provides service identifiers and their expected concrete types.
-     *
-     * @return list Service identifiers and expected types.
-     */
-    public static function provideContainerDependenciesWithInstanceType(): array
-    {
-        $dependencies = (new ConfigProvider())->getDependencies();
-        $classNames   = array_keys(array_merge(...array_values($dependencies)));
-
-        return array_map(function ($classNameName) use ($dependencies) {
-            $instanceOf = $dependencies['aliases'][$classNameName] ?? $classNameName;
-
-            return [
-                $classNameName,
-                $instanceOf,
-            ];
-        }, $classNames);
-    }
-
-    /**
-     * Verifies Laminas resolves each alias and factory to its expected type.
-     *
-     * @param class-string $className The configured service identifier.
-     * @param class-string $instanceOf The expected resolved service type.
-     */
-    #[DataProvider('provideContainerDependenciesWithInstanceType')]
-    public function testServiceManagerCanCreateDependecyThatImplements(string $className, string $instanceOf): void
-    {
-        $object = $this->serviceManager->get($className);
-        static::assertInstanceOf($instanceOf, $object);
-    }
-}
diff --git a/tests/Functional/Container/ConfigProviderIntegrationTest.php b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php
similarity index 74%
rename from tests/Functional/Container/ConfigProviderIntegrationTest.php
rename to tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php
index 29ea492..be03a5a 100644
--- a/tests/Functional/Container/ConfigProviderIntegrationTest.php
+++ b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php
@@ -14,9 +14,9 @@
  * @license   https://opensource.org/licenses/MIT MIT License
  */
 
-namespace CoiSA\ErrorHandler\Test\Functional\Container;
+namespace CoiSA\ErrorHandler\Test\Functional\ServiceProvider;
 
-use CoiSA\ErrorHandler\Container\ConfigProvider;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent;
 use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener;
@@ -31,7 +31,8 @@
 use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
 use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use CoiSA\ErrorHandler\Test\Log\AssertThrowableTestCaseLogger;
-use Laminas\ServiceManager\ServiceManager;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use FastForward\Container\ServiceProvider\ArrayServiceProvider;
 use Nyholm\Psr7\Factory\Psr17Factory;
 use Phly\EventDispatcher\EventDispatcher;
 use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate;
@@ -39,26 +40,34 @@
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\WithoutErrorHandler;
 use Psr\EventDispatcher\EventDispatcherInterface;
+use Psr\Container\ContainerInterface;
 use Psr\Http\Message\ResponseFactoryInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\StreamFactoryInterface;
 use Psr\Http\Server\RequestHandlerInterface;
 
+use function FastForward\Container\container;
+
 /**
- * Verifies the Laminas configuration adapter's reporting and lifecycle behavior.
+ * Verifies reporter, event and HTTP composition through the Interop service provider.
  *
- * @package CoiSA\ErrorHandler\Test\Functional\Container
+ * @package CoiSA\ErrorHandler\Test\Functional\ServiceProvider
  *
  * @internal
  * @coversNothing
  */
-final class ConfigProviderIntegrationTest extends TestCase
+final class ErrorHandlerCompositionTest extends TestCase
 {
     /**
-     * @var ServiceManager
+     * @var array Application services supplied before resolving the container.
+     */
+    private array $services = [];
+
+    /**
+     * @var ContainerInterface|null Lazily composed container for the current test.
      */
-    private $serviceManager;
+    private ?ContainerInterface $container = null;
 
     /**
      * @var EventDispatcher
@@ -75,15 +84,12 @@ final class ConfigProviderIntegrationTest extends TestCase
      */
     protected function setUp(): void
     {
-        $this->serviceManager = new ServiceManager((new ConfigProvider())->getDependencies());
-
         $this->listenerProvider = new ListenerProviderAggregate();
         $this->eventDispatcher  = new EventDispatcher($this->listenerProvider);
 
         $factory = new Psr17Factory();
 
-        $this->serviceManager->setService(ResponseFactoryInterface::class, $factory);
-        $this->serviceManager->setService(StreamFactoryInterface::class, $factory);
+        $this->services = [ResponseFactoryInterface::class => $factory, StreamFactoryInterface::class => $factory];
     }
 
     /**
@@ -91,17 +97,19 @@ protected function setUp(): void
      */
     protected function tearDown(): void
     {
-        $this->serviceManager->get(ErrorHandler::class)->unregister();
+        if ($this->container !== null) {
+            $this->container->get(ErrorHandler::class)->unregister();
+        }
     }
 
     /**
-     * Provides the concrete services declared by the Laminas adapter.
+     * Provides service identifiers explicitly declared by the Interop provider.
      *
-     * @return list Concrete service-identifier cases.
+     * @return list Declared service-identifier cases.
      */
     public static function provideServiceProviderClassNames(): array
     {
-        $factories = (new ConfigProvider())->getFactories();
+        $factories = (new ErrorHandlerServiceProvider())->getFactories();
 
         return array_chunk(array_keys($factories), 1);
     }
@@ -112,9 +120,9 @@ public static function provideServiceProviderClassNames(): array
      * @param class-string $service The service identifier under test.
      */
     #[DataProvider('provideServiceProviderClassNames')]
-    public function testContainerHasConfigProviderFactories(string $service): void
+    public function testContainerHasProviderServices(string $service): void
     {
-        static::assertTrue($this->serviceManager->has($service));
+        static::assertTrue($this->container()->has($service));
     }
 
     /**
@@ -123,10 +131,28 @@ public function testContainerHasConfigProviderFactories(string $service): void
      * @param class-string $service The service identifier under test.
      */
     #[DataProvider('provideServiceProviderClassNames')]
-    public function testContainerProvideConfigProviderFactories(string $service): void
+    public function testContainerResolvesProviderServices(string $service): void
+    {
+        $this->services[EventDispatcherInterface::class] = $this->eventDispatcher;
+        static::assertInstanceOf($service, $this->container()->get($service));
+    }
+
+    /**
+     * Composes the library provider with application service definitions last.
+     *
+     * @return ContainerInterface The shared container for the current test.
+     */
+    private function container(): ContainerInterface
     {
-        $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher);
-        static::assertInstanceOf($service, $this->serviceManager->get($service));
+        $application = new ArrayServiceProvider(array_map(
+            static fn (object $service): \Closure => static fn (): object => $service,
+            $this->services
+        ));
+
+        return $this->container ??= container(new AggregateServiceProvider(
+            new ErrorHandlerServiceProvider(),
+            $application
+        ));
     }
 
     /**
@@ -137,7 +163,7 @@ public function testErrorHandlerWithoutHandlerWillDoNothing(): void
         $message   = uniqid('test', true);
         $exception = new \InvalidArgumentException($message);
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $this->expectNotToPerformAssertions();
@@ -155,9 +181,9 @@ public function testCallableHandlerWillHandleThrowable(): void
         $callableThrowableHandler = new CallableThrowableHandler(function ($throwable) use ($exception): void {
             $this->assertSame($exception, $throwable);
         });
-        $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
+        $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleThrowable($exception);
@@ -174,9 +200,9 @@ public function testCallableHandlerFromAggregationWillHandleThrowable(): void
         $callableThrowableHandler = new CallableThrowableHandler(function ($throwable) use ($exception): void {
             $this->assertSame($exception, $throwable);
         });
-        $this->serviceManager->setService(CallableThrowableHandler::class, $callableThrowableHandler);
+        $this->services[CallableThrowableHandler::class] = $callableThrowableHandler;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleThrowable($exception);
@@ -213,9 +239,9 @@ public function testEventDispatcherCallableHandlerWillHandleThrowable(): void
         $this->listenerProvider->attach($errorEventListenerProvider);
         $this->listenerProvider->attach($throwableListenerProvider);
 
-        $this->serviceManager->setService(EventDispatcherInterface::class, $this->eventDispatcher);
+        $this->services[EventDispatcherInterface::class] = $this->eventDispatcher;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleThrowable($exception);
@@ -229,9 +255,9 @@ public function testErrorHandlerRespectErrorReporting(): void
         $callableThrowableHandler = new CallableThrowableHandler(function ($throwable): void {
             $this->assertTrue(false);
         });
-        $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
+        $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $oldLevel = error_reporting(E_ALL ^ E_USER_NOTICE);
@@ -250,9 +276,9 @@ public function testThrowErrorExceptionPhpErrorHandlerThrowErrorException(): voi
         $callableThrowableHandler = new CallableThrowableHandler(function ($throwable): void {
             $this->assertInstanceOf(ErrorException::class, $throwable);
         });
-        $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
+        $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         trigger_error('Test user warning', E_USER_WARNING);
@@ -271,9 +297,9 @@ public function testErrorHandlerMiddlewareHandleRequestException(): void
                 'message' => $throwable->getMessage(),
             ]);
         });
-        $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
+        $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $serverRequest  = (new Psr17Factory())->createServerRequest('GET', '/');
@@ -306,7 +332,7 @@ public function handle(ServerRequestInterface $serverRequest): ResponseInterface
             }
         };
 
-        $middleware = $this->serviceManager->get(ErrorHandlerMiddleware::class);
+        $middleware = $this->container()->get(ErrorHandlerMiddleware::class);
         $response   = $middleware->process($serverRequest, $requestHandler);
 
         static::assertInstanceOf(ResponseInterface::class, $response);
@@ -325,7 +351,7 @@ public function handle(ServerRequestInterface $serverRequest): ResponseInterface
     public function testUnregisteredErrorHandlerHandleShutdownWillDoNothing(): void
     {
         $this->expectOutputString('');
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->handleShutdown();
     }
 
@@ -344,10 +370,10 @@ public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHan
             throw $exception;
         });
 
-        $this->serviceManager->setService(ThrowableHandlerInterface::class, $callableThrowableHandler);
-        $this->serviceManager->setService(ShutdownHandlerInterface::class, $callableShutdownHandler);
+        $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler;
+        $this->services[ShutdownHandlerInterface::class] = $callableShutdownHandler;
 
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->register();
 
         $errorHandler->handleShutdown();
@@ -358,7 +384,7 @@ public function testErrorHandlerHandleShutdownWithErrorWillHandledByThrowableHan
      */
     public function testUnregisterNotRegisteredErrorHandlerShouldNotThrowExceptions(): void
     {
-        $errorHandler = $this->serviceManager->get(ErrorHandler::class);
+        $errorHandler = $this->container()->get(ErrorHandler::class);
         $errorHandler->unregister();
         static::assertTrue(true);
     }
diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
index 6613ecd..faa032e 100644
--- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
+++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php
@@ -16,7 +16,7 @@
 
 namespace CoiSA\ErrorHandler\Test\Functional\ServiceProvider;
 
-use CoiSA\ErrorHandler\Container\Factory\ErrorHandlerFactory;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory;
 use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\ErrorHandlerInterface;
 use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEvent;
diff --git a/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php b/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php
index 3b2dbad..8e8ad28 100644
--- a/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php
+++ b/tests/Unit/Http/Message/ThrowableResponseFactoryTest.php
@@ -100,6 +100,32 @@ public function testZeroIsAValidRenderedBody(): void
         self::assertSame('0', (string) $streams->createStreamFromThrowable(new \RuntimeException('private')));
     }
 
+    /**
+     * Verifies successful nested rendering preserves the caller's output buffer.
+     */
+    public function testBalancedRendererBuffersPreserveCallerOutput(): void
+    {
+        $reporter = new CallableThrowableHandler(static function (): void {
+            ob_start();
+            echo 'nested body';
+            $nested = ob_get_clean();
+            echo 'public ' . $nested;
+        });
+        $streams = new ThrowableStreamFactory(new Psr17Factory(), $reporter);
+        ob_start();
+        echo 'caller output';
+        $level = ob_get_level();
+
+        try {
+            $stream = $streams->createStreamFromThrowable(new \RuntimeException('private'));
+            self::assertSame('public nested body', (string) $stream);
+            self::assertSame($level, ob_get_level());
+            self::assertSame('caller output', ob_get_contents());
+        } finally {
+            ob_end_clean();
+        }
+    }
+
     /**
      * Verifies renderer failure cleans nested buffers while preserving the caller buffer.
      */
diff --git a/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php b/tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php
similarity index 93%
rename from tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php
rename to tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php
index b1d6ceb..404bc1b 100644
--- a/tests/Unit/Container/ThrowableHandlerAggregateFactoryTest.php
+++ b/tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php
@@ -14,9 +14,9 @@
  * @license   https://opensource.org/licenses/MIT MIT License
  */
 
-namespace CoiSA\ErrorHandler\Test\Unit\Container;
+namespace CoiSA\ErrorHandler\Test\Unit\ServiceProvider\Factory;
 
-use CoiSA\ErrorHandler\Container\Factory\ThrowableHandlerAggregateFactory;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ThrowableHandlerAggregateFactory;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\TestCase;
@@ -27,7 +27,7 @@
 /**
  * Verifies optional reporter discovery and propagation of construction failures.
  *
- * @package CoiSA\ErrorHandler\Test\Unit\Container
+ * @package CoiSA\ErrorHandler\Test\Unit\ServiceProvider\Factory
  *
  * @internal
  */

From 2253e341e6288d626ffd67147a94bf19d2a2d251 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?=
 
Date: Thu, 15 Oct 2026 09:46:44 -0300
Subject: [PATCH 08/27] fix: harden reporting and document release workflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Felipe Sayão Lobato Abreu 
---
 .agents/skills/coisa-error-handler/SKILL.md   |   10 +-
 .changeset/README.md                          |   16 +
 .changeset/config.json                        |   16 +
 .changeset/modernize-error-handler.md         |   47 +
 .dockerignore                                 |    7 -
 .gitattributes                                |   45 +-
 .github/dependabot.yml                        |    5 +
 .github/workflows/changesets.yml              |   55 +
 .github/workflows/ci.yml                      |   23 +-
 .github/workflows/release.yml                 |   62 +
 .gitignore                                    |    3 +
 .php-cs-fixer.dist.php                        |    8 +-
 AGENTS.md                                     |    8 +-
 CHANGELOG.md                                  |   41 +-
 Dockerfile                                    |   10 -
 README.md                                     |   22 +-
 composer.lock                                 | 5907 -----------------
 docker-compose.yml                            |   11 -
 docs/api.md                                   |    7 +-
 docs/examples.md                              |  531 ++
 docs/fast-forward.md                          |   66 +-
 docs/index.html                               |  464 +-
 docs/migration.md                             |   10 +-
 docs/releases.md                              |   90 +
 docs/testing.md                               |  311 +-
 examples/custom-handlers.php                  |   72 +
 examples/event-dispatcher.php                 |   81 +
 examples/http-middleware.php                  |   70 +
 examples/http-rendering.php                   |   41 +
 examples/reporters.php                        |   37 +
 examples/scopes.php                           |  100 +
 package-lock.json                             |  551 ++
 package.json                                  |   20 +
 phpcs.xml                                     |    1 +
 scripts/release.mjs                           |   89 +
 src/ErrorHandler.php                          |   17 +-
 .../Middleware/ErrorHandlerMiddleware.php     |    4 +
 tests/Distribution/run.py                     |   37 +
 tests/Examples/run.py                         |   71 +
 tests/Fire/run.php                            |    6 +-
 tests/Fire/scenario.php                       |   11 +
 .../Functional/ErrorHandlerLifecycleTest.php  |  143 +
 tests/Release/release.test.mjs                |   58 +
 43 files changed, 2975 insertions(+), 6209 deletions(-)
 create mode 100644 .changeset/README.md
 create mode 100644 .changeset/config.json
 create mode 100644 .changeset/modernize-error-handler.md
 delete mode 100644 .dockerignore
 create mode 100644 .github/workflows/changesets.yml
 create mode 100644 .github/workflows/release.yml
 delete mode 100644 Dockerfile
 delete mode 100644 composer.lock
 delete mode 100644 docker-compose.yml
 create mode 100644 docs/examples.md
 create mode 100644 docs/releases.md
 create mode 100644 examples/custom-handlers.php
 create mode 100644 examples/event-dispatcher.php
 create mode 100644 examples/http-middleware.php
 create mode 100644 examples/http-rendering.php
 create mode 100644 examples/reporters.php
 create mode 100644 examples/scopes.php
 create mode 100644 package-lock.json
 create mode 100644 package.json
 create mode 100644 scripts/release.mjs
 create mode 100644 tests/Distribution/run.py
 create mode 100644 tests/Examples/run.py
 create mode 100644 tests/Functional/ErrorHandlerLifecycleTest.php
 create mode 100644 tests/Release/release.test.mjs

diff --git a/.agents/skills/coisa-error-handler/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md
index 5e65d80..aadcb88 100644
--- a/.agents/skills/coisa-error-handler/SKILL.md
+++ b/.agents/skills/coisa-error-handler/SKILL.md
@@ -112,7 +112,15 @@ In the library checkout, use `composer --no-plugins check` and
 against its configured reporters and middleware; the library's passing suite
 does not prove remote logger delivery or application-specific transport behavior.
 
-For full examples, consult the matching checkout's `docs/fast-forward.md`,
+After a reporter fails during registration, built-in middleware propagates the
+failure and restores callbacks without re-rendering through that reporter.
+Independent unregistered direct calls remain usable. A fatal inside another PHP
+shutdown callback can abort the remaining shutdown queue; do not promise recovery
+by re-registering handlers.
+
+For complete runnable recipes covering every built-in adapter, consult
+`docs/examples.md` and `docs/index.html#examples` in the matching checkout.
+For full contract details, consult the matching checkout's `docs/fast-forward.md`,
 `docs/api.md` and `docs/testing.md`, or the
 [source repository](https://github.com/coisa/error-handler). Keep the observed
 version, integration changes, executed checks and remaining limits explicit.
diff --git a/.changeset/README.md b/.changeset/README.md
new file mode 100644
index 0000000..356b314
--- /dev/null
+++ b/.changeset/README.md
@@ -0,0 +1,16 @@
+# Pending release notes
+
+This repository uses Changesets for the Composer package `coisa/error-handler`.
+The private npm manifest is development tooling only; its version mirrors the
+latest stable Git tag. Nothing is published to npm, and Composer has no hardcoded
+`version` field.
+
+Run `npm ci --ignore-scripts`, then `npm run changeset`. Select
+`@coisa/error-handler`, choose patch/minor/major and write an English summary.
+Commit the generated Markdown note with the implementation. Documentation and
+dependency updates can use patch notes; compatibility breaks require major.
+
+After merge into `master`, automation maintains `changeset-release/master`.
+Reviewing and merging that version PR authorizes its Git tag and GitHub release.
+See [the release procedure](../docs/releases.md) for permissions, checks and
+Packagist delivery. Do not consume pending notes manually.
diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 0000000..0957277
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,16 @@
+{
+  "$schema": "https://unpkg.com/@changesets/config/schema.json",
+  "changelog": "@changesets/cli/changelog",
+  "commit": false,
+  "fixed": [],
+  "linked": [],
+  "access": "restricted",
+  "baseBranch": "master",
+  "updateInternalDependencies": "patch",
+  "ignore": [],
+  "format": false,
+  "privatePackages": {
+    "version": true,
+    "tag": false
+  }
+}
diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md
new file mode 100644
index 0000000..93867f1
--- /dev/null
+++ b/.changeset/modernize-error-handler.md
@@ -0,0 +1,47 @@
+---
+"@coisa/error-handler": major
+---
+
+Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package
+`coisa/error-handler` and namespace `CoiSA\ErrorHandler`.
+
+### Changed
+
+- Require PHP 8.3 or newer and test PHP 8.3–8.5. PHP 7.x and PHP 8.0–8.2 are outside the support contract.
+- Exit with status 1 when the registered uncaught-exception handler completes, preserving failure signals in CLI jobs. Direct `handleThrowable()` calls still return normally.
+- Reject out-of-order `unregister()` calls without removing another handler. Only the active registration handles shutdown events.
+- Return a generic HTTP body when a reporter emits nothing; use standard reason phrases instead of exception messages. Preserve valid error status codes through 599.
+- Update PHPUnit, PHPStan, PHP CS Fixer, PHP_CodeSniffer and CI. Declare PSR-11 as a runtime dependency.
+- Require FastForward Container at runtime and reuse its alias/invokable factories. Remove the standalone `ErrorHandlerContainer`, its alias factory and container exceptions, and constructor-only dispatch/HTTP factories; use autowiring for plain injection.
+- Keep the Interop service provider as the single container integration entrypoint. Remove the Laminas `ConfigProvider`, discovery metadata and ServiceManager development dependency; move both remaining `Container\Factory` classes to `ServiceProvider\Factory` and migrate their behavior tests.
+- Separate throwable output capture from buffer cleanup through private methods in `ThrowableStreamFactory`, preserving renderer-failure precedence and caller-owned buffers.
+
+### Fixed
+
+- Skip an optional callable reporter when an autowiring container advertises it but retrieval reports PSR-11 not-found; continue propagating other construction exceptions.
+- Re-registering an instance no longer adds duplicate shutdown callbacks.
+- Failing or recursive reporters are not invoked repeatedly during exception/shutdown handling.
+- Custom shutdown callbacks still run after a reported uncaught throwable; replacing the active callback continues to suppress the old registration.
+- Throwable rendering cleans reachable removable buffers when the renderer throws, stops at non-removable buffers, preserves the original failure if cleanup also throws, preserves the body `0`, and accepts SQLSTATE exception codes without raising a secondary type error.
+- Shutdown reporting excludes nonfatal core and compile warnings.
+
+### Added
+
+- An Interop service provider with nine explicit definitions, constructor autowiring, process-handler binding and factory services for independent middleware scopes.
+- Complete multiline PHPDoc for changed source, tests and examples, including lifecycle state, failure contracts and typed test data.
+- Integration tests using the released FastForward container, an executable provider example, English wiring/lifetime documentation, AGENTS.md and the portable `coisa-error-handler` consumer skill with explicit package provenance.
+- Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests.
+- Regression coverage for constructor autowiring, concrete adapter overrides, required renderer interface failures and reporter reset after re-registration; document FastForward's concrete-class fallback boundary.
+- English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment.
+- Weekly Composer and GitHub Actions update proposals through Dependabot.
+
+### Follow-up validation and documentation
+
+- Limit failed-reporter suppression to registered lifecycles and preserve independent direct deliveries.
+- Propagate middleware reporter failures without rendering through the failed reporter a second time.
+- Characterize fatal shutdown-queue termination with fresh and reused handlers in bounded subprocesses.
+- Expose eight complete executable recipes and all built-in adapters in the cookbook and static site; verify source parity in CI.
+- Introduce private Changesets tooling for reviewed version PRs, immutable Git tags and GitHub releases; Composer remains the distribution channel.
+- Remove the redundant Dockerfile, Compose service and Docker ignore rules; the PHP matrix runs directly in CI.
+- Stop tracking composer.lock in this library; validate lowest/highest dependency bounds and keep application locks application-owned.
+- Exclude development tools, npm manifests/lock, Changesets, tests, examples and documentation from Git archives, and verify a production-only installation from the exported package.
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 4d8ad36..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,7 +0,0 @@
-.composer/
-.git/
-.github/
-.idea/
-vendor/
-tmp/
-*.cache
diff --git a/.gitattributes b/.gitattributes
index 7978bdf..19a7271 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,14 +1,31 @@
-*                            text=auto
-tests/                      export-ignore
-.editorconfig               export-ignore
-.gitattributes              export-ignore
-.gitignore                  export-ignore
-.php_cs                     export-ignore
-.scrutinizer.yml            export-ignore
-docker-compose.override.yml export-ignore
-docker-compose.yml          export-ignore
-Dockerfile                  export-ignore
-phpcs.xml                   export-ignore
-phpstan.neon                export-ignore
-phpunit.xml                 export-ignore
-Makefile                    export-ignore
+* text=auto
+
+# Runtime distributions contain source, Composer metadata and license/release information.
+/.agents export-ignore
+/.agents/** export-ignore
+/.github export-ignore
+/.github/** export-ignore
+/.changeset export-ignore
+/.changeset/** export-ignore
+/docs export-ignore
+/docs/** export-ignore
+/examples export-ignore
+/examples/** export-ignore
+/scripts export-ignore
+/scripts/** export-ignore
+/tests export-ignore
+/tests/** export-ignore
+/.docheader export-ignore
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/.php-cs-fixer.dist.php export-ignore
+/.php_cs export-ignore
+/.scrutinizer.yml export-ignore
+/AGENTS.md export-ignore
+/Makefile export-ignore
+/phpcs.xml export-ignore
+/phpstan.neon export-ignore
+/phpunit.xml export-ignore
+/package.json export-ignore
+/package-lock.json export-ignore
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index bf60b15..67a1a63 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,5 +1,10 @@
 version: 2
 updates:
+  - package-ecosystem: npm
+    directory: /
+    schedule:
+      interval: weekly
+    open-pull-requests-limit: 5
   - package-ecosystem: composer
     directory: /
     schedule:
diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml
new file mode 100644
index 0000000..bfa2a08
--- /dev/null
+++ b/.github/workflows/changesets.yml
@@ -0,0 +1,55 @@
+name: Release tooling
+
+on:
+  pull_request:
+    branches: [master]
+  push:
+    branches: [master]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  verify:
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - uses: actions/setup-node@66d7d1ddafddf1ef45f98ee51a7496f9cdadb2f4 # v6
+        with:
+          node-version: '24'
+          cache: npm
+      - run: npm ci --ignore-scripts
+      - run: npm run test:release
+      - name: Require a release note on feature PRs
+        if: >-
+          github.event_name == 'pull_request' &&
+          !(github.event.pull_request.head.ref == 'changeset-release/master' &&
+            github.event.pull_request.head.repo.full_name == github.repository &&
+            github.event.pull_request.user.login == 'github-actions[bot]')
+        env:
+          BASE_SHA: ${{ github.event.pull_request.base.sha }}
+        run: |
+          removed=$(git diff --name-only --diff-filter=DR "$BASE_SHA...HEAD" -- '.changeset/*.md' ':!.changeset/README.md')
+          if [ -n "$removed" ]; then
+            echo "::error::Preserve pending release notes; only the version PR consumes them."
+            exit 1
+          fi
+          files=$(git diff --name-only --diff-filter=AM "$BASE_SHA...HEAD" -- '.changeset/*.md' ':!.changeset/README.md')
+          if [ -z "$files" ]; then
+            echo "::error::Add an English release note with npm run changeset."
+            exit 1
+          fi
+          npm run changeset:status -- --since="$BASE_SHA" --output=changeset-status.json
+          node --input-type=module -e '
+            import fs from "node:fs";
+            const plan = JSON.parse(fs.readFileSync("changeset-status.json", "utf8"));
+            if (!plan.changesets.length || !plan.changesets.every(note => note.summary?.trim()) ||
+                !plan.releases.some(release => release.name === "@coisa/error-handler" &&
+                  ["patch", "minor", "major"].includes(release.type))) {
+              throw new Error("A nonempty changeset for @coisa/error-handler is required.");
+            }'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e5b1092..7e82f41 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,8 +28,6 @@ jobs:
             dependencies: highest
           - php: '8.4'
             dependencies: highest
-          - php: '8.5'
-            dependencies: locked
           - php: '8.5'
             dependencies: highest
     steps:
@@ -41,25 +39,12 @@ jobs:
           coverage: none
           tools: composer:v2
       - run: composer validate --strict --no-plugins
-      - if: matrix.dependencies == 'locked'
-        run: composer install --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress
       - if: matrix.dependencies == 'highest'
         run: composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress
       - if: matrix.dependencies == 'lowest'
         run: composer update --prefer-lowest --prefer-stable --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress
       - run: composer --no-plugins check
-      - name: Verify the documented CLI example
-        run: |
-          python3 - <<'PY'
-          import subprocess
-          result = subprocess.run(['php', 'examples/cli.php'], capture_output=True, text=True, timeout=5)
-          assert result.returncode == 1 and result.stdout == ''
-          assert 'Example warning' in result.stderr and 'Example uncaught exception' in result.stderr
-          provider = subprocess.run(['php', 'examples/fast-forward.php'], capture_output=True, text=True, timeout=5)
-          assert provider.returncode == 0 and provider.stderr == ''
-          assert provider.stdout == 'Reported: FastForward provider warning\n'
-          PY
-      - name: Verify production-only autoload
-        run: |
-          composer install --no-dev --no-plugins --no-scripts --no-interaction --no-progress
-          php -r 'require "vendor/autoload.php"; $provider = new CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider(); $services = FastForward\Container\container($provider); $handler = $services->get(CoiSA\ErrorHandler\ErrorHandlerInterface::class); if (!$handler instanceof CoiSA\ErrorHandler\ErrorHandler || count($provider->getFactories()) !== 9) { throw new RuntimeException("Production provider resolution failed"); }'
+      - name: Verify every documented example and displayed source
+        run: python3 tests/Examples/run.py
+      - name: Verify runtime archive and production-only installation
+        run: python3 tests/Distribution/run.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c5cd1c3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,62 @@
+name: Release
+
+on:
+  push:
+    branches: [master]
+
+permissions:
+  contents: read
+
+concurrency:
+  group: release-master
+  cancel-in-progress: false
+
+jobs:
+  release:
+    if: github.repository == 'coisa/error-handler'
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    permissions:
+      contents: write
+      pull-requests: write
+      actions: write
+    steps:
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - uses: actions/setup-node@66d7d1ddafddf1ef45f98ee51a7496f9cdadb2f4 # v6
+        with:
+          node-version: '24'
+          cache: npm
+      - run: npm ci --ignore-scripts
+      - run: npm run test:release
+      - name: Publish only a merged version PR
+        run: npm run release:github
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          RELEASE_BEFORE: ${{ github.event.before }}
+      - name: Check the current default-branch tip
+        id: tip
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          current=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/master" --jq .object.sha)
+          if [ "$current" = "$GITHUB_SHA" ]; then
+            echo "current=true" >> "$GITHUB_OUTPUT"
+          fi
+      - name: Create or update the version PR
+        id: version
+        if: steps.tip.outputs.current == 'true'
+        uses: changesets/action/version@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: npm run version:packages
+          pr-title: 'chore: release coisa/error-handler'
+          commit-message: 'chore: release coisa/error-handler'
+      # GITHUB_TOKEN-created PRs do not trigger pull_request workflows.
+      - name: Run CI on the generated version branch
+        if: steps.version.outputs.pr-number != ''
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: gh workflow run ci.yml --ref changeset-release/master
diff --git a/.gitignore b/.gitignore
index fbd4ac1..ed3c01e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
 .composer/
 .idea/
 vendor/
+composer.lock
+node_modules/
+changeset-status.json
 tests/html/
 tests/clover.xml
 tests/testdox.html
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 75f1c3d..394b190 100755
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -14,6 +14,12 @@
  * @license   https://opensource.org/licenses/MIT MIT License
  */
 
+// coisa/php-cs-fixer 2.1.0 depends on AbstractRuleSetDescription, removed from
+// current PHP CS Fixer. This explicit PSR-12 baseline is not its full CoiSA preset.
 return (new PhpCsFixer\Config())
     ->setRules(['@PSR12' => true])
-    ->setFinder(PhpCsFixer\Finder::create()->in([__DIR__ . '/src', __DIR__ . '/tests'])->name('*.php'));
+    ->setFinder(PhpCsFixer\Finder::create()->in([
+        __DIR__ . '/src',
+        __DIR__ . '/tests',
+        __DIR__ . '/examples',
+    ])->name('*.php'));
diff --git a/AGENTS.md b/AGENTS.md
index d617752..b90fd95 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,8 +42,8 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed
 ## Work Guidance
 
 Inspect the current branch and relevant interfaces before changes. Update
-source, meaningful regression tests, examples, affected guides and Unreleased
-changelog together. Keep the skill aligned with tested public APIs, without
+source, meaningful regression tests, examples, affected guides and a pending
+Changeset together. Keep runtime code independent of Node release tooling. Keep the skill aligned with tested public APIs, without
 copying the full manual or adding host-specific dependencies.
 
 Run fatal-error and process-termination experiments only through bounded child
@@ -69,6 +69,10 @@ Check documentation links and inspect the actual CI result for the tested SHA.
 - [docs/api.md](docs/api.md): core lifecycle, reporters and PSR composition.
 - [docs/fast-forward.md](docs/fast-forward.md): provider definitions, overrides,
   cached services and fresh middleware scopes.
+- [docs/examples.md](docs/examples.md): executable cookbook covering all built-in
+  adapters; keep website snippets identical to their example sources.
+- [docs/releases.md](docs/releases.md): pending Changesets, reviewed version PRs,
+  immutable release tags and Composer delivery boundaries.
 - [docs/testing.md](docs/testing.md): failure runners, commands and evidence limits.
 - [docs/migration.md](docs/migration.md): breaking changes from published 2.0.
 - [.agents/skills/coisa-error-handler/SKILL.md](.agents/skills/coisa-error-handler/SKILL.md):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8552a64..2f25ca0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,35 +1,10 @@
 # Changelog
 
-## Unreleased — next major
-
-### Changed
-
-- Require PHP 8.3 or newer and test PHP 8.3–8.5. PHP 7.x and PHP 8.0–8.2 are outside the support contract.
-- Exit with status 1 when the registered uncaught-exception handler completes, preserving failure signals in CLI jobs. Direct `handleThrowable()` calls still return normally.
-- Reject out-of-order `unregister()` calls without removing another handler. Only the active registration handles shutdown events.
-- Return a generic HTTP body when a reporter emits nothing; use standard reason phrases instead of exception messages. Preserve valid error status codes through 599.
-- Update PHPUnit, PHPStan, PHP CS Fixer, PHP_CodeSniffer, Docker and CI. Declare PSR-11 as a runtime dependency.
-- Require FastForward Container at runtime and reuse its alias/invokable factories. Remove the standalone `ErrorHandlerContainer`, its alias factory and container exceptions, and constructor-only dispatch/HTTP factories; use autowiring for plain injection.
-- Keep the Interop service provider as the single container integration entrypoint. Remove the Laminas `ConfigProvider`, discovery metadata and ServiceManager development dependency; move both remaining `Container\Factory` classes to `ServiceProvider\Factory` and migrate their behavior tests.
-- Separate throwable output capture from buffer cleanup through private methods in `ThrowableStreamFactory`, preserving renderer-failure precedence and caller-owned buffers.
-
-### Fixed
-
-- Skip an optional callable reporter when an autowiring container advertises it but retrieval reports PSR-11 not-found; continue propagating other construction exceptions.
-- Re-registering an instance no longer adds duplicate shutdown callbacks.
-- Failing or recursive reporters are not invoked repeatedly during exception/shutdown handling.
-- Custom shutdown callbacks still run after a reported uncaught throwable; replacing the active callback continues to suppress the old registration.
-- Throwable rendering cleans reachable removable buffers when the renderer throws, stops at non-removable buffers, preserves the original failure if cleanup also throws, preserves the body `0`, and accepts SQLSTATE exception codes without raising a secondary type error.
-- Shutdown reporting excludes nonfatal core and compile warnings.
-
-### Added
-
-- An Interop service provider with nine explicit definitions, constructor autowiring, process-handler binding and factory services for independent middleware scopes.
-- Complete multiline PHPDoc for changed source, tests and examples, including lifecycle state, failure contracts and typed test data.
-- Integration tests using the released FastForward container, an executable provider example, English wiring/lifetime documentation, AGENTS.md and the portable `coisa-error-handler` consumer skill with explicit package provenance.
-- Bounded subprocess fire tests, HTTP loopback tests, lifecycle and response regression tests.
-- Regression coverage for constructor autowiring, concrete adapter overrides, required renderer interface failures and reporter reset after re-registration; document FastForward's concrete-class fallback boundary.
-- English usage, API, migration and testing documentation, plus a static documentation site with manual default-branch Pages deployment.
-- Weekly Composer and GitHub Actions update proposals through Dependabot.
-
-The historical 2.0.0 and 1.0.0 releases are available in [GitHub Releases](https://github.com/coisa/error-handler/releases).
+This branch describes an **unreleased next major** after 2.0.0. The complete pending
+release notes are in [.changeset/modernize-error-handler.md](.changeset/modernize-error-handler.md).
+Changesets will generate a versioned section when the reviewed version PR is prepared.
+See [release maintenance](docs/releases.md).
+
+Historical [2.0.0](https://github.com/coisa/error-handler/releases/tag/2.0.0) and
+[1.0.0](https://github.com/coisa/error-handler/releases/tag/1.0.0) releases predate
+this workflow. No new version has been published by adding this configuration.
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 3ceeecd..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,10 +0,0 @@
-ARG PHP_VERSION=8.3
-FROM php:${PHP_VERSION}-cli-alpine
-
-RUN apk add --no-cache git unzip
-COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
-WORKDIR /app
-COPY . .
-# Resolve for this PHP version; the checked-in lock is the PHP 8.5 reference set.
-RUN composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress
-CMD ["composer", "--no-plugins", "check"]
diff --git a/README.md b/README.md
index 936fc0e..2851ec5 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ Small, composable PHP error handling with FastForward Container, PSR-14 events a
 
 ## Install and run
 
-To reproduce this branch with PHP 8.5 and Composer 2:
+To reproduce this branch with PHP 8.3–8.5 and Composer 2:
 
 ```sh
 git clone https://github.com/coisa/error-handler.git
@@ -22,7 +22,7 @@ composer --no-plugins check
 php examples/cli.php
 ```
 
-While reviewing an unmerged PR, check out its branch before installing. On PHP 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible development tools. The CLI example deliberately exits **1** after reporting a warning and an uncaught exception.
+While reviewing an unmerged PR, check out its branch before installing. This library does not commit `composer.lock`; development tools resolve for the selected PHP version. Applications retain their own lock. The CLI example deliberately exits **1** after reporting a warning and an uncaught exception.
 
 For the currently published release, `composer require coisa/error-handler` installs the version available on Packagist; it does not select this unreleased work.
 
@@ -66,7 +66,23 @@ The middleware restores handlers in `finally`, including when response rendering
 
 GitHub Pages is prepared as a **manual** workflow restricted to `master`. After review and merge, an owner can enable Pages with GitHub Actions as the source and run the Documentation workflow from `master`. Nothing in the pull-request CI publishes documentation or executes PHP on Pages.
 
-Dependabot is configured to propose Composer and GitHub Actions updates weekly after this configuration reaches the default branch. Updates remain reviewable pull requests.
+Dependabot is configured to propose Composer, npm release-tooling and GitHub Actions updates weekly after this configuration reaches the default branch. Updates remain reviewable pull requests.
+
+## Complete usage recipes
+
+The [usage cookbook](docs/examples.md) and [documentation site](docs/index.html#examples)
+show all built-in adapters with eight runnable examples: process bootstrap,
+Interop provider, ordered reporters, PSR-14 events with PSR-3 logging, HTTP
+middleware, safe JSON rendering, custom PHP/shutdown handlers and nested scopes.
+CI executes every recipe and verifies that the displayed code matches its source.
+
+## Releases
+
+[Changesets](docs/releases.md) collects English release notes and maintains a
+version PR after changes reach `master`. Merging that reviewed bot PR creates the
+matching Git tag and GitHub release. The initial major note prepares 3.0.0 from
+the published 2.0.0 baseline; no release is performed by this development PR.
+Node is only development release tooling; consumers install the Composer package.
 
 ## Working with coding agents
 
diff --git a/composer.lock b/composer.lock
deleted file mode 100644
index 9d88a2c..0000000
--- a/composer.lock
+++ /dev/null
@@ -1,5907 +0,0 @@
-{
-    "_readme": [
-        "This file locks the dependencies of your project to a known state",
-        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
-        "This file is @generated automatically"
-    ],
-    "content-hash": "2aafd0e1101697f930aa5a8c7540dca2",
-    "packages": [
-        {
-            "name": "brick/varexporter",
-            "version": "0.6.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/brick/varexporter.git",
-                "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/brick/varexporter/zipball/af98bfc2b702a312abbcaff37656dbe419cec5bc",
-                "reference": "af98bfc2b702a312abbcaff37656dbe419cec5bc",
-                "shasum": ""
-            },
-            "require": {
-                "nikic/php-parser": "^5.0",
-                "php": "^8.1"
-            },
-            "require-dev": {
-                "php-coveralls/php-coveralls": "^2.2",
-                "phpunit/phpunit": "^10.5",
-                "vimeo/psalm": "6.8.4"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Brick\\VarExporter\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()",
-            "keywords": [
-                "var_export"
-            ],
-            "support": {
-                "issues": "https://github.com/brick/varexporter/issues",
-                "source": "https://github.com/brick/varexporter/tree/0.6.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/BenMorel",
-                    "type": "github"
-                }
-            ],
-            "time": "2025-02-20T17:42:39+00:00"
-        },
-        {
-            "name": "container-interop/service-provider",
-            "version": "v0.4.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/container-interop/service-provider.git",
-                "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/container-interop/service-provider/zipball/e04441ca21ef03e10dce70b0af29269281eec6dc",
-                "reference": "e04441ca21ef03e10dce70b0af29269281eec6dc",
-                "shasum": ""
-            },
-            "require": {
-                "psr/container": "^1.0 || ^2.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Interop\\Container\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "Promoting container interoperability through standard service providers",
-            "homepage": "https://github.com/container-interop/service-provider",
-            "support": {
-                "issues": "https://github.com/container-interop/service-provider/issues",
-                "source": "https://github.com/container-interop/service-provider/tree/v0.4.1"
-            },
-            "time": "2023-12-14T14:50:12+00:00"
-        },
-        {
-            "name": "dflydev/dot-access-data",
-            "version": "v3.0.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/dflydev/dflydev-dot-access-data.git",
-                "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
-                "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1 || ^8.0"
-            },
-            "require-dev": {
-                "phpstan/phpstan": "^0.12.42",
-                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
-                "scrutinizer/ocular": "1.6.0",
-                "squizlabs/php_codesniffer": "^3.5",
-                "vimeo/psalm": "^4.0.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "3.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Dflydev\\DotAccessData\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Dragonfly Development Inc.",
-                    "email": "info@dflydev.com",
-                    "homepage": "http://dflydev.com"
-                },
-                {
-                    "name": "Beau Simensen",
-                    "email": "beau@dflydev.com",
-                    "homepage": "http://beausimensen.com"
-                },
-                {
-                    "name": "Carlos Frutos",
-                    "email": "carlos@kiwing.it",
-                    "homepage": "https://github.com/cfrutos"
-                },
-                {
-                    "name": "Colin O'Dell",
-                    "email": "colinodell@gmail.com",
-                    "homepage": "https://www.colinodell.com"
-                }
-            ],
-            "description": "Given a deep data structure, access data by dot notation.",
-            "homepage": "https://github.com/dflydev/dflydev-dot-access-data",
-            "keywords": [
-                "access",
-                "data",
-                "dot",
-                "notation"
-            ],
-            "support": {
-                "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
-                "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
-            },
-            "time": "2024-07-08T12:26:09+00:00"
-        },
-        {
-            "name": "fast-forward/config",
-            "version": "v1.4.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fast-forward/config.git",
-                "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fast-forward/config/zipball/930434f7f7aaf02d551eb53ab86ed25e6511522c",
-                "reference": "930434f7f7aaf02d551eb53ab86ed25e6511522c",
-                "shasum": ""
-            },
-            "require": {
-                "dflydev/dot-access-data": "^3.0",
-                "laminas/laminas-config-aggregator": "^1.13",
-                "php": "^8.1",
-                "psr/container": "^1.0||^2.0",
-                "psr/simple-cache": "^3.0"
-            },
-            "require-dev": {
-                "coisa/php-cs-fixer": "^2.1",
-                "phpspec/prophecy-phpunit": "^2.3",
-                "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "1.x-dev"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "src/functions.php"
-                ],
-                "psr-4": {
-                    "FastForward\\Config\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Felipe Sayão Lobato Abreu",
-                    "email": "github@mentordosnerds.com"
-                }
-            ],
-            "description": "Fast Forward Config utility classes",
-            "homepage": "https://github.com/php-fast-forward",
-            "support": {
-                "issues": "https://github.com/php-fast-forward/config/issues",
-                "source": "https://github.com/php-fast-forward/config"
-            },
-            "time": "2025-06-11T14:36:02+00:00"
-        },
-        {
-            "name": "fast-forward/container",
-            "version": "v1.6.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fast-forward/container.git",
-                "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fast-forward/container/zipball/d9dd6d964189db80db0490cbe19dcd29841deba4",
-                "reference": "d9dd6d964189db80db0490cbe19dcd29841deba4",
-                "shasum": ""
-            },
-            "require": {
-                "container-interop/service-provider": "^0.4.1",
-                "fast-forward/config": "^1.1",
-                "php": "^8.3",
-                "php-di/php-di": "^7.0",
-                "psr/container": "^1.0 || ^2.0"
-            },
-            "require-dev": {
-                "fast-forward/dev-tools": "dev-main"
-            },
-            "type": "library",
-            "extra": {
-                "grumphp": {
-                    "config-default-path": "vendor/fast-forward/dev-tools/grumphp.yml"
-                },
-                "branch-alias": {
-                    "dev-main": "1.x-dev"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "src/functions.php"
-                ],
-                "psr-4": {
-                    "FastForward\\Container\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Felipe Sayão Lobato Abreu",
-                    "email": "github@mentordosnerds.com"
-                }
-            ],
-            "description": "Fast Forward PSR-11 Container utility classes",
-            "homepage": "https://github.com/php-fast-forward",
-            "support": {
-                "issues": "https://github.com/php-fast-forward/container/issues",
-                "source": "https://github.com/php-fast-forward/container"
-            },
-            "time": "2026-03-27T15:33:59+00:00"
-        },
-        {
-            "name": "laminas/laminas-config-aggregator",
-            "version": "1.20.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laminas/laminas-config-aggregator.git",
-                "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-config-aggregator/zipball/75331b17a9ba72c5fa8679988ef08a588ddd86af",
-                "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af",
-                "shasum": ""
-            },
-            "require": {
-                "brick/varexporter": "^0.5.0 || ^0.4.0 || ^0.6.0 || ^0.7.0",
-                "laminas/laminas-stdlib": "^3.18.0",
-                "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
-                "webimpress/safe-writer": "^2.2.0"
-            },
-            "conflict": {
-                "nikic/php-parser": "<4.12",
-                "zendframework/zend-config-aggregator": "*"
-            },
-            "require-dev": {
-                "laminas/laminas-coding-standard": "~3.1.0",
-                "laminas/laminas-config": "^3.10.1",
-                "phpunit/phpunit": "^11.5.42",
-                "psalm/plugin-phpunit": "^0.19.5",
-                "vimeo/psalm": "^6.13.1"
-            },
-            "suggest": {
-                "laminas/laminas-config": "Allows loading configuration from XML, INI, YAML, and JSON files",
-                "laminas/laminas-config-aggregator-modulemanager": "Allows loading configuration from laminas-mvc Module classes",
-                "laminas/laminas-config-aggregator-parameters": "Allows usage of templated parameters within your configuration"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Laminas\\ConfigAggregator\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "description": "Lightweight library for collecting and merging configuration from different sources",
-            "homepage": "https://laminas.dev",
-            "keywords": [
-                "config-aggregator",
-                "laminas"
-            ],
-            "support": {
-                "chat": "https://laminas.dev/chat",
-                "docs": "https://docs.laminas.dev/laminas-config-aggregator/",
-                "forum": "https://discourse.laminas.dev",
-                "issues": "https://github.com/laminas/laminas-config-aggregator/issues",
-                "rss": "https://github.com/laminas/laminas-config-aggregator/releases.atom",
-                "source": "https://github.com/laminas/laminas-config-aggregator"
-            },
-            "funding": [
-                {
-                    "url": "https://funding.communitybridge.org/projects/laminas-project",
-                    "type": "community_bridge"
-                }
-            ],
-            "time": "2026-06-01T15:37:55+00:00"
-        },
-        {
-            "name": "laminas/laminas-stdlib",
-            "version": "3.21.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laminas/laminas-stdlib.git",
-                "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096",
-                "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096",
-                "shasum": ""
-            },
-            "require": {
-                "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
-            },
-            "conflict": {
-                "zendframework/zend-stdlib": "*"
-            },
-            "require-dev": {
-                "laminas/laminas-coding-standard": "^3.1.0",
-                "phpbench/phpbench": "^1.4.1",
-                "phpunit/phpunit": "^11.5.42",
-                "psalm/plugin-phpunit": "^0.19.5",
-                "vimeo/psalm": "^6.13.1"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Laminas\\Stdlib\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "description": "SPL extensions, array utilities, error handlers, and more",
-            "homepage": "https://laminas.dev",
-            "keywords": [
-                "laminas",
-                "stdlib"
-            ],
-            "support": {
-                "chat": "https://laminas.dev/chat",
-                "docs": "https://docs.laminas.dev/laminas-stdlib/",
-                "forum": "https://discourse.laminas.dev",
-                "issues": "https://github.com/laminas/laminas-stdlib/issues",
-                "rss": "https://github.com/laminas/laminas-stdlib/releases.atom",
-                "source": "https://github.com/laminas/laminas-stdlib"
-            },
-            "funding": [
-                {
-                    "url": "https://funding.communitybridge.org/projects/laminas-project",
-                    "type": "community_bridge"
-                }
-            ],
-            "time": "2025-10-11T18:13:12+00:00"
-        },
-        {
-            "name": "laravel/serializable-closure",
-            "version": "v2.0.16",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laravel/serializable-closure.git",
-                "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
-                "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^8.1"
-            },
-            "require-dev": {
-                "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
-                "nesbot/carbon": "^2.67|^3.0",
-                "pestphp/pest": "^2.36|^3.0|^4.0",
-                "phpstan/phpstan": "^2.0",
-                "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Laravel\\SerializableClosure\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Taylor Otwell",
-                    "email": "taylor@laravel.com"
-                },
-                {
-                    "name": "Nuno Maduro",
-                    "email": "nuno@laravel.com"
-                }
-            ],
-            "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
-            "keywords": [
-                "closure",
-                "laravel",
-                "serializable"
-            ],
-            "support": {
-                "issues": "https://github.com/laravel/serializable-closure/issues",
-                "source": "https://github.com/laravel/serializable-closure"
-            },
-            "time": "2026-08-18T20:28:54+00:00"
-        },
-        {
-            "name": "nikic/php-parser",
-            "version": "v5.9.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
-                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
-                "shasum": ""
-            },
-            "require": {
-                "ext-json": "*",
-                "ext-tokenizer": "*",
-                "php": ">=7.4"
-            },
-            "require-dev": {
-                "ircmaxell/php-yacc": "^0.0.7",
-                "phpunit/phpunit": "^9.0"
-            },
-            "bin": [
-                "bin/php-parse"
-            ],
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "5.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "PhpParser\\": "lib/PhpParser"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Nikita Popov"
-                }
-            ],
-            "description": "A PHP parser written in PHP",
-            "keywords": [
-                "parser",
-                "php"
-            ],
-            "support": {
-                "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
-            },
-            "time": "2026-09-13T18:51:52+00:00"
-        },
-        {
-            "name": "php-di/invoker",
-            "version": "2.3.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/PHP-DI/Invoker.git",
-                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
-                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.3",
-                "psr/container": "^1.0|^2.0"
-            },
-            "require-dev": {
-                "athletic/athletic": "~0.1.8",
-                "mnapoli/hard-mode": "~0.3.0",
-                "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Invoker\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "Generic and extensible callable invoker",
-            "homepage": "https://github.com/PHP-DI/Invoker",
-            "keywords": [
-                "callable",
-                "dependency",
-                "dependency-injection",
-                "injection",
-                "invoke",
-                "invoker"
-            ],
-            "support": {
-                "issues": "https://github.com/PHP-DI/Invoker/issues",
-                "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/mnapoli",
-                    "type": "github"
-                }
-            ],
-            "time": "2025-08-30T10:22:22+00:00"
-        },
-        {
-            "name": "php-di/php-di",
-            "version": "7.1.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/PHP-DI/PHP-DI.git",
-                "reference": "f88054cc052e40dbe7b383c8817c19442d480352"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
-                "reference": "f88054cc052e40dbe7b383c8817c19442d480352",
-                "shasum": ""
-            },
-            "require": {
-                "laravel/serializable-closure": "^1.0 || ^2.0",
-                "php": ">=8.0",
-                "php-di/invoker": "^2.0",
-                "psr/container": "^1.1 || ^2.0"
-            },
-            "provide": {
-                "psr/container-implementation": "^1.0"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "^3",
-                "friendsofphp/proxy-manager-lts": "^1",
-                "mnapoli/phpunit-easymock": "^1.3",
-                "phpunit/phpunit": "^9.6 || ^10 || ^11",
-                "vimeo/psalm": "^5|^6"
-            },
-            "suggest": {
-                "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
-            },
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "src/functions.php"
-                ],
-                "psr-4": {
-                    "DI\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "The dependency injection container for humans",
-            "homepage": "https://php-di.org/",
-            "keywords": [
-                "PSR-11",
-                "container",
-                "container-interop",
-                "dependency injection",
-                "di",
-                "ioc",
-                "psr11"
-            ],
-            "support": {
-                "issues": "https://github.com/PHP-DI/PHP-DI/issues",
-                "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/mnapoli",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2025-08-16T11:10:48+00:00"
-        },
-        {
-            "name": "psr/container",
-            "version": "2.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/container.git",
-                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
-                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.4.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Container\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common Container Interface (PHP FIG PSR-11)",
-            "homepage": "https://github.com/php-fig/container",
-            "keywords": [
-                "PSR-11",
-                "container",
-                "container-interface",
-                "container-interop",
-                "psr"
-            ],
-            "support": {
-                "issues": "https://github.com/php-fig/container/issues",
-                "source": "https://github.com/php-fig/container/tree/2.0.2"
-            },
-            "time": "2021-11-05T16:47:00+00:00"
-        },
-        {
-            "name": "psr/event-dispatcher",
-            "version": "1.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/event-dispatcher.git",
-                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
-                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\EventDispatcher\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
-                }
-            ],
-            "description": "Standard interfaces for event handling.",
-            "keywords": [
-                "events",
-                "psr",
-                "psr-14"
-            ],
-            "support": {
-                "issues": "https://github.com/php-fig/event-dispatcher/issues",
-                "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
-            },
-            "time": "2019-01-08T18:20:26+00:00"
-        },
-        {
-            "name": "psr/http-factory",
-            "version": "1.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-factory.git",
-                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
-                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1",
-                "psr/http-message": "^1.0 || ^2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
-            "keywords": [
-                "factory",
-                "http",
-                "message",
-                "psr",
-                "psr-17",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-factory"
-            },
-            "time": "2024-04-15T12:06:14+00:00"
-        },
-        {
-            "name": "psr/http-message",
-            "version": "2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-message.git",
-                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
-                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.2 || ^8.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP messages",
-            "homepage": "https://github.com/php-fig/http-message",
-            "keywords": [
-                "http",
-                "http-message",
-                "psr",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-message/tree/2.0"
-            },
-            "time": "2023-04-04T09:54:51+00:00"
-        },
-        {
-            "name": "psr/http-server-handler",
-            "version": "1.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-server-handler.git",
-                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
-                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.0",
-                "psr/http-message": "^1.0 || ^2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Server\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP server-side request handler",
-            "keywords": [
-                "handler",
-                "http",
-                "http-interop",
-                "psr",
-                "psr-15",
-                "psr-7",
-                "request",
-                "response",
-                "server"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
-            },
-            "time": "2023-04-10T20:06:20+00:00"
-        },
-        {
-            "name": "psr/http-server-middleware",
-            "version": "1.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-server-middleware.git",
-                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
-                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.0",
-                "psr/http-message": "^1.0 || ^2.0",
-                "psr/http-server-handler": "^1.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Server\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP server-side middleware",
-            "keywords": [
-                "http",
-                "http-interop",
-                "middleware",
-                "psr",
-                "psr-15",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "issues": "https://github.com/php-fig/http-server-middleware/issues",
-                "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
-            },
-            "time": "2023-04-11T06:14:47+00:00"
-        },
-        {
-            "name": "psr/log",
-            "version": "3.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/log.git",
-                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
-                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.0.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Log\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for logging libraries",
-            "homepage": "https://github.com/php-fig/log",
-            "keywords": [
-                "log",
-                "psr",
-                "psr-3"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/log/tree/3.0.2"
-            },
-            "time": "2024-09-11T13:17:53+00:00"
-        },
-        {
-            "name": "psr/simple-cache",
-            "version": "3.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/simple-cache.git",
-                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
-                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.0.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\SimpleCache\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interfaces for simple caching",
-            "keywords": [
-                "cache",
-                "caching",
-                "psr",
-                "psr-16",
-                "simple-cache"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
-            },
-            "time": "2021-10-29T13:26:27+00:00"
-        },
-        {
-            "name": "webimpress/safe-writer",
-            "version": "2.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/webimpress/safe-writer.git",
-                "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/webimpress/safe-writer/zipball/9d37cc8bee20f7cb2f58f6e23e05097eab5072e6",
-                "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.3 || ^8.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.5.4",
-                "vimeo/psalm": "^4.7",
-                "webimpress/coding-standard": "^1.2.2"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.2.x-dev",
-                    "dev-develop": "2.3.x-dev",
-                    "dev-release-1.0": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Webimpress\\SafeWriter\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-2-Clause"
-            ],
-            "description": "Tool to write files safely, to avoid race conditions",
-            "keywords": [
-                "concurrent write",
-                "file writer",
-                "race condition",
-                "safe writer",
-                "webimpress"
-            ],
-            "support": {
-                "issues": "https://github.com/webimpress/safe-writer/issues",
-                "source": "https://github.com/webimpress/safe-writer/tree/2.2.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/michalbundyra",
-                    "type": "github"
-                }
-            ],
-            "time": "2021-04-19T16:34:45+00:00"
-        }
-    ],
-    "packages-dev": [
-        {
-            "name": "clue/ndjson-react",
-            "version": "v1.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/clue/reactphp-ndjson.git",
-                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0",
-                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3",
-                "react/stream": "^1.2"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
-                "react/event-loop": "^1.2"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Clue\\React\\NDJson\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering"
-                }
-            ],
-            "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.",
-            "homepage": "https://github.com/clue/reactphp-ndjson",
-            "keywords": [
-                "NDJSON",
-                "json",
-                "jsonlines",
-                "newline",
-                "reactphp",
-                "streaming"
-            ],
-            "support": {
-                "issues": "https://github.com/clue/reactphp-ndjson/issues",
-                "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0"
-            },
-            "funding": [
-                {
-                    "url": "https://clue.engineering/support",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/clue",
-                    "type": "github"
-                }
-            ],
-            "time": "2022-12-23T10:58:28+00:00"
-        },
-        {
-            "name": "composer/pcre",
-            "version": "3.4.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/composer/pcre.git",
-                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
-                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.4 || ^8.0"
-            },
-            "conflict": {
-                "phpstan/phpstan": "<2.2.2"
-            },
-            "require-dev": {
-                "phpstan/phpstan": "^2",
-                "phpstan/phpstan-deprecation-rules": "^2",
-                "phpstan/phpstan-strict-rules": "^2",
-                "phpunit/phpunit": "^9"
-            },
-            "type": "library",
-            "extra": {
-                "phpstan": {
-                    "includes": [
-                        "extension.neon"
-                    ]
-                },
-                "branch-alias": {
-                    "dev-main": "3.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Composer\\Pcre\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Jordi Boggiano",
-                    "email": "j.boggiano@seld.be",
-                    "homepage": "http://seld.be"
-                }
-            ],
-            "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
-            "keywords": [
-                "PCRE",
-                "preg",
-                "regex",
-                "regular expression"
-            ],
-            "support": {
-                "issues": "https://github.com/composer/pcre/issues",
-                "source": "https://github.com/composer/pcre/tree/3.4.0"
-            },
-            "funding": [
-                {
-                    "url": "https://packagist.com",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/composer",
-                    "type": "github"
-                }
-            ],
-            "time": "2026-06-07T11:47:49+00:00"
-        },
-        {
-            "name": "composer/semver",
-            "version": "3.4.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/composer/semver.git",
-                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
-                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.3.2 || ^7.0 || ^8.0"
-            },
-            "require-dev": {
-                "phpstan/phpstan": "^1.11",
-                "symfony/phpunit-bridge": "^3 || ^7"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "3.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Composer\\Semver\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nils Adermann",
-                    "email": "naderman@naderman.de",
-                    "homepage": "http://www.naderman.de"
-                },
-                {
-                    "name": "Jordi Boggiano",
-                    "email": "j.boggiano@seld.be",
-                    "homepage": "http://seld.be"
-                },
-                {
-                    "name": "Rob Bast",
-                    "email": "rob.bast@gmail.com",
-                    "homepage": "http://robbast.nl"
-                }
-            ],
-            "description": "Semver library that offers utilities, version constraint parsing and validation.",
-            "keywords": [
-                "semantic",
-                "semver",
-                "validation",
-                "versioning"
-            ],
-            "support": {
-                "irc": "ircs://irc.libera.chat:6697/composer",
-                "issues": "https://github.com/composer/semver/issues",
-                "source": "https://github.com/composer/semver/tree/3.4.4"
-            },
-            "funding": [
-                {
-                    "url": "https://packagist.com",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/composer",
-                    "type": "github"
-                }
-            ],
-            "time": "2025-08-20T19:15:30+00:00"
-        },
-        {
-            "name": "composer/xdebug-handler",
-            "version": "3.0.5",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/composer/xdebug-handler.git",
-                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
-                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
-                "shasum": ""
-            },
-            "require": {
-                "composer/pcre": "^1 || ^2 || ^3",
-                "php": "^7.2.5 || ^8.0",
-                "psr/log": "^1 || ^2 || ^3"
-            },
-            "require-dev": {
-                "phpstan/phpstan": "^1.0",
-                "phpstan/phpstan-strict-rules": "^1.1",
-                "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Composer\\XdebugHandler\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "John Stevenson",
-                    "email": "john-stevenson@blueyonder.co.uk"
-                }
-            ],
-            "description": "Restarts a process without Xdebug.",
-            "keywords": [
-                "Xdebug",
-                "performance"
-            ],
-            "support": {
-                "irc": "ircs://irc.libera.chat:6697/composer",
-                "issues": "https://github.com/composer/xdebug-handler/issues",
-                "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
-            },
-            "funding": [
-                {
-                    "url": "https://packagist.com",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/composer",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2024-05-06T16:37:16+00:00"
-        },
-        {
-            "name": "ergebnis/agent-detector",
-            "version": "1.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/ergebnis/agent-detector.git",
-                "reference": "e211f17928c8b95a51e06040792d57f5462fb271"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271",
-                "reference": "e211f17928c8b95a51e06040792d57f5462fb271",
-                "shasum": ""
-            },
-            "require": {
-                "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
-            },
-            "require-dev": {
-                "ergebnis/composer-normalize": "^2.51.0",
-                "ergebnis/license": "^2.7.0",
-                "ergebnis/php-cs-fixer-config": "^6.60.2",
-                "ergebnis/phpstan-rules": "^2.13.1",
-                "ergebnis/phpunit-slow-test-detector": "^2.24.0",
-                "ergebnis/rector-rules": "^1.18.1",
-                "fakerphp/faker": "^1.24.1",
-                "infection/infection": "^0.26.6",
-                "phpstan/extension-installer": "^1.4.3",
-                "phpstan/phpstan": "^2.1.54",
-                "phpstan/phpstan-deprecation-rules": "^2.0.4",
-                "phpstan/phpstan-phpunit": "^2.0.16",
-                "phpstan/phpstan-strict-rules": "^2.0.10",
-                "phpunit/phpunit": "^9.6.34",
-                "rector/rector": "^2.4.2"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "1.2-dev"
-                },
-                "composer-normalize": {
-                    "indent-size": 2,
-                    "indent-style": "space"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Ergebnis\\AgentDetector\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Andreas Möller",
-                    "email": "am@localheinz.com",
-                    "homepage": "https://localheinz.com"
-                }
-            ],
-            "description": "Provides a detector for detecting the presence of an agent.",
-            "homepage": "https://github.com/ergebnis/agent-detector",
-            "support": {
-                "issues": "https://github.com/ergebnis/agent-detector/issues",
-                "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md",
-                "source": "https://github.com/ergebnis/agent-detector"
-            },
-            "time": "2026-05-07T08:19:07+00:00"
-        },
-        {
-            "name": "evenement/evenement",
-            "version": "v3.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/igorw/evenement.git",
-                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
-                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9 || ^6"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Evenement\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Igor Wiedler",
-                    "email": "igor@wiedler.ch"
-                }
-            ],
-            "description": "Événement is a very simple event dispatching library for PHP",
-            "keywords": [
-                "event-dispatcher",
-                "event-emitter"
-            ],
-            "support": {
-                "issues": "https://github.com/igorw/evenement/issues",
-                "source": "https://github.com/igorw/evenement/tree/v3.0.2"
-            },
-            "time": "2023-08-08T05:53:35+00:00"
-        },
-        {
-            "name": "fidry/cpu-core-counter",
-            "version": "1.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/theofidry/cpu-core-counter.git",
-                "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
-                "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.2 || ^8.0"
-            },
-            "require-dev": {
-                "fidry/makefile": "^0.2.0",
-                "fidry/php-cs-fixer-config": "^1.1.2",
-                "phpstan/extension-installer": "^1.2.0",
-                "phpstan/phpstan": "^2.0",
-                "phpstan/phpstan-deprecation-rules": "^2.0.0",
-                "phpstan/phpstan-phpunit": "^2.0",
-                "phpstan/phpstan-strict-rules": "^2.0",
-                "phpunit/phpunit": "^8.5.31 || ^9.5.26",
-                "webmozarts/strict-phpunit": "^7.5"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Fidry\\CpuCoreCounter\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Théo FIDRY",
-                    "email": "theo.fidry@gmail.com"
-                }
-            ],
-            "description": "Tiny utility to get the number of CPU cores.",
-            "keywords": [
-                "CPU",
-                "core"
-            ],
-            "support": {
-                "issues": "https://github.com/theofidry/cpu-core-counter/issues",
-                "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/theofidry",
-                    "type": "github"
-                }
-            ],
-            "time": "2025-08-14T07:29:31+00:00"
-        },
-        {
-            "name": "friendsofphp/php-cs-fixer",
-            "version": "v3.95.25",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
-                "reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
-                "reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
-                "shasum": ""
-            },
-            "require": {
-                "clue/ndjson-react": "^1.3",
-                "composer/semver": "^3.4",
-                "composer/xdebug-handler": "^3.0.5",
-                "ergebnis/agent-detector": "^1.2",
-                "ext-filter": "*",
-                "ext-hash": "*",
-                "ext-json": "*",
-                "ext-tokenizer": "*",
-                "fidry/cpu-core-counter": "^1.3",
-                "php": "^7.4 || ^8.0",
-                "react/child-process": "^0.6.6",
-                "react/event-loop": "^1.5",
-                "react/socket": "^1.16",
-                "react/stream": "^1.4",
-                "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0",
-                "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0",
-                "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
-                "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
-                "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
-                "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
-                "symfony/polyfill-mbstring": "^1.37",
-                "symfony/polyfill-php80": "^1.37",
-                "symfony/polyfill-php81": "^1.37",
-                "symfony/polyfill-php84": "^1.37",
-                "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0",
-                "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0"
-            },
-            "require-dev": {
-                "facile-it/paraunit": "^1.3.1 || ^2.11.0",
-                "infection/infection": "^0.32.7",
-                "justinrainbow/json-schema": "^6.10.0",
-                "keradus/cli-executor": "^2.3",
-                "php-coveralls/php-coveralls": "^2.9.1",
-                "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
-                "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
-                "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6",
-                "symfony/polyfill-php85": "^1.38",
-                "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1",
-                "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1"
-            },
-            "suggest": {
-                "ext-dom": "For handling output formats in XML",
-                "ext-mbstring": "For handling non-UTF8 characters."
-            },
-            "bin": [
-                "php-cs-fixer"
-            ],
-            "type": "application",
-            "autoload": {
-                "psr-4": {
-                    "PhpCsFixer\\": "src/"
-                },
-                "exclude-from-classmap": [
-                    "src/**/Internal/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Dariusz Rumiński",
-                    "email": "dariusz.ruminski@gmail.com"
-                }
-            ],
-            "description": "A tool to automatically fix PHP code style",
-            "keywords": [
-                "Static code analysis",
-                "fixer",
-                "standards",
-                "static analysis"
-            ],
-            "support": {
-                "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
-                "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/keradus",
-                    "type": "github"
-                }
-            ],
-            "time": "2026-09-08T11:11:37+00:00"
-        },
-        {
-            "name": "myclabs/deep-copy",
-            "version": "1.14.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
-                "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^8.0"
-            },
-            "conflict": {
-                "doctrine/collections": "<1.6.8",
-                "doctrine/common": "<2.13.3 || >=3 <3.2.2"
-            },
-            "require-dev": {
-                "doctrine/collections": "^1.6.8",
-                "doctrine/common": "^2.13.3 || ^3.2.2",
-                "phpspec/prophecy": "^1.10",
-                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
-            },
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "src/DeepCopy/deep_copy.php"
-                ],
-                "psr-4": {
-                    "DeepCopy\\": "src/DeepCopy/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "Create deep copies (clones) of your objects",
-            "keywords": [
-                "clone",
-                "copy",
-                "duplicate",
-                "object",
-                "object graph"
-            ],
-            "support": {
-                "issues": "https://github.com/myclabs/DeepCopy/issues",
-                "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/mnapoli",
-                    "type": "github"
-                }
-            ],
-            "time": "2026-08-11T10:17:44+00:00"
-        },
-        {
-            "name": "nyholm/psr7",
-            "version": "1.8.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/Nyholm/psr7.git",
-                "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
-                "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2",
-                "psr/http-factory": "^1.0",
-                "psr/http-message": "^1.1 || ^2.0"
-            },
-            "provide": {
-                "php-http/message-factory-implementation": "1.0",
-                "psr/http-factory-implementation": "1.0",
-                "psr/http-message-implementation": "1.0"
-            },
-            "require-dev": {
-                "http-interop/http-factory-tests": "^0.9",
-                "php-http/message-factory": "^1.0",
-                "php-http/psr7-integration-tests": "^1.0",
-                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
-                "symfony/error-handler": "^4.4"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.8-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Nyholm\\Psr7\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Tobias Nyholm",
-                    "email": "tobias.nyholm@gmail.com"
-                },
-                {
-                    "name": "Martijn van der Ven",
-                    "email": "martijn@vanderven.se"
-                }
-            ],
-            "description": "A fast PHP7 implementation of PSR-7",
-            "homepage": "https://tnyholm.se",
-            "keywords": [
-                "psr-17",
-                "psr-7"
-            ],
-            "support": {
-                "issues": "https://github.com/Nyholm/psr7/issues",
-                "source": "https://github.com/Nyholm/psr7/tree/1.8.2"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/Zegnat",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nyholm",
-                    "type": "github"
-                }
-            ],
-            "time": "2024-09-09T07:06:30+00:00"
-        },
-        {
-            "name": "phar-io/manifest",
-            "version": "2.0.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phar-io/manifest.git",
-                "reference": "54750ef60c58e43759730615a392c31c80e23176"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
-                "reference": "54750ef60c58e43759730615a392c31c80e23176",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-libxml": "*",
-                "ext-phar": "*",
-                "ext-xmlwriter": "*",
-                "phar-io/version": "^3.0.1",
-                "php": "^7.2 || ^8.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne@blankerts.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Heuer",
-                    "email": "sebastian@phpeople.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
-            "support": {
-                "issues": "https://github.com/phar-io/manifest/issues",
-                "source": "https://github.com/phar-io/manifest/tree/2.0.4"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/theseer",
-                    "type": "github"
-                }
-            ],
-            "time": "2024-03-03T12:33:53+00:00"
-        },
-        {
-            "name": "phar-io/version",
-            "version": "3.2.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phar-io/version.git",
-                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
-                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.2 || ^8.0"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne@blankerts.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Heuer",
-                    "email": "sebastian@phpeople.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Library for handling version information and constraints",
-            "support": {
-                "issues": "https://github.com/phar-io/version/issues",
-                "source": "https://github.com/phar-io/version/tree/3.2.1"
-            },
-            "time": "2022-02-21T01:04:05+00:00"
-        },
-        {
-            "name": "phly/phly-event-dispatcher",
-            "version": "1.6.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phly/phly-event-dispatcher.git",
-                "reference": "09b86c00d16e34b5d33123191cb613307ff54be7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phly/phly-event-dispatcher/zipball/09b86c00d16e34b5d33123191cb613307ff54be7",
-                "reference": "09b86c00d16e34b5d33123191cb613307ff54be7",
-                "shasum": ""
-            },
-            "require": {
-                "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
-                "psr/container": "^1.0 || ^2.0",
-                "psr/event-dispatcher": "^1.0"
-            },
-            "conflict": {
-                "phpspec/prophecy": "<1.7.5"
-            },
-            "provide": {
-                "psr/event-dispatcher-implementation": "^1.0"
-            },
-            "require-dev": {
-                "fig/event-dispatcher-util": "^1.0",
-                "laminas/laminas-coding-standard": "~2.3.0",
-                "phpunit/phpunit": "^10.5"
-            },
-            "type": "library",
-            "extra": {
-                "laminas": {
-                    "config-provider": "Phly\\EventDispatcher\\ConfigProvider"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "src/functions/lazy_listener.php"
-                ],
-                "psr-4": {
-                    "Phly\\EventDispatcher\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "description": "Experimental event dispatcher for PSR-14",
-            "keywords": [
-                "components",
-                "event-dispatcher",
-                "psr-14"
-            ],
-            "support": {
-                "issues": "https://github.com/phly/phly-event-dispatcher/issues",
-                "rss": "https://github.com/phly/phly-event-dispatcher/releases.atom",
-                "source": "https://github.com/phly/phly-event-dispatcher"
-            },
-            "time": "2026-09-09T16:22:59+00:00"
-        },
-        {
-            "name": "phpstan/phpstan",
-            "version": "2.2.14",
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
-                "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.4|^8.0"
-            },
-            "conflict": {
-                "phpstan/phpstan-shim": "*"
-            },
-            "bin": [
-                "phpstan",
-                "phpstan.phar"
-            ],
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Ondřej Mirtes"
-                },
-                {
-                    "name": "Markus Staab"
-                },
-                {
-                    "name": "Vincent Langlet"
-                }
-            ],
-            "description": "PHPStan - PHP Static Analysis Tool",
-            "keywords": [
-                "dev",
-                "static analysis"
-            ],
-            "support": {
-                "docs": "https://phpstan.org/user-guide/getting-started",
-                "forum": "https://github.com/phpstan/phpstan/discussions",
-                "issues": "https://github.com/phpstan/phpstan/issues",
-                "security": "https://github.com/phpstan/phpstan/security/policy",
-                "source": "https://github.com/phpstan/phpstan-src"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/ondrejmirtes",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/phpstan",
-                    "type": "github"
-                }
-            ],
-            "time": "2026-09-12T21:39:33+00:00"
-        },
-        {
-            "name": "phpunit/php-code-coverage",
-            "version": "14.3.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "f8640c238e930b914d0098a1edbad6652e61a64a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f8640c238e930b914d0098a1edbad6652e61a64a",
-                "reference": "f8640c238e930b914d0098a1edbad6652e61a64a",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-libxml": "*",
-                "ext-mbstring": "*",
-                "ext-xmlwriter": "*",
-                "nikic/php-parser": "^5.8.0",
-                "php": ">=8.4",
-                "phpunit/php-text-template": "^6.0",
-                "sebastian/complexity": "^6.0",
-                "sebastian/environment": "^9.3.2",
-                "sebastian/git-state": "^1.0",
-                "sebastian/lines-of-code": "^5.0.2",
-                "sebastian/version": "^7.0",
-                "theseer/tokenizer": "^2.0.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3.3"
-            },
-            "suggest": {
-                "ext-pcov": "PHP extension that provides line coverage",
-                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "14.3.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
-            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
-            "keywords": [
-                "coverage",
-                "testing",
-                "xunit"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
-                "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.3"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-09-10T13:08:32+00:00"
-        },
-        {
-            "name": "phpunit/php-file-iterator",
-            "version": "7.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
-                "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae",
-                "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3.1"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "7.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
-            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
-            "keywords": [
-                "filesystem",
-                "iterator"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
-                "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-25T14:47:43+00:00"
-        },
-        {
-            "name": "phpunit/php-invoker",
-            "version": "7.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-invoker.git",
-                "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
-                "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "ext-pcntl": "*",
-                "phpunit/phpunit": "^13.0"
-            },
-            "suggest": {
-                "ext-pcntl": "*"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "7.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Invoke callables with a timeout",
-            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
-            "keywords": [
-                "process"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
-                "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-02-06T04:34:47+00:00"
-        },
-        {
-            "name": "phpunit/php-text-template",
-            "version": "6.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-text-template.git",
-                "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4",
-                "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "6.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Simple template engine.",
-            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
-            "keywords": [
-                "template"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
-                "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-02-06T04:36:37+00:00"
-        },
-        {
-            "name": "phpunit/php-timer",
-            "version": "9.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-timer.git",
-                "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6",
-                "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "9.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Utility class for timing",
-            "homepage": "https://github.com/sebastianbergmann/php-timer/",
-            "keywords": [
-                "timer"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
-                "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-02-06T04:37:53+00:00"
-        },
-        {
-            "name": "phpunit/phpunit",
-            "version": "13.3.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "d19d90cf860efb7e4d96cee8243e06a248578085"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d19d90cf860efb7e4d96cee8243e06a248578085",
-                "reference": "d19d90cf860efb7e4d96cee8243e06a248578085",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-filter": "*",
-                "ext-json": "*",
-                "ext-libxml": "*",
-                "ext-mbstring": "*",
-                "ext-xmlwriter": "*",
-                "myclabs/deep-copy": "^1.14.0",
-                "phar-io/manifest": "^2.0.4",
-                "phar-io/version": "^3.2.1",
-                "php": ">=8.4.1",
-                "phpunit/php-code-coverage": "^14.3.3",
-                "phpunit/php-file-iterator": "^7.0.2",
-                "phpunit/php-invoker": "^7.0.0",
-                "phpunit/php-text-template": "^6.0.0",
-                "phpunit/php-timer": "^9.0.0",
-                "sebastian/cli-parser": "^5.0.1",
-                "sebastian/comparator": "^8.4",
-                "sebastian/diff": "^9.0.1",
-                "sebastian/environment": "^9.3.2",
-                "sebastian/exporter": "^8.2.1",
-                "sebastian/file-filter": "^1.0",
-                "sebastian/git-state": "^1.0",
-                "sebastian/global-state": "^9.0.1",
-                "sebastian/object-enumerator": "^8.1.0",
-                "sebastian/recursion-context": "^8.0.1",
-                "sebastian/type": "^7.0.2",
-                "sebastian/version": "^7.0.0",
-                "staabm/side-effects-detector": "^1.0.5"
-            },
-            "bin": [
-                "phpunit"
-            ],
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "13.3-dev"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "src/Framework/Assert/Functions.php"
-                ],
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "The PHP Unit Testing framework.",
-            "homepage": "https://phpunit.de/",
-            "keywords": [
-                "phpunit",
-                "testing",
-                "xunit"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
-                "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.4"
-            },
-            "funding": [
-                {
-                    "url": "https://phpunit.de/sponsoring.html",
-                    "type": "other"
-                }
-            ],
-            "time": "2026-09-15T07:37:05+00:00"
-        },
-        {
-            "name": "react/cache",
-            "version": "v1.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/cache.git",
-                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
-                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0",
-                "react/promise": "^3.0 || ^2.0 || ^1.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "React\\Cache\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "Async, Promise-based cache interface for ReactPHP",
-            "keywords": [
-                "cache",
-                "caching",
-                "promise",
-                "reactphp"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/cache/issues",
-                "source": "https://github.com/reactphp/cache/tree/v1.2.0"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2022-11-30T15:59:55+00:00"
-        },
-        {
-            "name": "react/child-process",
-            "version": "v0.6.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/child-process.git",
-                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
-                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
-                "shasum": ""
-            },
-            "require": {
-                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
-                "php": ">=5.3.0",
-                "react/event-loop": "^1.2",
-                "react/stream": "^1.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
-                "react/socket": "^1.16",
-                "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "React\\ChildProcess\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "Event-driven library for executing child processes with ReactPHP.",
-            "keywords": [
-                "event-driven",
-                "process",
-                "reactphp"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/child-process/issues",
-                "source": "https://github.com/reactphp/child-process/tree/v0.6.7"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2025-12-23T15:25:20+00:00"
-        },
-        {
-            "name": "react/dns",
-            "version": "v1.14.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/dns.git",
-                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
-                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0",
-                "react/cache": "^1.0 || ^0.6 || ^0.5",
-                "react/event-loop": "^1.2",
-                "react/promise": "^3.2 || ^2.7 || ^1.2.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
-                "react/async": "^4.3 || ^3 || ^2",
-                "react/promise-timer": "^1.11"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "React\\Dns\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "Async DNS resolver for ReactPHP",
-            "keywords": [
-                "async",
-                "dns",
-                "dns-resolver",
-                "reactphp"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/dns/issues",
-                "source": "https://github.com/reactphp/dns/tree/v1.14.0"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2025-11-18T19:34:28+00:00"
-        },
-        {
-            "name": "react/event-loop",
-            "version": "v1.6.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/event-loop.git",
-                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
-                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
-            },
-            "suggest": {
-                "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "React\\EventLoop\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
-            "keywords": [
-                "asynchronous",
-                "event-loop"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/event-loop/issues",
-                "source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2025-11-17T20:46:25+00:00"
-        },
-        {
-            "name": "react/promise",
-            "version": "v3.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/promise.git",
-                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
-                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1.0"
-            },
-            "require-dev": {
-                "phpstan/phpstan": "1.12.28 || 1.4.10",
-                "phpunit/phpunit": "^9.6 || ^7.5"
-            },
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "src/functions_include.php"
-                ],
-                "psr-4": {
-                    "React\\Promise\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
-            "keywords": [
-                "promise",
-                "promises"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/promise/issues",
-                "source": "https://github.com/reactphp/promise/tree/v3.3.0"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2025-08-19T18:57:03+00:00"
-        },
-        {
-            "name": "react/socket",
-            "version": "v1.17.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/socket.git",
-                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
-                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
-                "shasum": ""
-            },
-            "require": {
-                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
-                "php": ">=5.3.0",
-                "react/dns": "^1.13",
-                "react/event-loop": "^1.2",
-                "react/promise": "^3.2 || ^2.6 || ^1.2.1",
-                "react/stream": "^1.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
-                "react/async": "^4.3 || ^3.3 || ^2",
-                "react/promise-stream": "^1.4",
-                "react/promise-timer": "^1.11"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "React\\Socket\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
-            "keywords": [
-                "Connection",
-                "Socket",
-                "async",
-                "reactphp",
-                "stream"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/socket/issues",
-                "source": "https://github.com/reactphp/socket/tree/v1.17.0"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2025-11-19T20:47:34+00:00"
-        },
-        {
-            "name": "react/stream",
-            "version": "v1.4.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/reactphp/stream.git",
-                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
-                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
-                "shasum": ""
-            },
-            "require": {
-                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
-                "php": ">=5.3.8",
-                "react/event-loop": "^1.2"
-            },
-            "require-dev": {
-                "clue/stream-filter": "~1.2",
-                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "React\\Stream\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Christian Lück",
-                    "email": "christian@clue.engineering",
-                    "homepage": "https://clue.engineering/"
-                },
-                {
-                    "name": "Cees-Jan Kiewiet",
-                    "email": "reactphp@ceesjankiewiet.nl",
-                    "homepage": "https://wyrihaximus.net/"
-                },
-                {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com",
-                    "homepage": "https://sorgalla.com/"
-                },
-                {
-                    "name": "Chris Boden",
-                    "email": "cboden@gmail.com",
-                    "homepage": "https://cboden.dev/"
-                }
-            ],
-            "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
-            "keywords": [
-                "event-driven",
-                "io",
-                "non-blocking",
-                "pipe",
-                "reactphp",
-                "readable",
-                "stream",
-                "writable"
-            ],
-            "support": {
-                "issues": "https://github.com/reactphp/stream/issues",
-                "source": "https://github.com/reactphp/stream/tree/v1.4.0"
-            },
-            "funding": [
-                {
-                    "url": "https://opencollective.com/reactphp",
-                    "type": "open_collective"
-                }
-            ],
-            "time": "2024-06-11T12:45:25+00:00"
-        },
-        {
-            "name": "sebastian/cli-parser",
-            "version": "5.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/cli-parser.git",
-                "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3",
-                "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.2.6"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "5.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library for parsing CLI options",
-            "homepage": "https://github.com/sebastianbergmann/cli-parser",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
-                "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
-                "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-01T04:27:14+00:00"
-        },
-        {
-            "name": "sebastian/comparator",
-            "version": "8.4.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb",
-                "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-mbstring": "*",
-                "php": ">=8.4",
-                "sebastian/diff": "^9.0",
-                "sebastian/exporter": "^8.2"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3"
-            },
-            "suggest": {
-                "ext-bcmath": "For comparing BcMath\\Number objects"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "8.4-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                },
-                {
-                    "name": "Jeff Welch",
-                    "email": "whatthejeff@gmail.com"
-                },
-                {
-                    "name": "Volker Dusch",
-                    "email": "github@wallbash.com"
-                },
-                {
-                    "name": "Bernhard Schussek",
-                    "email": "bschussek@2bepublished.at"
-                }
-            ],
-            "description": "Provides the functionality to compare PHP values for equality",
-            "homepage": "https://github.com/sebastianbergmann/comparator",
-            "keywords": [
-                "comparator",
-                "compare",
-                "equality"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/comparator/issues",
-                "security": "https://github.com/sebastianbergmann/comparator/security/policy",
-                "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-07T07:23:13+00:00"
-        },
-        {
-            "name": "sebastian/complexity",
-            "version": "6.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/complexity.git",
-                "reference": "c5651c795c98093480df79350cb050813fc7a2f3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3",
-                "reference": "c5651c795c98093480df79350cb050813fc7a2f3",
-                "shasum": ""
-            },
-            "require": {
-                "nikic/php-parser": "^5.0",
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "6.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library for calculating the complexity of PHP code units",
-            "homepage": "https://github.com/sebastianbergmann/complexity",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/complexity/issues",
-                "security": "https://github.com/sebastianbergmann/complexity/security/policy",
-                "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-02-06T04:41:32+00:00"
-        },
-        {
-            "name": "sebastian/diff",
-            "version": "9.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/diff.git",
-                "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26",
-                "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3.1",
-                "symfony/process": "^7.4.17"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "9.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                },
-                {
-                    "name": "Kore Nordmann",
-                    "email": "mail@kore-nordmann.de"
-                }
-            ],
-            "description": "Diff implementation",
-            "homepage": "https://github.com/sebastianbergmann/diff",
-            "keywords": [
-                "diff",
-                "udiff",
-                "unidiff",
-                "unified diff"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/diff/issues",
-                "security": "https://github.com/sebastianbergmann/diff/security/policy",
-                "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-25T15:38:55+00:00"
-        },
-        {
-            "name": "sebastian/environment",
-            "version": "9.3.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/environment.git",
-                "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e",
-                "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.1.11"
-            },
-            "suggest": {
-                "ext-posix": "*"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "9.3-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                }
-            ],
-            "description": "Provides functionality to handle HHVM/PHP environments",
-            "homepage": "https://github.com/sebastianbergmann/environment",
-            "keywords": [
-                "Xdebug",
-                "environment",
-                "hhvm"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/environment/issues",
-                "security": "https://github.com/sebastianbergmann/environment/security/policy",
-                "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-05-25T13:41:38+00:00"
-        },
-        {
-            "name": "sebastian/exporter",
-            "version": "8.2.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a",
-                "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a",
-                "shasum": ""
-            },
-            "require": {
-                "ext-mbstring": "*",
-                "php": ">=8.4",
-                "sebastian/recursion-context": "^8.0.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "8.2-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                },
-                {
-                    "name": "Jeff Welch",
-                    "email": "whatthejeff@gmail.com"
-                },
-                {
-                    "name": "Volker Dusch",
-                    "email": "github@wallbash.com"
-                },
-                {
-                    "name": "Adam Harvey",
-                    "email": "aharvey@php.net"
-                },
-                {
-                    "name": "Bernhard Schussek",
-                    "email": "bschussek@gmail.com"
-                }
-            ],
-            "description": "Provides the functionality to export PHP variables for visualization",
-            "homepage": "https://www.github.com/sebastianbergmann/exporter",
-            "keywords": [
-                "export",
-                "exporter"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/exporter/issues",
-                "security": "https://github.com/sebastianbergmann/exporter/security/policy",
-                "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-07T07:22:06+00:00"
-        },
-        {
-            "name": "sebastian/file-filter",
-            "version": "1.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/file-filter.git",
-                "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93",
-                "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "1.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library for filtering files",
-            "homepage": "https://github.com/sebastianbergmann/file-filter",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/file-filter/issues",
-                "security": "https://github.com/sebastianbergmann/file-filter/security/policy",
-                "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-04-22T07:20:04+00:00"
-        },
-        {
-            "name": "sebastian/git-state",
-            "version": "1.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/git-state.git",
-                "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b",
-                "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "1.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library for describing the state of a Git checkout",
-            "homepage": "https://github.com/sebastianbergmann/git-state",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/git-state/issues",
-                "security": "https://github.com/sebastianbergmann/git-state/security/policy",
-                "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-03-21T12:54:28+00:00"
-        },
-        {
-            "name": "sebastian/global-state",
-            "version": "9.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/global-state.git",
-                "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945",
-                "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4",
-                "sebastian/object-reflector": "^6.0",
-                "sebastian/recursion-context": "^8.0"
-            },
-            "require-dev": {
-                "ext-dom": "*",
-                "phpunit/phpunit": "^13.1.13"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "9.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                }
-            ],
-            "description": "Snapshotting of global state",
-            "homepage": "https://www.github.com/sebastianbergmann/global-state",
-            "keywords": [
-                "global state"
-            ],
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/global-state/issues",
-                "security": "https://github.com/sebastianbergmann/global-state/security/policy",
-                "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-06-01T15:11:33+00:00"
-        },
-        {
-            "name": "sebastian/lines-of-code",
-            "version": "5.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
-                "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
-                "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
-                "shasum": ""
-            },
-            "require": {
-                "nikic/php-parser": "^5.8.0",
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.2.4"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "5.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library for counting the lines of code in PHP source code",
-            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
-                "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
-                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-07-09T08:42:34+00:00"
-        },
-        {
-            "name": "sebastian/object-enumerator",
-            "version": "8.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
-                "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576",
-                "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4",
-                "sebastian/recursion-context": "^8.0.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "8.1-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                }
-            ],
-            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
-            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
-                "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
-                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-13T07:05:05+00:00"
-        },
-        {
-            "name": "sebastian/object-reflector",
-            "version": "6.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/object-reflector.git",
-                "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2",
-                "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "6.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                }
-            ],
-            "description": "Allows reflection of object attributes, including inherited and non-public ones",
-            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
-                "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
-                "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-13T06:34:36+00:00"
-        },
-        {
-            "name": "sebastian/recursion-context",
-            "version": "8.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0",
-                "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.2.6"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "8.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de"
-                },
-                {
-                    "name": "Jeff Welch",
-                    "email": "whatthejeff@gmail.com"
-                },
-                {
-                    "name": "Adam Harvey",
-                    "email": "aharvey@php.net"
-                }
-            ],
-            "description": "Provides functionality to recursively process PHP variables",
-            "homepage": "https://github.com/sebastianbergmann/recursion-context",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
-                "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
-                "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-03T05:58:12+00:00"
-        },
-        {
-            "name": "sebastian/type",
-            "version": "7.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/type.git",
-                "reference": "bd1df467864cb95140414059a535b2d906173fcf"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf",
-                "reference": "bd1df467864cb95140414059a535b2d906173fcf",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^13.3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "7.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Collection of value objects that represent the types of the PHP type system",
-            "homepage": "https://github.com/sebastianbergmann/type",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/type/issues",
-                "security": "https://github.com/sebastianbergmann/type/security/policy",
-                "source": "https://github.com/sebastianbergmann/type/tree/7.0.2"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-10T08:00:57+00:00"
-        },
-        {
-            "name": "sebastian/version",
-            "version": "7.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/version.git",
-                "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b",
-                "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "7.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
-            "homepage": "https://github.com/sebastianbergmann/version",
-            "support": {
-                "issues": "https://github.com/sebastianbergmann/version/issues",
-                "security": "https://github.com/sebastianbergmann/version/security/policy",
-                "source": "https://github.com/sebastianbergmann/version/tree/7.0.0"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/sebastian/version",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-02-06T04:52:52+00:00"
-        },
-        {
-            "name": "squizlabs/php_codesniffer",
-            "version": "4.0.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
-                "reference": "bbdc3d0532623e21838b7041a4364383a8126f96"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96",
-                "reference": "bbdc3d0532623e21838b7041a4364383a8126f96",
-                "shasum": ""
-            },
-            "require": {
-                "ext-libxml": "*",
-                "ext-simplexml": "*",
-                "ext-tokenizer": "*",
-                "ext-xmlwriter": "*",
-                "php": ">=7.2.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31"
-            },
-            "suggest": {
-                "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.",
-                "ext-pcntl": "For parallel processing support via the --parallel CLI option."
-            },
-            "bin": [
-                "bin/phpcbf",
-                "bin/phpcs"
-            ],
-            "type": "library",
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Greg Sherwood",
-                    "role": "Former lead"
-                },
-                {
-                    "name": "Juliette Reinders Folmer",
-                    "role": "Current lead"
-                },
-                {
-                    "name": "Contributors",
-                    "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
-                }
-            ],
-            "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.",
-            "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
-            "keywords": [
-                "phpcs",
-                "standards",
-                "static analysis"
-            ],
-            "support": {
-                "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
-                "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
-                "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
-                "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/PHPCSStandards",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/jrfnl",
-                    "type": "github"
-                },
-                {
-                    "url": "https://opencollective.com/php_codesniffer",
-                    "type": "open_collective"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/phpcsstandards",
-                    "type": "thanks_dev"
-                }
-            ],
-            "time": "2026-08-06T02:45:27+00:00"
-        },
-        {
-            "name": "staabm/side-effects-detector",
-            "version": "1.0.5",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/staabm/side-effects-detector.git",
-                "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
-                "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
-                "shasum": ""
-            },
-            "require": {
-                "ext-tokenizer": "*",
-                "php": "^7.4 || ^8.0"
-            },
-            "require-dev": {
-                "phpstan/extension-installer": "^1.4.3",
-                "phpstan/phpstan": "^1.12.6",
-                "phpunit/phpunit": "^9.6.21",
-                "symfony/var-dumper": "^5.4.43",
-                "tomasvotruba/type-coverage": "1.0.0",
-                "tomasvotruba/unused-public": "1.0.0"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "lib/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "A static analysis tool to detect side effects in PHP code",
-            "keywords": [
-                "static analysis"
-            ],
-            "support": {
-                "issues": "https://github.com/staabm/side-effects-detector/issues",
-                "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/staabm",
-                    "type": "github"
-                }
-            ],
-            "time": "2024-10-20T05:08:20+00:00"
-        },
-        {
-            "name": "symfony/console",
-            "version": "v8.1.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/console.git",
-                "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d",
-                "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1",
-                "symfony/deprecation-contracts": "^2.5|^3",
-                "symfony/polyfill-mbstring": "^1.0",
-                "symfony/polyfill-php85": "^1.32",
-                "symfony/service-contracts": "^2.5|^3",
-                "symfony/string": "^7.4.6|^8.0.6"
-            },
-            "conflict": {
-                "symfony/dependency-injection": "<8.1",
-                "symfony/event-dispatcher": "<8.1"
-            },
-            "provide": {
-                "psr/log-implementation": "1.0|2.0|3.0"
-            },
-            "require-dev": {
-                "psr/log": "^1|^2|^3",
-                "symfony/config": "^7.4|^8.0",
-                "symfony/dependency-injection": "^8.1",
-                "symfony/event-dispatcher": "^8.1",
-                "symfony/filesystem": "^7.4|^8.0",
-                "symfony/http-foundation": "^7.4|^8.0",
-                "symfony/http-kernel": "^7.4|^8.0",
-                "symfony/lock": "^7.4|^8.0",
-                "symfony/messenger": "^7.4|^8.0",
-                "symfony/mime": "^7.4|^8.0",
-                "symfony/process": "^7.4|^8.0",
-                "symfony/stopwatch": "^7.4|^8.0",
-                "symfony/uid": "^7.4|^8.0",
-                "symfony/validator": "^7.4|^8.0",
-                "symfony/var-dumper": "^7.4|^8.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Console\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Eases the creation of beautiful and testable command line interfaces",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "cli",
-                "command-line",
-                "console",
-                "terminal"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/console/tree/v8.1.7"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-09-13T10:55:57+00:00"
-        },
-        {
-            "name": "symfony/deprecation-contracts",
-            "version": "v3.7.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/deprecation-contracts.git",
-                "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
-                "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.1"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/contracts",
-                    "name": "symfony/contracts"
-                },
-                "branch-alias": {
-                    "dev-main": "3.7-dev"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "function.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "A generic function and convention to trigger deprecation notices",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-06-05T06:23:12+00:00"
-        },
-        {
-            "name": "symfony/event-dispatcher",
-            "version": "v8.1.5",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297",
-                "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1",
-                "symfony/deprecation-contracts": "^2.5|^3",
-                "symfony/event-dispatcher-contracts": "^2.5|^3"
-            },
-            "conflict": {
-                "symfony/security-http": "<7.4",
-                "symfony/service-contracts": "<2.5"
-            },
-            "provide": {
-                "psr/event-dispatcher-implementation": "1.0",
-                "symfony/event-dispatcher-implementation": "2.0|3.0"
-            },
-            "require-dev": {
-                "psr/log": "^1|^2|^3",
-                "symfony/config": "^7.4|^8.0",
-                "symfony/dependency-injection": "^7.4|^8.0",
-                "symfony/error-handler": "^7.4|^8.0",
-                "symfony/expression-language": "^7.4|^8.0",
-                "symfony/framework-bundle": "^7.4|^8.0",
-                "symfony/http-foundation": "^7.4|^8.0",
-                "symfony/service-contracts": "^2.5|^3",
-                "symfony/stopwatch": "^7.4|^8.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\EventDispatcher\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-21T17:47:34+00:00"
-        },
-        {
-            "name": "symfony/event-dispatcher-contracts",
-            "version": "v3.7.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
-                "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.1",
-                "psr/event-dispatcher": "^1"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/contracts",
-                    "name": "symfony/contracts"
-                },
-                "branch-alias": {
-                    "dev-main": "3.7-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Contracts\\EventDispatcher\\": ""
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Generic abstractions related to dispatching event",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-06-05T06:23:12+00:00"
-        },
-        {
-            "name": "symfony/filesystem",
-            "version": "v8.1.6",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/filesystem.git",
-                "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba",
-                "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1",
-                "symfony/deprecation-contracts": "^2.5|^3",
-                "symfony/polyfill-ctype": "~1.8",
-                "symfony/polyfill-mbstring": "~1.8"
-            },
-            "require-dev": {
-                "symfony/process": "^7.4|^8.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Filesystem\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Provides basic utilities for the filesystem",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/filesystem/tree/v8.1.6"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-23T10:06:25+00:00"
-        },
-        {
-            "name": "symfony/finder",
-            "version": "v8.1.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/finder.git",
-                "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062",
-                "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1"
-            },
-            "require-dev": {
-                "symfony/filesystem": "^7.4|^8.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Finder\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Finds files and directories via an intuitive fluent interface",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/finder/tree/v8.1.7"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-09-10T19:14:39+00:00"
-        },
-        {
-            "name": "symfony/options-resolver",
-            "version": "v8.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/options-resolver.git",
-                "reference": "88f9c561f678a02d54b897014049fa839e33ff82"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
-                "reference": "88f9c561f678a02d54b897014049fa839e33ff82",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1",
-                "symfony/deprecation-contracts": "^2.5|^3"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\OptionsResolver\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Provides an improved replacement for the array_replace PHP function",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "config",
-                "configuration",
-                "options"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-05-29T05:06:50+00:00"
-        },
-        {
-            "name": "symfony/polyfill-ctype",
-            "version": "v1.37.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
-                "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "provide": {
-                "ext-ctype": "*"
-            },
-            "suggest": {
-                "ext-ctype": "For best performance"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Ctype\\": ""
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Gert de Pagter",
-                    "email": "BackEndTea@gmail.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill for ctype functions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "ctype",
-                "polyfill",
-                "portable"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-04-10T16:19:22+00:00"
-        },
-        {
-            "name": "symfony/polyfill-intl-grapheme",
-            "version": "v1.41.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
-                "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
-                "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "suggest": {
-                "ext-intl": "For best performance"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill for intl's grapheme_* functions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "grapheme",
-                "intl",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-07-28T08:25:59+00:00"
-        },
-        {
-            "name": "symfony/polyfill-intl-normalizer",
-            "version": "v1.42.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
-                "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502",
-                "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "suggest": {
-                "ext-intl": "For best performance"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
-                },
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill for intl's Normalizer class and related functions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "intl",
-                "normalizer",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-08-07T06:33:24+00:00"
-        },
-        {
-            "name": "symfony/polyfill-mbstring",
-            "version": "v1.38.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
-                "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
-                "shasum": ""
-            },
-            "require": {
-                "ext-iconv": "*",
-                "php": ">=7.2"
-            },
-            "provide": {
-                "ext-mbstring": "*"
-            },
-            "suggest": {
-                "ext-mbstring": "For best performance"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Mbstring\\": ""
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill for the Mbstring extension",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "mbstring",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-05-27T06:59:30+00:00"
-        },
-        {
-            "name": "symfony/polyfill-php80",
-            "version": "v1.37.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-php80.git",
-                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
-                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php80\\": ""
-                },
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Ion Bazan",
-                    "email": "ion.bazan@gmail.com"
-                },
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-04-10T16:19:22+00:00"
-        },
-        {
-            "name": "symfony/polyfill-php81",
-            "version": "v1.38.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-php81.git",
-                "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
-                "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php81\\": ""
-                },
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-05-26T12:45:58+00:00"
-        },
-        {
-            "name": "symfony/polyfill-php84",
-            "version": "v1.38.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-php84.git",
-                "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
-                "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php84\\": ""
-                },
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-05-26T12:51:13+00:00"
-        },
-        {
-            "name": "symfony/polyfill-php85",
-            "version": "v1.41.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-php85.git",
-                "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a",
-                "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.2"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/polyfill",
-                    "name": "symfony/polyfill"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "bootstrap.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php85\\": ""
-                },
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-07-01T12:47:55+00:00"
-        },
-        {
-            "name": "symfony/process",
-            "version": "v8.1.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/process.git",
-                "reference": "10823b09358e690df4ff943e24e8492bb1019fc3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3",
-                "reference": "10823b09358e690df4ff943e24e8492bb1019fc3",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Process\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Executes commands in sub-processes",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/process/tree/v8.1.7"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-09-02T12:40:29+00:00"
-        },
-        {
-            "name": "symfony/service-contracts",
-            "version": "v3.7.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
-                "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.1",
-                "psr/container": "^1.1|^2.0",
-                "symfony/deprecation-contracts": "^2.5|^3"
-            },
-            "conflict": {
-                "ext-psr": "<1.1|>=2"
-            },
-            "type": "library",
-            "extra": {
-                "thanks": {
-                    "url": "https://github.com/symfony/contracts",
-                    "name": "symfony/contracts"
-                },
-                "branch-alias": {
-                    "dev-main": "3.7-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Contracts\\Service\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Test/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Generic abstractions related to writing services",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-07-27T15:39:01+00:00"
-        },
-        {
-            "name": "symfony/stopwatch",
-            "version": "v8.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/stopwatch.git",
-                "reference": "21c07b026905d596e8379caeb115d87aa479499d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d",
-                "reference": "21c07b026905d596e8379caeb115d87aa479499d",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1",
-                "symfony/service-contracts": "^2.5|^3"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Stopwatch\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Provides a way to profile code",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/stopwatch/tree/v8.1.0"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-05-29T05:06:50+00:00"
-        },
-        {
-            "name": "symfony/string",
-            "version": "v8.1.7",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/string.git",
-                "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c",
-                "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.4.1",
-                "symfony/polyfill-ctype": "^1.8",
-                "symfony/polyfill-intl-grapheme": "^1.33",
-                "symfony/polyfill-intl-normalizer": "^1.0",
-                "symfony/polyfill-mbstring": "^1.0"
-            },
-            "conflict": {
-                "symfony/translation-contracts": "<2.5"
-            },
-            "require-dev": {
-                "symfony/emoji": "^7.4|^8.0",
-                "symfony/http-client": "^7.4|^8.0",
-                "symfony/intl": "^7.4|^8.0",
-                "symfony/translation-contracts": "^2.5|^3.0",
-                "symfony/var-exporter": "^7.4|^8.0"
-            },
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "Resources/functions.php"
-                ],
-                "psr-4": {
-                    "Symfony\\Component\\String\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "grapheme",
-                "i18n",
-                "string",
-                "unicode",
-                "utf-8",
-                "utf8"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/string/tree/v8.1.7"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/nicolas-grekas",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2026-09-11T14:51:16+00:00"
-        },
-        {
-            "name": "theseer/tokenizer",
-            "version": "2.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/theseer/tokenizer.git",
-                "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
-                "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-tokenizer": "*",
-                "ext-xmlwriter": "*",
-                "php": "^8.1"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne@blankerts.de",
-                    "role": "Developer"
-                }
-            ],
-            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
-            "support": {
-                "issues": "https://github.com/theseer/tokenizer/issues",
-                "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/theseer",
-                    "type": "github"
-                }
-            ],
-            "time": "2025-12-08T11:19:18+00:00"
-        }
-    ],
-    "aliases": [],
-    "minimum-stability": "stable",
-    "stability-flags": {},
-    "prefer-stable": false,
-    "prefer-lowest": false,
-    "platform": {
-        "php": "^8.3"
-    },
-    "platform-dev": {},
-    "plugin-api-version": "2.9.0"
-}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index f27f3e9..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-services:
-  tests:
-    build:
-      context: .
-      dockerfile: Dockerfile
-      args:
-        PHP_VERSION: ${PHP_VERSION:-8.3}
-    network_mode: none
-    mem_limit: 512m
-    cpus: 2
-    pids_limit: 64
diff --git a/docs/api.md b/docs/api.md
index c7201b2..c60784a 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -2,6 +2,9 @@
 
 All classes are under `CoiSA\ErrorHandler`. Handler interfaces remain small so reporting, application termination, HTTP rendering and event dispatch can be composed explicitly.
 
+Complete, executable recipes for all adapters are in the [usage cookbook](examples.md)
+and inline on the [documentation site](index.html#examples).
+
 ## Lifecycle
 
 ```php
@@ -28,9 +31,9 @@ Only the active instance receives shutdown events. This includes a custom shutdo
 | `PhpErrorHandlerInterface::handlePhpError(int, string, string, int): void` | Processes a PHP error with severity, message, file and line |
 | `ShutdownHandlerInterface::handleShutdown(): void` | Handles a shutdown notification |
 
-`ErrorHandler` defaults to `ThrowErrorExceptionPhpErrorHandler` and `PhpLastErrorShutdownHandler`. The former respects the current `error_reporting()` bitmask (including `@`) and converts reportable errors to the package's `ErrorException`. The outer `ErrorHandler` delivers that exception and then resumes normal execution for recoverable warnings. The shutdown handler inspects `error_get_last()` for `E_ERROR`, `E_PARSE`, `E_CORE_ERROR` or `E_COMPILE_ERROR`; it does not turn fatal termination into recovery.
+`ErrorHandler` defaults to `ThrowErrorExceptionPhpErrorHandler` and `PhpLastErrorShutdownHandler`. The former respects the current `error_reporting()` bitmask (including `@`) and converts reportable errors to the package's `ErrorException`. The outer `ErrorHandler` delivers that exception and then resumes normal execution for recoverable warnings. The shutdown handler inspects `error_get_last()` for `E_ERROR`, `E_PARSE`, `E_CORE_ERROR` or `E_COMPILE_ERROR`; it does not turn fatal termination into recovery. An engine fatal inside another shutdown callback aborts the remaining shutdown queue, including callbacks registered after it. Re-registering with a new callback cannot recover that path.
 
-A reporter that throws is marked failed and is not retried during the same registration. Recursive calls through the same `ErrorHandler` raise `LogicException`. Direct calls propagate reporter failures; uncaught reporter failures fall back to PHP's native failure path. `unregister()` followed by `register()` begins a fresh lifecycle. The library does not choose an alternative logger or retry remote delivery.
+A reporter that throws is marked failed and is not retried during the same registration. Recursive calls through the same `ErrorHandler` raise `LogicException`. Independent unregistered direct calls propagate reporter failures without retaining the failed flag. `ErrorHandler::hasReporterFailed()` exposes failure suppression only during an active registration. The built-in middleware checks this state and propagates a reporter failure without rendering through that reporter again. Custom `ErrorHandlerInterface` implementations own their failure policy. Direct calls propagate reporter failures; uncaught reporter failures fall back to PHP's native failure path. `unregister()` followed by `register()` begins a fresh lifecycle. The library does not choose an alternative logger or retry remote delivery.
 
 ## Reporter composition and PSR-14
 
diff --git a/docs/examples.md b/docs/examples.md
new file mode 100644
index 0000000..64912b9
--- /dev/null
+++ b/docs/examples.md
@@ -0,0 +1,531 @@
+# Usage cookbook
+
+This guide describes the **unreleased next major** of `coisa/error-handler` for PHP 8.3–8.5. Every recipe below is a complete executable file in `examples/`. Run it from the repository after `composer install --no-plugins --no-scripts`. Load the matching checkout; the published 2.0 API is different. If copying a recipe into an application, adjust the Composer autoload path.
+
+The checkout's development dependencies supply Nyholm PSR-7 and Phly EventDispatcher for these examples. Applications choose their own PSR implementations; these packages are not runtime dependencies of this library. To use the same implementations in an application, install `nyholm/psr7` for HTTP and `phly/phly-event-dispatcher` for events. The full FastForward framework is unnecessary.
+
+## Built-in adapters
+
+Names are relative to `CoiSA\ErrorHandler`; use the imports in each recipe.
+
+| Adapter or contract | Responsibility | Recipe |
+| --- | --- | --- |
+| `ErrorHandler / ErrorHandlerInterface` | Process registration, callback restoration and direct delivery | [Example](#cli) |
+| `CallableThrowableHandler / ThrowableHandlerInterface` | Application-owned reporting adapter | [Example](#reporters) |
+| `ThrowableHandlerAggregate` | Ordered reporters; stop when a reporter throws | [Example](#reporters) |
+| `ThrowErrorExceptionPhpErrorHandler / PhpErrorHandlerInterface` | Default severity-aware conversion and custom PHP policy | [Example](#custom-handlers) |
+| `PhpLastErrorShutdownHandler / ShutdownHandlerInterface` | Default fatal inspection and application shutdown policy | [Example](#custom-handlers) |
+| `CallableShutdownHandler` | Adapt a shutdown callback | [Example](#custom-handlers) |
+| `DispatchThrowableHandler / DispatchErrorEventThrowableHandler` | Dispatch the original object or an ErrorEvent wrapper | [Example](#events) |
+| `ErrorEvent / ErrorEventInterface` | Access getThrowable(); diagnostic __toString() for private sinks | [Example](#events) |
+| `ThrowableCallableListener / ErrorEventCallableListener` | Adapt application event callbacks | [Example](#events) |
+| `ThrowableListenerProvider / ErrorEventListenerProvider` | Select the supported event category | [Example](#events) |
+| `LogErrorEventListener` | Send an ErrorEvent to an application PSR-3 logger | [Example](#events) |
+| `ErrorHandlerServiceProvider / ThrowableHandlerAggregateFactory` | Lazy Interop definitions and optional reporter discovery | [Example](#fast-forward) |
+| `ThrowableStreamFactory / ThrowableStreamFactoryInterface` | Capture deliberate output; generic fallback when empty | [Example](#http-rendering) |
+| `ThrowableResponseFactory / ThrowableResponseFactoryInterface` | Map integer error codes 400–599; otherwise 500 | [Example](#http-rendering) |
+| `ErrorHandlerMiddleware` | Scoped PSR-15 processing; return a PSR-7 response | [Example](#http-middleware) |
+| `ErrorHandlerFactory / ErrorHandlerMiddlewareFactory` | Fresh handler and middleware lifetimes | [Example](#scopes) |
+
+
+
+## Process bootstrap
+
+Keep one handler registered through process termination. Recoverable warnings report and continue; a successfully reported uncaught exception exits 1. This example deliberately exits 1 and writes two diagnostic lines to stderr. Do not wrap process bootstrap in a finally that unregisters before the uncaught callback runs.
+
+Run `php examples/cli.php`. [Executable source](../examples/cli.php).
+
+```php
+getMessage() . PHP_EOL);
+});
+$handler = new ErrorHandler($reporter);
+$handler->register();
+
+trigger_error('Example warning; execution continues.', E_USER_WARNING);
+throw new RuntimeException('Example uncaught exception; exit status is 1.');
+```
+
+
+
+## Interop service provider
+
+Implement ServiceProviderInterface with lazy getFactories() and getExtensions() maps. Merge the library first and the application last in one AggregateServiceProvider. FastForward resolves and caches services; registration remains an explicit application decision. This example prints Reported: FastForward provider warning and restores the earlier callbacks.
+
+Run `php examples/fast-forward.php`. [Executable source](../examples/fast-forward.php).
+
+```php
+ Application service factories.
+     */
+    public function getFactories(): array
+    {
+        return [
+            ThrowableHandlerInterface::class => static fn (): CallableThrowableHandler =>
+                new CallableThrowableHandler(static function (Throwable $error): void {
+                    printf("Reported: %s\n", $error->getMessage());
+                }),
+        ];
+    }
+
+    /**
+     * Declares no application service extensions.
+     *
+     * @return array An empty extension map.
+     */
+    public function getExtensions(): array
+    {
+        return [];
+    }
+};
+
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+/**
+ * @var ErrorHandlerInterface $errors
+ */
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+try {
+    trigger_error('FastForward provider warning', E_USER_WARNING);
+} finally {
+    $errors->unregister();
+}
+```
+
+
+
+## Callable reporters and ordered composition
+
+CallableThrowableHandler adapts an application callback. ThrowableHandlerAggregate accepts constructor arguments and attach(), delivering the same Throwable in order. It stops on the first reporter failure. Direct handleThrowable() returns to the caller and needs no registration. Independent direct calls can resume after a reporter fails.
+
+Run `php examples/reporters.php`. [Executable source](../examples/reporters.php).
+
+```php
+getMessage();
+    })
+);
+$reporters->attach(new CallableThrowableHandler(
+    static function (Throwable $error) use (&$deliveries): void {
+        $deliveries[] = 'second: ' . $error->getMessage();
+    }
+));
+
+$errors = new ErrorHandler($reporters);
+// Direct delivery needs no registration and does not terminate the process.
+$errors->handleThrowable(new RuntimeException('Synthetic failure'));
+echo implode(PHP_EOL, $deliveries) . PHP_EOL;
+```
+
+
+
+## PSR-14 dispatch, listeners and PSR-3 logging
+
+The default provider aggregate discovers EventDispatcherInterface and attaches DispatchThrowableHandler plus DispatchErrorEventThrowableHandler. The first dispatches the original Throwable; the second wraps it in ErrorEvent. Subscribe intentionally to one or both paths: a listener on each means two application notifications. ThrowableListenerProvider selects Throwable objects; ErrorEventListenerProvider selects the built-in ErrorEvent. Callable listeners receive the selected object; LogErrorEventListener records its diagnostic string at the configured PSR-3 level. The example records two notifications and one private log entry.
+
+Run `php examples/event-dispatcher.php`. [Executable source](../examples/event-dispatcher.php).
+
+```php
+ Synthetic private log records; replace with an application logger.
+     */
+    public array $records = [];
+
+    /**
+     * Records diagnostics without emitting them into an HTTP response.
+     *
+     * Untyped parameters also support applications using psr/log 1.x.
+     *
+     * @param mixed $level The PSR-3 log level.
+     * @param mixed $message The diagnostic message.
+     * @param array $context The application log context.
+     */
+    public function log($level, $message, array $context = []): void
+    {
+        $this->records[] = $level . ': ' . (string) $message;
+    }
+};
+$throwables = new ThrowableListenerProvider(
+    new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void {
+        $deliveries[] = 'throwable: ' . $error->getMessage();
+    })
+);
+$events = new ErrorEventListenerProvider(
+    new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void {
+        $deliveries[] = 'event: ' . $event->getThrowable()->getMessage();
+    })
+);
+$events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL));
+$dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events));
+$application = new ArrayServiceProvider([
+    EventDispatcherInterface::class => static fn (): EventDispatcherInterface => $dispatcher,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+try {
+    trigger_error('Synthetic warning', E_USER_WARNING);
+} finally {
+    $errors->unregister();
+}
+echo implode(PHP_EOL, $deliveries) . PHP_EOL;
+printf("Private log records: %d\n", count($logger->records));
+```
+
+
+
+## PSR-15 HTTP middleware with the provider
+
+Bind the application's PSR-17 response and stream factories and a private reporter. ErrorHandlerMiddleware returns successful downstream responses unchanged and maps a downstream Throwable to an error response. The example returns HTTP 503 with Internal Server Error, reports the failure privately, then reports a warning through the restored process handler. It prints Reported: 2. Your application owns response emission, headers and content type.
+
+Run `php examples/http-middleware.php`. [Executable source](../examples/http-middleware.php).
+
+```php
+ static fn (): ThrowableHandlerInterface => $reporter,
+    ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17,
+    StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$process = $services->get(ErrorHandlerInterface::class);
+$middleware = $services->get(ErrorHandlerMiddleware::class);
+$downstream = new class () implements RequestHandlerInterface {
+    /**
+     * Simulates an application failure with an HTTP error status.
+     *
+     * @param ServerRequestInterface $request The incoming request.
+     * @return ResponseInterface A response supplied by the error middleware.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        throw new RuntimeException('Private database diagnostic', 503);
+    }
+};
+
+$process->register();
+try {
+    $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream);
+    // The process handler is active again after the middleware scope ends.
+    trigger_error('Synthetic post-request warning', E_USER_WARNING);
+} finally {
+    $process->unregister();
+}
+printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody());
+printf("Reported: %d\n", count($reported));
+// Your HTTP framework/emitter sends the response; this example only inspects it.
+```
+
+
+
+## Controlled JSON rendering without a container
+
+Compose ThrowableStreamFactory and ThrowableResponseFactory directly. Separate private diagnostic side effects from deliberately public renderer output. This example emits only a fixed JSON error and adds application/json to the resulting response. Never echo exception messages, traces or credentials in a production renderer. To inject these factories into provider middleware, bind ThrowableResponseFactoryInterface explicitly in the application provider.
+
+Run `php examples/http-rendering.php`. [Executable source](../examples/http-rendering.php).
+
+```php
+ 'Service unavailable'], JSON_THROW_ON_ERROR);
+});
+$psr17 = new Psr17Factory();
+$streams = new ThrowableStreamFactory($psr17, new ThrowableHandlerAggregate($reporter, $publicRenderer));
+$responses = new ThrowableResponseFactory($streams, $psr17);
+$response = $responses->createResponseFromThrowable(new RuntimeException('Private credentials', 503))
+    ->withHeader('Content-Type', 'application/json');
+
+printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type'));
+echo (string) $response->getBody() . PHP_EOL;
+printf("Private reports: %d\n", count($privateDiagnostics));
+```
+
+
+
+## Application PHP-error and shutdown handlers
+
+ErrorHandler defaults to ThrowErrorExceptionPhpErrorHandler and PhpLastErrorShutdownHandler. The first honors error_reporting() and suppression, converts PHP errors and lets the outer handler report them; recoverable warnings continue. The second reports observable E_ERROR, E_PARSE, E_CORE_ERROR and E_COMPILE_ERROR at shutdown, without recovery. Override PhpErrorHandlerInterface to change conversion, or ShutdownHandlerInterface with CallableShutdownHandler for application notifications. This example simulates shutdown directly and unregisters before real termination: one PHP error and one shutdown notification.
+
+Run `php examples/custom-handlers.php`. [Executable source](../examples/custom-handlers.php).
+
+```php
+ Reportable PHP messages received by this application handler.
+     */
+    public array $messages = [];
+
+    /**
+     * Records reportable PHP errors without converting them to exceptions.
+     *
+     * @param int $code The PHP severity.
+     * @param string $message The PHP message.
+     * @param string $filename The source filename.
+     * @param int $line The source line.
+     */
+    public function handlePhpError(int $code, string $message, string $filename, int $line): void
+    {
+        if (($code & error_reporting()) !== 0) {
+            $this->messages[] = $message;
+        }
+    }
+};
+$shutdowns = 0;
+$shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void {
+    ++$shutdowns;
+});
+$application = new ArrayServiceProvider([
+    ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface =>
+        new CallableThrowableHandler(static function (Throwable $error): void {
+            error_log($error->getMessage());
+        }),
+    PhpErrorHandlerInterface::class => static fn (): PhpErrorHandlerInterface => $phpErrors,
+    ShutdownHandlerInterface::class => static fn (): ShutdownHandlerInterface => $shutdown,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+try {
+    trigger_error('Application-owned warning', E_USER_WARNING);
+    $errors->handleShutdown(); // Simulate a notification without terminating the process.
+} finally {
+    $errors->unregister();
+}
+printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns);
+```
+
+
+
+## Fresh handlers and nested middleware
+
+FastForward caches get(ErrorHandlerInterface) and get(ErrorHandlerMiddleware). ErrorHandlerMiddlewareFactory invokes ErrorHandlerFactory to obtain a dedicated handler instead of sharing the cached process handler. Otherwise its finally could unregister the process handler. Resolve the callable factories and invoke each time you need a distinct nested scope. Plain autowiring does not provide that lifetime. This example nests two fresh middleware instances beneath a registered process handler and reports twice. PHP callbacks remain process-wide; this supports sequential nesting, not concurrent request or fiber isolation.
+
+Run `php examples/scopes.php`. [Executable source](../examples/scopes.php).
+
+```php
+ static fn (): ThrowableHandlerInterface => $reporter,
+    ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17,
+    StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$makeHandler = $services->get(ErrorHandlerFactory::class);
+$first = $makeHandler($services);
+$second = $makeHandler($services);
+$makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class);
+$outer = $makeMiddleware($services);
+$inner = $makeMiddleware($services);
+$downstream = new class () implements RequestHandlerInterface {
+    /**
+     * Throws a synthetic downstream failure for the inner scope.
+     *
+     * @param ServerRequestInterface $request The synthetic request.
+     * @return ResponseInterface The response produced by the inner middleware.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        throw new RuntimeException('Nested failure');
+    }
+};
+$next = new class ($inner, $downstream) implements RequestHandlerInterface {
+    /**
+     * Stores the independently constructed inner middleware and application handler.
+     *
+     * @param MiddlewareInterface $inner The inner error middleware.
+     * @param RequestHandlerInterface $downstream The application request handler.
+     */
+    public function __construct(private MiddlewareInterface $inner, private RequestHandlerInterface $downstream)
+    {
+    }
+
+    /**
+     * Enters the inner scope while the outer scope remains registered.
+     *
+     * @param ServerRequestInterface $request The incoming request.
+     * @return ResponseInterface The inner response.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        return $this->inner->process($request, $this->downstream);
+    }
+};
+$process = $services->get(ErrorHandlerInterface::class);
+$process->register();
+try {
+    $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next);
+    trigger_error('Process handler restored', E_USER_WARNING);
+} finally {
+    $process->unregister();
+}
+printf(
+    "Fresh handlers: %s | Fresh middleware: %s\n",
+    $first !== $second ? 'yes' : 'no',
+    $outer !== $inner ? 'yes' : 'no'
+);
+printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported);
+```
+
+## Failure and lifetime boundaries
+
+Unregister in reverse registration order and use distinct handlers for nested scopes. A second register on the same active instance is a no-op. PHP retains one shutdown callback per instance until process exit; inactive callbacks do nothing. A fatal inside another shutdown callback can abort the remaining shutdown queue, even for a fresh handler; it is outside the reporting guarantee.
+
+A reporter failure during a registration suppresses further delivery for that lifecycle. `ErrorHandler::hasReporterFailed()` exposes this state for integrations. Built-in middleware propagates that failure and restores callbacks instead of passing it to the renderer and retrying the reporter. A custom implementation of `ErrorHandlerInterface` owns its own failure policy. Independent unregistered direct calls do not retain the failed flag.
+
+Reporter output captured by `ThrowableStreamFactory` becomes the public response body. Empty output becomes `Internal Server Error`; `0` remains a valid body. Renderers must balance removable buffers and never close caller buffers. A renderer or response-factory failure propagates after middleware cleanup; no HTTP response is emitted automatically.
+
+Use explicit required reporter/renderer interface bindings and application-last merged definitions. FastForward can retry construction or fall back to autowiring concrete classes; do not infer successful delivery from `has()` or assume exception identity survives container resolution. See [provider details](fast-forward.md), [API contracts](api.md), [migration](migration.md) and [validation](testing.md).
diff --git a/docs/fast-forward.md b/docs/fast-forward.md
index d0cec3c..71f610c 100644
--- a/docs/fast-forward.md
+++ b/docs/fast-forward.md
@@ -28,15 +28,59 @@ It reports one warning and restores the prior PHP callbacks. Merge the library
 and application definitions into one provider, with the application last:
 
 ```php
+ Application service factories.
+     */
+    public function getFactories(): array
+    {
+        return [
+            ThrowableHandlerInterface::class => static fn (): CallableThrowableHandler =>
+                new CallableThrowableHandler(static function (Throwable $error): void {
+                    printf("Reported: %s\n", $error->getMessage());
+                }),
+        ];
+    }
+
+    /**
+     * Declares no application service extensions.
+     *
+     * @return array An empty extension map.
+     */
+    public function getExtensions(): array
+    {
+        return [];
+    }
+};
+
 $services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+/**
+ * @var ErrorHandlerInterface $errors
+ */
 $errors = $services->get(ErrorHandlerInterface::class);
 $errors->register();
+try {
+    trigger_error('FastForward provider warning', E_USER_WARNING);
+} finally {
+    $errors->unregister();
+}
 ```
 
 For a process-wide handler, keep it registered through termination. For a scoped
@@ -119,6 +163,14 @@ constructor-only factories have been removed. Provider factories all live under
 `ServiceProvider\Factory`; see [migration](migration.md). Provider consumers must
 support constructor autowiring, as FastForward's `container()` does.
 
+## Runnable integration recipes
+
+The [usage cookbook](examples.md) exposes complete examples for every built-in adapter:
+PSR-14 dispatch and PSR-3 logging, private HTTP reporting, controlled JSON rendering,
+custom PHP/shutdown handlers, direct reporter composition and nested scopes.
+Each recipe is also available inline in the [documentation site](index.html#examples)
+and verified against its executable source in CI.
+
 ## HTTP scopes and fresh instances
 
 Supply `Psr\Http\Message\ResponseFactoryInterface` and
@@ -126,6 +178,13 @@ Supply `Psr\Http\Message\ResponseFactoryInterface` and
 Nyholm PSR-7 factory. The normal middleware service can process sequential
 requests and restores an already registered process handler after each one.
 
+The middleware factory deliberately does not resolve the cached `ErrorHandler`
+process service. Its `finally` must unregister only its own handler; sharing the
+process instance would deactivate the application's bootstrap registration.
+`ErrorHandlerFactory` also applies optional PHP/shutdown bindings consistently.
+Autowiring selects constructor dependencies, but does not establish a fresh
+lifecycle or make a cached instance independent.
+
 FastForward caches `get()` results. For nested scopes, resolve a factory object
 and invoke it for each middleware instance:
 
@@ -162,3 +221,8 @@ have already been made.
 Sources: [FastForward Container 1.6.0](https://github.com/php-fast-forward/container/tree/v1.6.0),
 [provider contract](https://github.com/container-interop/service-provider/tree/v0.4.1),
 [factory lifetime pattern](https://github.com/php-fast-forward/container/blob/v1.6.0/docs/advanced/special-patterns.rst).
+
+If the built-in handler's reporter fails during a request warning, middleware
+propagates that failure and restores callbacks without rendering it through the
+reporter again. Ordinary downstream failures still use the configured response
+factory. See [failure boundaries](examples.md#failure-and-lifetime-boundaries).
diff --git a/docs/index.html b/docs/index.html
index fbc673c..725b79f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -6,6 +6,7 @@
   
   CoiSA Error Handler — Make failure observable
   
@@ -13,7 +14,7 @@
 
 
{!}CoiSA / Error HandlerView source ↗
- +
A small PHP library. An explicit failure contract.
@@ -25,13 +26,13 @@

Make failure
observable.

01 / Reproduce it

Install the development checkout.

-

Use PHP 8.5 and Composer 2 for the checked-in dependency lock. Check out the pull-request branch when reviewing unmerged changes.

+

Use PHP 8.3–8.5 and Composer 2. This library resolves development dependencies for your runtime; applications keep their own Composer locks. Check out the pull-request branch when reviewing unmerged changes.

TERMINAL · DEVELOPMENT CHECKOUT
git clone https://github.com/coisa/error-handler.git
 cd error-handler
 composer install --no-plugins --no-scripts
 composer --no-plugins check
 php examples/cli.php
-

The example reports a warning, then an uncaught exception, and deliberately exits with status 1. On PHP 8.3 or 8.4, use composer update --no-plugins --no-scripts first to resolve compatible development tools.

+

The example reports a warning, then an uncaught exception, and deliberately exits with status 1. Run composer update --no-plugins --no-scripts to refresh local development dependencies.

composer require coisa/error-handler installs the published Packagist release; it does not select this unreleased work.

@@ -50,22 +51,461 @@

Make failure
observable.

$errors->register();

Keep diagnostic output in an appropriate private sink. A reporter that emits nothing produces the generic HTTP body Internal Server Error.

+
Usage cookbook

Choose an integration. Run the complete example.

These examples target the unreleased next major. The checkout includes Nyholm PSR-7 and Phly EventDispatcher as development dependencies. Applications own their PSR-17, PSR-14 and PSR-3 implementations. Adjust the autoload path when copying a file into an application.

Install the example implementations in an application with composer require nyholm/psr7 phly/phly-event-dispatcher when using both HTTP and event recipes.

Built-in adapterResponsibilityUsage
ErrorHandler / ErrorHandlerInterfaceProcess registration, callback restoration and direct deliveryExample
CallableThrowableHandler / ThrowableHandlerInterfaceApplication-owned reporting adapterExample
ThrowableHandlerAggregateOrdered reporters; stop when a reporter throwsExample
ThrowErrorExceptionPhpErrorHandler / PhpErrorHandlerInterfaceDefault severity-aware conversion and custom PHP policyExample
PhpLastErrorShutdownHandler / ShutdownHandlerInterfaceDefault fatal inspection and application shutdown policyExample
CallableShutdownHandlerAdapt a shutdown callbackExample
DispatchThrowableHandler / DispatchErrorEventThrowableHandlerDispatch the original object or an ErrorEvent wrapperExample
ErrorEvent / ErrorEventInterfaceAccess getThrowable(); diagnostic __toString() for private sinksExample
ThrowableCallableListener / ErrorEventCallableListenerAdapt application event callbacksExample
ThrowableListenerProvider / ErrorEventListenerProviderSelect the supported event categoryExample
LogErrorEventListenerSend an ErrorEvent to an application PSR-3 loggerExample
ErrorHandlerServiceProvider / ThrowableHandlerAggregateFactoryLazy Interop definitions and optional reporter discoveryExample
ThrowableStreamFactory / ThrowableStreamFactoryInterfaceCapture deliberate output; generic fallback when emptyExample
ThrowableResponseFactory / ThrowableResponseFactoryInterfaceMap integer error codes 400–599; otherwise 500Example
ErrorHandlerMiddlewareScoped PSR-15 processing; return a PSR-7 responseExample
ErrorHandlerFactory / ErrorHandlerMiddlewareFactoryFresh handler and middleware lifetimesExample
+
+
Executable recipe · examples/cli.php

Process bootstrap

+

Keep one handler registered through process termination. Recoverable warnings report and continue; a successfully reported uncaught exception exits 1. This example deliberately exits 1 and writes two diagnostic lines to stderr. Do not wrap process bootstrap in a finally that unregisters before the uncaught callback runs.

+
RUN FROM THE CHECKOUT
php examples/cli.php
+
Complete PHP example
examples/cli.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandler;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$reporter = new CallableThrowableHandler(static function (Throwable $error): void {
+    fwrite(STDERR, get_class($error) . ': ' . $error->getMessage() . PHP_EOL);
+});
+$handler = new ErrorHandler($reporter);
+$handler->register();
+
+trigger_error('Example warning; execution continues.', E_USER_WARNING);
+throw new RuntimeException('Example uncaught exception; exit status is 1.');
+
-
Container integration

Declare services. Choose when to register.

-

The Interop service provider follows FastForward's callable factory definitions. Resolve the process handler through ErrorHandlerInterface, configure your reporter in an application provider, and explicitly register at bootstrap.

-

FastForward Container is required at runtime and replaces the standalone container. Constructor autowiring resolves plain adapters; three lifecycle factories preserve reporter composition and fresh scopes. The full framework is not required. Bind required custom renderers through their interfaces to prevent construction failures from falling back to autowiring.

-
RUN THE COMPLETE PROVIDER EXAMPLE
php examples/fast-forward.php
- +
Executable recipe · examples/fast-forward.php

Interop service provider

+

Implement ServiceProviderInterface with lazy getFactories() and getExtensions() maps. Merge the library first and the application last in one AggregateServiceProvider. FastForward resolves and caches services; registration remains an explicit application decision. This example prints Reported: FastForward provider warning and restores the earlier callbacks.

+
RUN FROM THE CHECKOUT
php examples/fast-forward.php
+
Complete PHP example
examples/fast-forward.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandlerInterface;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use Interop\Container\ServiceProviderInterface;
+
+use function FastForward\Container\container;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$application = new class () implements ServiceProviderInterface {
+    /**
+     * Supplies application service definitions for the integration fixture.
+     *
+     * @return array<class-string, callable> Application service factories.
+     */
+    public function getFactories(): array
+    {
+        return [
+            ThrowableHandlerInterface::class => static fn (): CallableThrowableHandler =>
+                new CallableThrowableHandler(static function (Throwable $error): void {
+                    printf("Reported: %s\n", $error->getMessage());
+                }),
+        ];
+    }
+
+    /**
+     * Declares no application service extensions.
+     *
+     * @return array<string, callable> An empty extension map.
+     */
+    public function getExtensions(): array
+    {
+        return [];
+    }
+};
+
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+/**
+ * @var ErrorHandlerInterface $errors
+ */
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+try {
+    trigger_error('FastForward provider warning', E_USER_WARNING);
+} finally {
+    $errors->unregister();
+}
+
+
+
Executable recipe · examples/reporters.php

Callable reporters and ordered composition

+

CallableThrowableHandler adapts an application callback. ThrowableHandlerAggregate accepts constructor arguments and attach(), delivering the same Throwable in order. It stops on the first reporter failure. Direct handleThrowable() returns to the caller and needs no registration. Independent direct calls can resume after a reporter fails.

+
RUN FROM THE CHECKOUT
php examples/reporters.php
+
Complete PHP example
examples/reporters.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandler;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$deliveries = [];
+$reporters = new ThrowableHandlerAggregate(
+    new CallableThrowableHandler(static function (Throwable $error) use (&$deliveries): void {
+        $deliveries[] = 'first: ' . $error->getMessage();
+    })
+);
+$reporters->attach(new CallableThrowableHandler(
+    static function (Throwable $error) use (&$deliveries): void {
+        $deliveries[] = 'second: ' . $error->getMessage();
+    }
+));
+
+$errors = new ErrorHandler($reporters);
+// Direct delivery needs no registration and does not terminate the process.
+$errors->handleThrowable(new RuntimeException('Synthetic failure'));
+echo implode(PHP_EOL, $deliveries) . PHP_EOL;
+
+
+
Executable recipe · examples/event-dispatcher.php

PSR-14 dispatch, listeners and PSR-3 logging

+

The default provider aggregate discovers EventDispatcherInterface and attaches DispatchThrowableHandler plus DispatchErrorEventThrowableHandler. The first dispatches the original Throwable; the second wraps it in ErrorEvent. Subscribe intentionally to one or both paths: a listener on each means two application notifications. ThrowableListenerProvider selects Throwable objects; ErrorEventListenerProvider selects the built-in ErrorEvent. Callable listeners receive the selected object; LogErrorEventListener records its diagnostic string at the configured PSR-3 level. The example records two notifications and one private log entry.

+
RUN FROM THE CHECKOUT
php examples/event-dispatcher.php
+
Complete PHP example
examples/event-dispatcher.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandlerInterface;
+use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEventInterface;
+use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener;
+use CoiSA\ErrorHandler\EventDispatcher\Listener\LogErrorEventListener;
+use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener;
+use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider;
+use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use FastForward\Container\ServiceProvider\ArrayServiceProvider;
+use Phly\EventDispatcher\EventDispatcher;
+use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate;
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Psr\Log\AbstractLogger;
+use Psr\Log\LogLevel;
+
+use function FastForward\Container\container;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$deliveries = [];
+$logger = new class () extends AbstractLogger {
+    /**
+     * @var list<string> Synthetic private log records; replace with an application logger.
+     */
+    public array $records = [];
+
+    /**
+     * Records diagnostics without emitting them into an HTTP response.
+     *
+     * Untyped parameters also support applications using psr/log 1.x.
+     *
+     * @param mixed $level The PSR-3 log level.
+     * @param mixed $message The diagnostic message.
+     * @param array<string, mixed> $context The application log context.
+     */
+    public function log($level, $message, array $context = []): void
+    {
+        $this->records[] = $level . ': ' . (string) $message;
+    }
+};
+$throwables = new ThrowableListenerProvider(
+    new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void {
+        $deliveries[] = 'throwable: ' . $error->getMessage();
+    })
+);
+$events = new ErrorEventListenerProvider(
+    new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void {
+        $deliveries[] = 'event: ' . $event->getThrowable()->getMessage();
+    })
+);
+$events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL));
+$dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events));
+$application = new ArrayServiceProvider([
+    EventDispatcherInterface::class => static fn (): EventDispatcherInterface => $dispatcher,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+try {
+    trigger_error('Synthetic warning', E_USER_WARNING);
+} finally {
+    $errors->unregister();
+}
+echo implode(PHP_EOL, $deliveries) . PHP_EOL;
+printf("Private log records: %d\n", count($logger->records));
+
+
+
Executable recipe · examples/http-middleware.php

PSR-15 HTTP middleware with the provider

+

Bind the application's PSR-17 response and stream factories and a private reporter. ErrorHandlerMiddleware returns successful downstream responses unchanged and maps a downstream Throwable to an error response. The example returns HTTP 503 with Internal Server Error, reports the failure privately, then reports a warning through the restored process handler. It prints Reported: 2. Your application owns response emission, headers and content type.

+
RUN FROM THE CHECKOUT
php examples/http-middleware.php
+
Complete PHP example
examples/http-middleware.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandlerInterface;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
+use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use FastForward\Container\ServiceProvider\ArrayServiceProvider;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+use function FastForward\Container\container;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$reported = [];
+$psr17 = new Psr17Factory();
+$reporter = new CallableThrowableHandler(static function (Throwable $error) use (&$reported): void {
+    $reported[] = $error; // Replace with a private logger; do not echo diagnostics.
+});
+$application = new ArrayServiceProvider([
+    ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter,
+    ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17,
+    StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$process = $services->get(ErrorHandlerInterface::class);
+$middleware = $services->get(ErrorHandlerMiddleware::class);
+$downstream = new class () implements RequestHandlerInterface {
+    /**
+     * Simulates an application failure with an HTTP error status.
+     *
+     * @param ServerRequestInterface $request The incoming request.
+     * @return ResponseInterface A response supplied by the error middleware.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        throw new RuntimeException('Private database diagnostic', 503);
+    }
+};
+
+$process->register();
+try {
+    $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream);
+    // The process handler is active again after the middleware scope ends.
+    trigger_error('Synthetic post-request warning', E_USER_WARNING);
+} finally {
+    $process->unregister();
+}
+printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody());
+printf("Reported: %d\n", count($reported));
+// Your HTTP framework/emitter sends the response; this example only inspects it.
+
+
+
Executable recipe · examples/http-rendering.php

Controlled JSON rendering without a container

+

Compose ThrowableStreamFactory and ThrowableResponseFactory directly. Separate private diagnostic side effects from deliberately public renderer output. This example emits only a fixed JSON error and adds application/json to the resulting response. Never echo exception messages, traces or credentials in a production renderer. To inject these factories into provider middleware, bind ThrowableResponseFactoryInterface explicitly in the application provider.

+
RUN FROM THE CHECKOUT
php examples/http-rendering.php
+
Complete PHP example
examples/http-rendering.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;
+use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
+use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
+use Nyholm\Psr7\Factory\Psr17Factory;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$privateDiagnostics = [];
+$reporter = new CallableThrowableHandler(
+    static function (Throwable $error) use (&$privateDiagnostics): void {
+        $privateDiagnostics[] = $error; // Replace with a private logger.
+    }
+);
+$publicRenderer = new CallableThrowableHandler(static function (): void {
+    echo json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR);
+});
+$psr17 = new Psr17Factory();
+$streams = new ThrowableStreamFactory($psr17, new ThrowableHandlerAggregate($reporter, $publicRenderer));
+$responses = new ThrowableResponseFactory($streams, $psr17);
+$response = $responses->createResponseFromThrowable(new RuntimeException('Private credentials', 503))
+    ->withHeader('Content-Type', 'application/json');
+
+printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type'));
+echo (string) $response->getBody() . PHP_EOL;
+printf("Private reports: %d\n", count($privateDiagnostics));
+
+
+
Executable recipe · examples/custom-handlers.php

Application PHP-error and shutdown handlers

+

ErrorHandler defaults to ThrowErrorExceptionPhpErrorHandler and PhpLastErrorShutdownHandler. The first honors error_reporting() and suppression, converts PHP errors and lets the outer handler report them; recoverable warnings continue. The second reports observable E_ERROR, E_PARSE, E_CORE_ERROR and E_COMPILE_ERROR at shutdown, without recovery. Override PhpErrorHandlerInterface to change conversion, or ShutdownHandlerInterface with CallableShutdownHandler for application notifications. This example simulates shutdown directly and unregisters before real termination: one PHP error and one shutdown notification.

+
RUN FROM THE CHECKOUT
php examples/custom-handlers.php
+
Complete PHP example
examples/custom-handlers.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandlerInterface;
+use CoiSA\ErrorHandler\Handler\CallableShutdownHandler;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface;
+use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface;
+use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use FastForward\Container\ServiceProvider\ArrayServiceProvider;
+
+use function FastForward\Container\container;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$phpErrors = new class () implements PhpErrorHandlerInterface {
+    /**
+     * @var list<string> Reportable PHP messages received by this application handler.
+     */
+    public array $messages = [];
+
+    /**
+     * Records reportable PHP errors without converting them to exceptions.
+     *
+     * @param int $code The PHP severity.
+     * @param string $message The PHP message.
+     * @param string $filename The source filename.
+     * @param int $line The source line.
+     */
+    public function handlePhpError(int $code, string $message, string $filename, int $line): void
+    {
+        if (($code & error_reporting()) !== 0) {
+            $this->messages[] = $message;
+        }
+    }
+};
+$shutdowns = 0;
+$shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void {
+    ++$shutdowns;
+});
+$application = new ArrayServiceProvider([
+    ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface =>
+        new CallableThrowableHandler(static function (Throwable $error): void {
+            error_log($error->getMessage());
+        }),
+    PhpErrorHandlerInterface::class => static fn (): PhpErrorHandlerInterface => $phpErrors,
+    ShutdownHandlerInterface::class => static fn (): ShutdownHandlerInterface => $shutdown,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$errors = $services->get(ErrorHandlerInterface::class);
+$errors->register();
+try {
+    trigger_error('Application-owned warning', E_USER_WARNING);
+    $errors->handleShutdown(); // Simulate a notification without terminating the process.
+} finally {
+    $errors->unregister();
+}
+printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns);
+
+
+
Executable recipe · examples/scopes.php

Fresh handlers and nested middleware

+

FastForward caches get(ErrorHandlerInterface) and get(ErrorHandlerMiddleware). ErrorHandlerMiddlewareFactory invokes ErrorHandlerFactory to obtain a dedicated handler instead of sharing the cached process handler. Otherwise its finally could unregister the process handler. Resolve the callable factories and invoke each time you need a distinct nested scope. Plain autowiring does not provide that lifetime. This example nests two fresh middleware instances beneath a registered process handler and reports twice. PHP callbacks remain process-wide; this supports sequential nesting, not concurrent request or fiber isolation.

+
RUN FROM THE CHECKOUT
php examples/scopes.php
+
Complete PHP example
examples/scopes.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\ErrorHandlerInterface;
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
+use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory;
+use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
+use FastForward\Container\ServiceProvider\AggregateServiceProvider;
+use FastForward\Container\ServiceProvider\ArrayServiceProvider;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Psr\Http\Server\MiddlewareInterface;
+
+use function FastForward\Container\container;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$reported = 0;
+$psr17 = new Psr17Factory();
+$reporter = new CallableThrowableHandler(static function () use (&$reported): void {
+    ++$reported;
+});
+$application = new ArrayServiceProvider([
+    ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter,
+    ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17,
+    StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17,
+]);
+$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application));
+$makeHandler = $services->get(ErrorHandlerFactory::class);
+$first = $makeHandler($services);
+$second = $makeHandler($services);
+$makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class);
+$outer = $makeMiddleware($services);
+$inner = $makeMiddleware($services);
+$downstream = new class () implements RequestHandlerInterface {
+    /**
+     * Throws a synthetic downstream failure for the inner scope.
+     *
+     * @param ServerRequestInterface $request The synthetic request.
+     * @return ResponseInterface The response produced by the inner middleware.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        throw new RuntimeException('Nested failure');
+    }
+};
+$next = new class ($inner, $downstream) implements RequestHandlerInterface {
+    /**
+     * Stores the independently constructed inner middleware and application handler.
+     *
+     * @param MiddlewareInterface $inner The inner error middleware.
+     * @param RequestHandlerInterface $downstream The application request handler.
+     */
+    public function __construct(private MiddlewareInterface $inner, private RequestHandlerInterface $downstream)
+    {
+    }
+
+    /**
+     * Enters the inner scope while the outer scope remains registered.
+     *
+     * @param ServerRequestInterface $request The incoming request.
+     * @return ResponseInterface The inner response.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        return $this->inner->process($request, $this->downstream);
+    }
+};
+$process = $services->get(ErrorHandlerInterface::class);
+$process->register();
+try {
+    $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next);
+    trigger_error('Process handler restored', E_USER_WARNING);
+} finally {
+    $process->unregister();
+}
+printf(
+    "Fresh handlers: %s | Fresh middleware: %s\n",
+    $first !== $second ? 'yes' : 'no',
+    $outer !== $inner ? 'yes' : 'no'
+);
+printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported);
03 / Observable behavior

Know what each boundary does.

-
Input or operationResult
Reportable warningOne error exception; execution continues.
@ or an excluded severityThe default reporter does not receive the warning.
Uncaught throwableReport the original object; exit 1 when reporting returns.
Direct handleThrowable()Report and return; the caller controls execution.
Nested registrationsUnwind in reverse order; only the active handler reports shutdown.
Reporter failure or recursionStop retrying that reporter and propagate the failure.
HTTP error responsePreserve integer codes 400–599; otherwise use 500. No exception text in the reason phrase.
+
Input or operationResult
Reportable warningOne error exception; execution continues.
@ or an excluded severityThe default reporter does not receive the warning.
Uncaught throwableReport the original object; exit 1 when reporting returns.
Direct handleThrowable()Report and return; the caller controls execution.
Nested registrationsUnwind in reverse order; only the active handler reports shutdown.
Reporter failure or recursionDuring registration, stop retrying and propagate. Independent direct deliveries remain usable; middleware does not re-render a failed reporter.
HTTP error responsePreserve integer codes 400–599; otherwise use 500. No exception text in the reason phrase.

Read the complete API and composition guide ↗

04 / Exercise the failure path

A test should survive the crash.

-
29ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion, reporter reset and renderer/cleanup failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

-

The runner checks delivery counts, ordering, throwable classes, stderr and exact exit status. Separate integration tests exercise PSR composition and real HTTP 500/503 responses through a loopback-only server.

+
31ISOLATED SCENARIOS

Real warnings, throwable failures, compile fatals, allocation failure, custom shutdown, reporter recursion, reporter reset and renderer/cleanup failures run in child processes. Each child has a five-second deadline, a 16 MiB memory limit and a 256 KiB output cap.

+

The runner checks delivery counts, ordering, throwable classes, stderr and exact exit status. Two additional scenarios demonstrate that a fatal inside another shutdown callback aborts both fresh and reused handler queues. Separate integration tests exercise PSR composition and real HTTP 500/503 responses through a loopback-only server.

RUN THE EVIDENCE YOURSELF
php tests/Fire/run.php
 composer --no-plugins test
 php tests/Http/run.php
@@ -73,7 +513,7 @@

Make failure
observable.

05 / Move deliberately

Upgrade as a major release.

-
  • Raise the runtime to PHP 8.3 or newer.
  • Expect failure exit status 1 after a reported uncaught exception.
  • Unregister handlers in reverse order; use distinct instances for nested middleware.
  • Review the generic HTTP fallback and standard reason phrases.
  • Replace the old PHP 7.2 development container with the current Compose test service.
+
  • Raise the runtime to PHP 8.3 or newer.
  • Expect failure exit status 1 after a reported uncaught exception.
  • Unregister handlers in reverse order; use distinct instances for nested middleware.
  • Review the generic HTTP fallback and standard reason phrases.
  • Use native Composer checks and the PHP 8.3–8.5 CI matrix; the Docker/Compose setup is removed.

Read the 2.0 migration guide ↗

diff --git a/docs/migration.md b/docs/migration.md index 22efa94..244cb05 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -11,12 +11,12 @@ The Interop provider is the single container integration entrypoint. The Laminas `ConfigProvider` adapter and standalone `ErrorHandlerContainer` have been removed in favor of FastForward Container with this provider. -1. Upgrade the application to PHP 8.3, 8.4 or 8.5. Resolve Composer dependencies again. The checked-in development lock targets PHP 8.5; developers on 8.3/8.4 use `composer update --no-plugins --no-scripts` to resolve compatible tools. Consumers resolve their own lock as usual. +1. Upgrade the application to PHP 8.3, 8.4 or 8.5. Resolve Composer dependencies again. The library no longer commits `composer.lock`; resolve development dependencies for your PHP version with `composer update --no-plugins --no-scripts`. Applications keep their own locks. 2. Expect exit status **1** after a globally uncaught `Throwable` is reported successfully. Version 2 could exit with status 0. A reporter that explicitly terminates the process still controls its own exit code. Direct `handleThrowable()` calls do not exit. 3. Pair `register()` and `unregister()` in reverse order. A repeated `register()` on the same instance is idempotent, not reference counted. Out-of-order unregistration now throws `LogicException` instead of silently removing someone else's callback. Use separate instances for nested middleware and do not pre-register the middleware's own instance. 4. A reporter must not call its own `ErrorHandler` recursively. After a reporter throws, that instance stops retrying it until it is unregistered and registered again. The failure propagates to the caller or PHP; it is not silently swallowed. Only the active registration receives shutdown notifications, preventing duplicates across nested handlers. 5. Review custom HTTP rendering. Empty output now produces `Internal Server Error`, instead of a stringified exception and stack trace. The string `0` is preserved. Exception messages no longer become HTTP reason phrases. Codes 400–599 map to their matching status; other integer codes and string SQLSTATE codes map to 500. -6. If you used the old PHP 7.2 Docker setup, replace the two-service invocation with `docker compose run --rm --build tests`. It defaults to PHP 8.3 and resolves dependencies in the image, without using your host `vendor/`. +6. The Docker/Compose development setup is removed. Run the native Composer checks; CI supplies the PHP 8.3–8.5 dependency matrix. No application runtime image is supplied. 7. Replace `new Container\ErrorHandlerContainer($parent)` with `FastForward\Container\container(new ErrorHandlerServiceProvider(), $parent)` @@ -54,3 +54,9 @@ floor, required FastForward dependency, removed container/factories, CLI exit semantics, lifecycle rejection and HTTP output defaults warrant a major release. Before upgrading a real application, run the [documented checks](testing.md) and application-level tests for the actual reporter, middleware stack and PHP SAPI. These isolated fixtures do not establish reliability of third-party loggers, remote services or production infrastructure. + +For the built-in handler, failed-reporter suppression applies only during a +registered lifecycle. Independent direct calls remain usable after a reporter +failure. Middleware propagates a failed reporter instead of retrying it during +response rendering. Custom lifecycle implementations retain their own policy. +See the [complete usage recipes](examples.md) and [release workflow](releases.md). diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..2e9b6d8 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,90 @@ +# Release maintenance + +This workflow prepares the unreleased major after 2.0.0. Adding it to PR #3 does +not merge that PR, create a tag, publish a release or deploy documentation. + +## Contributor flow + +Use Node 24 and npm 10.9+ for release maintenance: + +```sh +npm ci --ignore-scripts +npm run changeset +npm run changeset:status +npm run test:release +``` + +Select `@coisa/error-handler`, choose a bump and write an English summary. Commit +the generated `.changeset/*.md` with the implementation. The first pending note +contains this branch's migration and lifecycle changes and requests a **major** +bump from the published 2.0.0 baseline to 3.0.0. Additive APIs normally use minor; +fixes, documentation and dependency maintenance normally use patch. Changesets +combines pending notes using the highest applicable bump. + +The npm package is **private development tooling**. Its name maps to the Composer +package `coisa/error-handler`; its version tracks the last stable Git release. +Do not add a `version` field to `composer.json`: Composer obtains versions from +Git tags. Node is unnecessary for installing or using the PHP library. No npm +publication, npm credential or additional PHP runtime package is involved. + +The Release tooling workflow checks new/modified notes against the PR base and +rejects an empty body or missing package bump. The bot version PR is exempt +because Changesets consumes those notes. Dependabot PRs need a maintainer-added +note too; this intentionally keeps the initial workflow small and reviewable. +Use the same docs, examples, PHPDoc and consumer-skill requirements for a feature +PR as for any other public API change. + +## Version PR and publication + +1. A merge into `master` runs the Release workflow. On its initial adoption, + introducing `package.json` establishes the 2.0.0 baseline and publishes nothing. +2. Pending notes create or update the bot branch `changeset-release/master`. + `npm run version:packages` updates `package.json`, `package-lock.json` and + `CHANGELOG.md`, and consumes the notes. It leaves PHP dependency versions alone. +3. Review that version PR, generated notes and exact-head CI. The workflow + explicitly dispatches PHP CI on the generated branch because PRs created with + `GITHUB_TOKEN` do not automatically trigger `pull_request` workflows. +4. Merging the reviewed **bot version PR** authorizes publication. The following + push verifies the changed version, same-repository release branch, bot author, + merged status and exact merge SHA through GitHub's API. +5. The workflow creates the stable tag, for example `3.0.0`, and a GitHub release + containing only that version's changelog section. The tag format matches the + existing `2.0.0` and `1.0.0` tags. An existing matching tag/release is reusable; + a tag pointing elsewhere, draft conflict or API failure stops publication. + +The implementation supports stable versions. Prerelease channels need a separate +design before adoption. Do not change the manifest version in ordinary feature +PRs: publication requires the reviewed bot PR and its matching notes. A workflow +retry can complete a release after a tag was created, without moving that tag. + +GitHub Releases and Packagist are separate delivery steps. Packagist discovers Git +tags through the package's configured GitHub hook or a maintainer-triggered +update. Verify that integration in the package settings; this PR neither changes +Packagist settings nor claims a successful registry update. + +## Repository setup and permissions + +An owner must enable **Actions → General → Allow GitHub Actions to create and +approve pull requests**. Keep the version PR subject to review and required +checks. No auto-merge is configured. The default `GITHUB_TOKEN` is sufficient: +the release job has `contents: write` for tags/commits/releases, +`pull-requests: write` for its version PR and `actions: write` only to dispatch CI. +PR verification remains read-only and never uses `pull_request_target`. + +Actions are pinned to immutable commits, npm dependencies are locked, and +installation uses `npm ci --ignore-scripts`. Weekly Dependabot proposals cover +Composer, GitHub Actions and the npm release tool. PHP validation stays in the +lowest/highest PHP 8.3–8.5 matrix. GitHub Pages remains a separate +manual workflow restricted to `master`. + +`scripts/release.mjs` has deterministic tests for provenance, version changes, +changelog extraction, conflicting tags, idempotency and API errors. Test and +rehearse `npm run version:packages` in a disposable checkout before changing the +flow. Mocked publication tests do not establish live repository permissions or +Packagist delivery. The first live version PR and publication happen only after +the owner's corresponding merges. + +References: [Changesets version action](https://github.com/changesets/action/tree/ae32849d5ba541f9ae29e40e22a623bc13562f51/version), +[Changesets configuration](https://github.com/changesets/changesets/blob/main/docs/config-file-options.md), +[GitHub token workflow triggers](https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow), +[Composer library versions](https://getcomposer.org/doc/02-libraries.md#library-versioning). diff --git a/docs/testing.md b/docs/testing.md index ac0e6e7..6c2821c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,199 +1,148 @@ -# Reproducing the failure tests +# Testing and evidence -The suite deliberately causes errors in **child processes**, never in PHPUnit or a host application. Fixtures contain only synthetic data. No external service or credential is used. +## Reproduce the current checkout -## Commands +Use PHP 8.3–8.5 and Composer 2. This library does not commit a Composer lock; +applications resolve and retain their own locks. Development dependencies are +resolved for the PHP version running the command. ```sh -# Reference development lock: PHP 8.5 + Composer 2. -composer install --no-plugins --no-scripts --no-interaction --prefer-dist -composer --no-plugins check - -# On PHP 8.3 or 8.4, first resolve compatible development dependencies. composer update --no-plugins --no-scripts --no-interaction --prefer-dist composer --no-plugins check +python3 tests/Examples/run.py +``` -# Core crash tests need PHP only; no Composer dependencies or extensions. -php tests/Fire/run.php +The check runs formatting, PHP_CodeSniffer, PHPUnit, maximum-level source PHPStan, +bounded child-process fire tests and loopback HTTP tests. Examples are also +formatted and checked. The separate Python command verifies all eight executable +recipes, their exact outputs/exit codes, cookbook/site source parity and HTML anchors. -# Include the four isolated PSR renderer regressions after Composer installation. -php tests/Fire/run.php --with-psr +CI resolves lowest and highest allowed dependencies on PHP 8.3, and highest on +PHP 8.4 and 8.5. It verifies a production installation without development +dependencies from the committed Git archive, checking development-file exclusions +and resolving the real provider. Run `python3 tests/Distribution/run.py` after +committing the candidate; it inspects HEAD, not uncommitted edits. +Consult the [actual CI run](https://github.com/coisa/error-handler/actions/workflows/ci.yml) +for the current SHA; a configured matrix is not evidence that it passed. -# PSR integration and real HTTP responses. -composer --no-plugins test +## Isolated failures + +```sh +# Core scenarios need PHP only, without Composer dependencies. +php tests/Fire/run.php +# Include the four PSR output-buffer cases after dependency installation. +php tests/Fire/run.php --with-psr php tests/Http/run.php ``` -The checks run PSR-12 formatting (with the existing declare-before-license convention), PHP_CodeSniffer, PHPUnit, PHPStan at maximum level over `src/`, crash tests and loopback HTTP smoke tests. A narrow PHPStan stub records that `ob_end_clean()` can throw through a user-defined output callback, as demonstrated by a child regression; no analysis errors are suppressed. Deliberately invalid `.inc` crash fixtures are excluded from lint and autoload classmaps. PHPUnit restores its own error handler between tests; the PHP-warning integration case explicitly disables PHPUnit's error handler so the package receives the real warning. +Every fire child uses `php -n`, a 16 MiB memory limit, five-second deadline and +256 KiB combined output cap. The parent verifies stdout/stderr, exact exit status, +delivery order/count, throwable class and applicable severity. Exceeding a bound +terminates the child and fails the suite. Native stderr diagnostics are expected +only in explicitly designated cases. -## Fire-test contract - -Every child uses `php -n`, a 16 MiB PHP memory limit, a five-second wall-clock deadline and a 256 KiB combined output cap. The parent captures stdout, stderr, exact exit status, event ordering, event counts, throwable class and applicable severity. Exceeding a limit terminates that child and fails the suite. A native fatal diagnostic on stderr is expected only in the scenarios that explicitly exercise it. - -| Cases | Expected observation | +| Cases | Contract exercised | | --- | --- | -| User/native warnings | One `ErrorException`; execution continues, exit 0 | -| `error_reporting()` and `@` | Suppressed warnings are not delivered | -| Uncaught exception, `Error`, `TypeError`, included parse error | One original throwable, exit 1 | -| Uncaught exception with custom/nested shutdown | Only the active custom callback runs; exit 1 | -| Uncaught exception followed by a throwing shutdown callback | Original and shutdown throwables each delivered once; exit 1 | -| Exception callback replaced by the reporter | Replaced instance does not receive shutdown | -| Compile fatal, nested-handler compile fatal | One active-handler delivery, native diagnostic, exit 255 | -| Controlled allocation failure | One fatal delivery, native diagnostic, exit 255 | -| 25 register/unregister cycles and final registration | One shutdown callback | -| Unregistered instance | No shutdown delivery | -| Nested handlers and previous native callbacks | Restoration preserves the original error mask; previous exception handler exits 42 | -| Out-of-order unregister | Rejected without removing the active handler | -| Reporter throws, shutdown reporter throws, recursive reporter | One reporter invocation and a bounded nonzero failure | -| Warning inside the PHP-error reporter | Native diagnostic; no recursive reporter call | -| Custom shutdown throws | Its throwable is delivered once | -| Aggregate | Reporters run in attachment order | -| Renderer failure with removable/non-removable nested buffer (`--with-psr`) | Original renderer failure propagates without a loop; non-removable buffers remain active | -| Renderer and output-cleanup callback both throw (`--with-psr`) | Original renderer failure wins; reachable buffers are cleaned | -| Output-cleanup callback throws after successful rendering (`--with-psr`) | Cleanup failure propagates and reachable buffers are cleaned | - -The allocation case deliberately requests 32 MiB under a 16 MiB limit. It proves this failure path only. There is no memory reserve and **no guarantee** of reporting arbitrary real-world out-of-memory conditions. - -## Observed baseline and verification - -On 2026-09-15, the original `f7284cd` source passed 10 of the first 19 isolated scenarios on macOS arm64/PHP 8.5.10. Four uncaught throwable cases exited 0; 25 registration cycles caused 26 shutdown deliveries; failing reporters were called three times; recursion produced 9,389 events before the output cap terminated the child. The original Composer lock could not install on PHP 8.5 because several locked integrations excluded it. - -The final 24-case core suite, including nested fatal-handler and custom shutdown coverage, passed on macOS arm64/PHP 8.5.10 and official Linux aarch64 images for PHP 8.3.33, 8.4.25 and 8.5.10. PHP 8.2.33 also passed the original 19 core cases during investigation but is outside the new support contract. - -Independent review exposed two regressions in the first modernization draft: custom shutdown was skipped after an uncaught exception, and cleanup looped on a non-removable output buffer. The new child tests failed before the fixes; the non-removable case was terminated by the runner's output cap. An additional regression demonstrated that a throwing output callback could replace the original renderer failure and leave a buffer open. With the fixes, the suite includes 24 core cases and three PSR renderer cases. The latter require Composer dependencies and run automatically through `composer --no-plugins check`. - -Native PHP 8.5.10 verification additionally runs PHPUnit 13.3.4, PHPStan 2.2.14 and three real HTTP requests through PHP's built-in server: 500, 503 and TypeError→500, all with a redacted body. The CI matrix checks lowest/highest compatible dependencies on PHP 8.3, highest on 8.4, and locked/highest on 8.5. A green result applies only to the exact checked commit; consult the [Actions run](https://github.com/coisa/error-handler/actions/workflows/ci.yml) rather than assuming that a configured matrix has passed. - -Before the service-provider addition, the full `composer --no-plugins check` command passed on native PHP 8.5.10/PHPUnit 13.3.4 and isolated PHP 8.3.33 with both highest dependencies (PHPUnit 12.5.35) and lowest allowed dependencies (PHPUnit 12.5.8, PHPStan 2.1.0, PSR Container 1.1.0): 100 tests and 144 assertions, maximum-level source analysis, formatting, all 27 isolated scenarios and all three HTTP responses. The runnable CLI example was checked for exit status 1, empty stdout and both expected diagnostic messages on stderr. +| Reportable/native warnings, masks and suppression | Converted once; recoverable execution continues; excluded severities are ignored | +| Uncaught exception, Error, TypeError and included parse error | Original throwable reported; successful reporting exits 1 | +| Compile fatal, nested fatal and bounded allocation failure | Observable engine fatal reported at shutdown; native termination remains | +| Repeated registration, unregistration and reverse-order restoration | One active shutdown delivery; previous callback identities and masks restored | +| Custom shutdown after uncaught exception, replacement and failure | Only the owning lifecycle receives notification | +| Failing/recursive reporters and re-registration | No repeated delivery within a failed registration; fresh lifecycle resets it | +| Ordered aggregate | Delivery order and same throwable preserved | +| Removable/non-removable buffers and throwing output callbacks | Bounded cleanup; renderer failure retains precedence; cleanup-only failure propagates | +| Fatal inside a different shutdown callback, fresh and reused handler | Native exit 255 with no delivery: PHP aborts the remaining shutdown queue | + +The last two shutdown cases characterize a limit, not successful reporting. +Registering a new generation of callbacks does not resume a queue aborted by an +engine fatal. Only existing child fixtures exercise fatal/process termination. + +The HTTP runner starts a loopback-only server on a dynamic `127.0.0.1` port, +waits at most five seconds for startup, gives each request two seconds, and +terminates the fixture server in `finally`. It checks redacted 500/503 responses +and a TypeError mapped to 500. It never uses a real application or production logs. + +## Provider and lifecycle regressions + +Tests use real FastForward Container definitions and constructor autowiring, +including explicit required reporter/renderer interfaces, optional handlers, +PSR-14 listeners, fresh factories and nested middleware beneath a process handler. +Required construction failures must reach the application factory and fail +without silent fallback. FastForward can normalize exceptions and attempt +construction more than once; exception identity and a single attempt are not promised. + +Independent unregistered `handleThrowable()` calls remain usable after a reporter +failure. During registration, failed reporters are suppressed. Built-in middleware +propagates that failure and restores callbacks without invoking its response +renderer through the failed reporter again. A response-factory failure also +propagates after callback restoration. + +The former Laminas behavior cases now exercise the Interop provider. Removed +ConfigProvider-specific configuration tests were retired; reporter, listener, +logging and HTTP behavior remains covered. See [provider contracts](fast-forward.md). + +## Observed evidence + +Before fixes, original source `f7284cd` passed 10 of the first 19 fire scenarios +on macOS arm64/PHP 8.5.10. Uncaught throwables exited 0, repeated registration +delivered duplicate shutdown notifications, and failed/recursive reporters were +retried. The current suite adds regressions for the demonstrated defects. + +The current local PHP 8.5.11/macOS arm64 installation with FastForward 1.6.0 passes +**73 tests / 177 assertions**, maximum source PHPStan, formatting, **31 bounded +scenarios**, three HTTP responses and eight executable examples. Dependency +snapshots used for these local runs are observations, not a committed lock or +a promise about future resolution. The earlier isolated highest-dependency run +with FastForward 1.8.0 also passed; new changes require current-head verification. + +Earlier Linux aarch64 checks exercised PHP 8.3.33, 8.4.25 and 8.5.10 using official +images. The repository Docker/Compose setup is now removed: the version matrix +runs directly in CI and no runtime image is built or distributed. Those older +image experiments are historical evidence, not a supported image interface. + +## Formatting preset compatibility + +The original `coisa/php-cs-fixer:2.1.0` preset installs with PHP CS Fixer 3.95.25, +but loading it fails because it extends the removed +`PhpCsFixer\\RuleSet\\AbstractRuleSetDescription`. An isolated installation +reproduced that failure. Its Composer PHP constraint itself allows PHP 8.3+. +The original modernization commit did not record a specific rationale. + +The repository currently selects an explicit PSR-12 baseline for source, tests +and examples. This is functional but is not equivalent to the additional CoiSA +rules, risky transformations and header/PHPDoc policy of the old preset. +Modernizing that package is a separate PR; do not depend on its unreleased branch +from this library. PHPDoc and licensing remain explicit review requirements. + +## Release and distribution verification ```sh -docker run --rm --network none --read-only --memory 128m --cpus 1 --pids-limit 32 \ - -v "$PWD:/app:ro" -w /app php:8.3-cli-alpine php tests/Fire/run.php -docker compose run --rm --build tests - -# Lowest allowed dependencies, isolated from the host's vendor/ and lock. -docker run --rm --memory 512m --cpus 2 --pids-limit 64 \ - error-handler-modernization-tests sh -c \ - 'composer update --prefer-lowest --prefer-stable --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress && composer --no-plugins check' +npm ci --ignore-scripts +npm run test:release +npm run changeset:status ``` -Container builds resolve development packages for the requested PHP version. For a specific matrix run, record the actual PHP version, resolved dependencies and image digest; floating version tags may change. The recorded image digests are: - -- PHP 8.3.33: `sha256:afdf8b1fee58486ccc0dab5f30f634b86873d56dac985f71ba217945647c05ad` -- PHP 8.4.25: `sha256:2f389f933c3cc58cc622bd243bb4ecff7e6553e2de4387a239bca640c988be19` -- PHP 8.5.10: `sha256:aa66ee4275e0472bafcd18e35142499fcbda6ac90e641e751418e052e6a0d812` - -## Service-provider verification - -The provider follow-up passes the full check on macOS arm64/PHP 8.5.10 with -FastForward Container 1.6.0: **118 tests and 196 assertions**, maximum PHPStan, -formatting, the same 27 isolated scenarios and three HTTP responses. Eighteen new -cases cover real-container resolution, reporter overrides, optional discovery, -PSR-14 dispatch, custom PHP/shutdown callbacks, independent factories, nested -middleware and restoration after renderer failure. Missing optional reporters -are skipped only for PSR not-found; separate regressions require other -construction exceptions to propagate. - -Independent review reproduced silent reporter fallback when application and -library providers were passed separately to FastForward. Six real-container -regressions now cover required-reporter PSR construction/not-found failures and -ordinary application exceptions for both process handler and fully configured -middleware. Four cases failed with the old composition; all six pass with one -`AggregateServiceProvider`, library first and application last. The skill and -example use this same composition. FastForward may normalize a PSR failure to -not-found, so the contract promises failed construction rather than preservation -of the original exception cause. - -`php examples/fast-forward.php` exits 0, emits exactly -`Reported: FastForward provider warning` followed by a newline, and leaves stderr -empty. CI verifies that output. That original provider revision also loaded its -13 definitions without FastForward installed. The review follow-up makes -FastForward a runtime dependency, replaces the standalone container, removes -constructor-only factories and reduces the provider to nine definitions. -Production-only CI now resolves a process handler through FastForward after -`composer install --no-dev`; it no longer asserts FastForward is absent. -The five-job PHP/dependency CI matrix exercises the provider tests too; consult -the current run for commit-specific Linux evidence. - -The consumer skill's frontmatter and source references were validated, and its -integration procedure is checked against these same public APIs. Repository -maintenance guidance is in [AGENTS.md](../AGENTS.md); the detailed wiring contract -is in [FastForward integration](fast-forward.md). - -## Review follow-up verification - -The first FastForward-required/autowire revision passed the full check on macOS arm64 -with PHP 8.5.11, PHPUnit 13.3.4 and FastForward Container 1.6.0: **89 tests and -185 assertions**, maximum PHPStan over `src/`, formatting, **28** isolated scenarios -and three HTTP responses. The count replaces tests exclusive to the removed -standalone container with retained Laminas integration tests and new autowire, -required-renderer and reporter-reset coverage; it is not the old 118-case suite. - -The 25 dependency-free core scenarios also pass on PHP 8.4.26 with `-n`. -An isolated production-only installation resolves the provider/process alias on -PHP 8.5.11 and PHP 8.4.26, without PHPUnit. The existing package versions were -preserved; moving FastForward and its transitive requirements to runtime grows -the locked production set from 8 to 20 packages. Both executable examples retain -their exact output and exit contracts. - -The additional reporter-reset child verifies that a failed reporter is not -retried until unregister/register starts a new lifecycle. Six required-renderer -cases cover both HTTP interfaces against PSR construction, PSR not-found and -ordinary application failures. A separate bounded reproduction on FastForward -1.6.0 showed that a concrete renderer override's PSR failure can fall back to -autowiring, while an interface override fails construction. Required custom -renderers therefore use explicit interface bindings in the documented contract. - -Highest-dependency CI exposed a version difference: FastForward 1.8.0 normalizes -ordinary application construction failures as well as PSR failures. The tests -therefore require the explicitly failing factory to be reached and construction -to fail with the original throwable or a PSR-11 normalization; they do not promise -original exception identity or a single construction attempt. Handler delivery -after a reporter fails remains a separate no-retry contract. - -An isolated highest-dependency installation also passes the full check on PHP -8.5.11 with FastForward 1.8.0, PHPUnit 13.4.2 and PHPStan 2.3.1: 89 tests / 185 -assertions, formatting, all 28 child scenarios and the three HTTP responses. -That installation changes only its disposable copy; the repository lock keeps -FastForward 1.6.0 and all existing package versions. - -These local results do not establish PHP 8.3 or new Linux matrix results for this -revision. Use the CI run attached to the exact follow-up commit for those. - -## Interop-only and stream-cleanup follow-up - -Container integration now uses only `ServiceProvider\ErrorHandlerServiceProvider`. -The native Laminas ConfigProvider, its Composer discovery metadata and the -ServiceManager development dependency are removed. The former Laminas behavior -tests now exercise the same reporting, event/listener and HTTP behavior through -FastForward and the Interop provider; adapter-specific configuration tests are -retired. The two remaining container factories and their unit tests move under -`ServiceProvider\Factory`. - -`ThrowableStreamFactory` keeps output capture and cleanup in the same class, -using a private cleanup method. Characterization tests passed before extraction: -balanced nested output preserves the caller's buffer, a renderer failure wins -over a simultaneous cleanup failure, and a cleanup-only failure propagates. The -non-removable-buffer and no-progress guards remain in place. - -The full check passes on macOS arm64/PHP 8.5.11 with the locked FastForward 1.6.0: -**70 tests / 168 assertions**, maximum source PHPStan, formatting, **29 isolated -scenarios** and three loopback HTTP responses. Only ServiceManager was removed -from the lock; retained dependency versions and the 20-package runtime set are -unchanged. CI on the exact follow-up head supplies the PHP 8.3–8.5 matrix evidence. - -The same full check also passes in an isolated highest-dependency installation -with FastForward 1.8.0, PHPUnit 13.4.2 and PHPStan 2.3.1. The production-only -installation resolves the relocated handler factory and creates independent -handlers, with the old ConfigProvider, old factory namespace and ServiceManager -absent. Both executable examples retain their exact output and exit contracts. - -## Boundaries and troubleshooting - -- PHP must have `proc_open()` enabled for crash/HTTP tests. Unit tests require the normal PHPUnit extensions. The HTTP runner binds only to a dynamically selected `127.0.0.1` port, waits at most five seconds to start, gives each request two seconds, and terminates its fixture server in `finally`. -- PHP-FPM, Apache modules, Windows, production load, asynchronous/concurrent runtimes, remote logging failures and process signals are not covered by the local evidence. The Linux CI runner adds its own platform evidence when it succeeds. -- Errors before registration, startup failures and `SIGKILL` cannot be rescued by a PHP shutdown callback. A handler does not make a fatal error recoverable. Native shutdown diagnostics may still be emitted by PHP; applications must configure `display_errors`/logging appropriately. -- A global Composer plugin can interfere with newer tooling. The documented `--no-plugins` commands isolate this project from plugins; they do not modify global configuration. -- If unregistration throws, unwind the middleware/handler stack in reverse order. If a renderer throws, its exception propagates after reachable removable buffers are cleaned. Cleanup stops at a non-removable buffer because PHP cannot remove it or reach lower buffers. Renderers must use removable buffers and must not close buffers owned by the caller. - -Sources: [PHP supported versions](https://www.php.net/supported-versions.php), [error-handler semantics](https://www.php.net/manual/en/function.set-error-handler.php), [exception handlers](https://www.php.net/manual/en/function.set-exception-handler.php), [shutdown semantics](https://www.php.net/manual/en/function.register-shutdown-function.php), [output buffer status](https://www.php.net/manual/en/function.ob-get-status.php). +Node is development release tooling only. `package-lock.json` remains committed +to reproduce it. Seventeen deterministic release tests verify provenance, notes, +tag conflicts, idempotency and API failures without creating tags/releases. +A disposable rehearsal verified the initial major changeset produces 3.0.0, +updates both npm manifests and consumes its note; the working branch remains 2.0.0 +and unreleased. See [release maintenance](releases.md) and the archive exclusions +in [.gitattributes](../.gitattributes). + +## Evidence limits + +PHP-FPM, Apache, Windows, concurrent requests/fibers, production load, process +signals and remote delivery need application-specific validation. Startup errors +and SIGKILL cannot be rescued. Reporters own private diagnostics; a shutdown +callback cannot recover fatal execution or guarantee an HTTP response. + +Renderers must balance removable buffers and never close caller-owned buffers. +PHP cannot remove a non-removable top buffer or reach those below it. Unregister +handlers in reverse order, preserving native ownership. Tests need `proc_open()` +and the normal PHPUnit PHP extensions. + +Sources: [PHP error handlers](https://www.php.net/manual/en/function.set-error-handler.php), +[exception callbacks](https://www.php.net/manual/en/function.set-exception-handler.php), +[shutdown semantics](https://www.php.net/manual/en/function.register-shutdown-function.php), +[buffer status](https://www.php.net/manual/en/function.ob-get-status.php). diff --git a/examples/custom-handlers.php b/examples/custom-handlers.php new file mode 100644 index 0000000..7c13b9d --- /dev/null +++ b/examples/custom-handlers.php @@ -0,0 +1,72 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; + +use function FastForward\Container\container; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$phpErrors = new class () implements PhpErrorHandlerInterface { + /** + * @var list Reportable PHP messages received by this application handler. + */ + public array $messages = []; + + /** + * Records reportable PHP errors without converting them to exceptions. + * + * @param int $code The PHP severity. + * @param string $message The PHP message. + * @param string $filename The source filename. + * @param int $line The source line. + */ + public function handlePhpError(int $code, string $message, string $filename, int $line): void + { + if (($code & error_reporting()) !== 0) { + $this->messages[] = $message; + } + } +}; +$shutdowns = 0; +$shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void { + ++$shutdowns; +}); +$application = new ArrayServiceProvider([ + ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => + new CallableThrowableHandler(static function (Throwable $error): void { + error_log($error->getMessage()); + }), + PhpErrorHandlerInterface::class => static fn (): PhpErrorHandlerInterface => $phpErrors, + ShutdownHandlerInterface::class => static fn (): ShutdownHandlerInterface => $shutdown, +]); +$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$errors = $services->get(ErrorHandlerInterface::class); +$errors->register(); +try { + trigger_error('Application-owned warning', E_USER_WARNING); + $errors->handleShutdown(); // Simulate a notification without terminating the process. +} finally { + $errors->unregister(); +} +printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns); diff --git a/examples/event-dispatcher.php b/examples/event-dispatcher.php new file mode 100644 index 0000000..4d3596f --- /dev/null +++ b/examples/event-dispatcher.php @@ -0,0 +1,81 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\EventDispatcher\Event\ErrorEventInterface; +use CoiSA\ErrorHandler\EventDispatcher\Listener\ErrorEventCallableListener; +use CoiSA\ErrorHandler\EventDispatcher\Listener\LogErrorEventListener; +use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; +use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; +use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Phly\EventDispatcher\EventDispatcher; +use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\AbstractLogger; +use Psr\Log\LogLevel; + +use function FastForward\Container\container; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$deliveries = []; +$logger = new class () extends AbstractLogger { + /** + * @var list Synthetic private log records; replace with an application logger. + */ + public array $records = []; + + /** + * Records diagnostics without emitting them into an HTTP response. + * + * Untyped parameters also support applications using psr/log 1.x. + * + * @param mixed $level The PSR-3 log level. + * @param mixed $message The diagnostic message. + * @param array $context The application log context. + */ + public function log($level, $message, array $context = []): void + { + $this->records[] = $level . ': ' . (string) $message; + } +}; +$throwables = new ThrowableListenerProvider( + new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { + $deliveries[] = 'throwable: ' . $error->getMessage(); + }) +); +$events = new ErrorEventListenerProvider( + new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void { + $deliveries[] = 'event: ' . $event->getThrowable()->getMessage(); + }) +); +$events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); +$dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events)); +$application = new ArrayServiceProvider([ + EventDispatcherInterface::class => static fn (): EventDispatcherInterface => $dispatcher, +]); +$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$errors = $services->get(ErrorHandlerInterface::class); +$errors->register(); +try { + trigger_error('Synthetic warning', E_USER_WARNING); +} finally { + $errors->unregister(); +} +echo implode(PHP_EOL, $deliveries) . PHP_EOL; +printf("Private log records: %d\n", count($logger->records)); diff --git a/examples/http-middleware.php b/examples/http-middleware.php new file mode 100644 index 0000000..e94a83c --- /dev/null +++ b/examples/http-middleware.php @@ -0,0 +1,70 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\RequestHandlerInterface; + +use function FastForward\Container\container; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$reported = []; +$psr17 = new Psr17Factory(); +$reporter = new CallableThrowableHandler(static function (Throwable $error) use (&$reported): void { + $reported[] = $error; // Replace with a private logger; do not echo diagnostics. +}); +$application = new ArrayServiceProvider([ + ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, + StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, +]); +$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$process = $services->get(ErrorHandlerInterface::class); +$middleware = $services->get(ErrorHandlerMiddleware::class); +$downstream = new class () implements RequestHandlerInterface { + /** + * Simulates an application failure with an HTTP error status. + * + * @param ServerRequestInterface $request The incoming request. + * @return ResponseInterface A response supplied by the error middleware. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Private database diagnostic', 503); + } +}; + +$process->register(); +try { + $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); + // The process handler is active again after the middleware scope ends. + trigger_error('Synthetic post-request warning', E_USER_WARNING); +} finally { + $process->unregister(); +} +printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody()); +printf("Reported: %d\n", count($reported)); +// Your HTTP framework/emitter sends the response; this example only inspects it. diff --git a/examples/http-rendering.php b/examples/http-rendering.php new file mode 100644 index 0000000..e24d92c --- /dev/null +++ b/examples/http-rendering.php @@ -0,0 +1,41 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use Nyholm\Psr7\Factory\Psr17Factory; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$privateDiagnostics = []; +$reporter = new CallableThrowableHandler( + static function (Throwable $error) use (&$privateDiagnostics): void { + $privateDiagnostics[] = $error; // Replace with a private logger. + } +); +$publicRenderer = new CallableThrowableHandler(static function (): void { + echo json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR); +}); +$psr17 = new Psr17Factory(); +$streams = new ThrowableStreamFactory($psr17, new ThrowableHandlerAggregate($reporter, $publicRenderer)); +$responses = new ThrowableResponseFactory($streams, $psr17); +$response = $responses->createResponseFromThrowable(new RuntimeException('Private credentials', 503)) + ->withHeader('Content-Type', 'application/json'); + +printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type')); +echo (string) $response->getBody() . PHP_EOL; +printf("Private reports: %d\n", count($privateDiagnostics)); diff --git a/examples/reporters.php b/examples/reporters.php new file mode 100644 index 0000000..a4fb92a --- /dev/null +++ b/examples/reporters.php @@ -0,0 +1,37 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$deliveries = []; +$reporters = new ThrowableHandlerAggregate( + new CallableThrowableHandler(static function (Throwable $error) use (&$deliveries): void { + $deliveries[] = 'first: ' . $error->getMessage(); + }) +); +$reporters->attach(new CallableThrowableHandler( + static function (Throwable $error) use (&$deliveries): void { + $deliveries[] = 'second: ' . $error->getMessage(); + } +)); + +$errors = new ErrorHandler($reporters); +// Direct delivery needs no registration and does not terminate the process. +$errors->handleThrowable(new RuntimeException('Synthetic failure')); +echo implode(PHP_EOL, $deliveries) . PHP_EOL; diff --git a/examples/scopes.php b/examples/scopes.php new file mode 100644 index 0000000..7901bbc --- /dev/null +++ b/examples/scopes.php @@ -0,0 +1,100 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; +use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Http\Server\MiddlewareInterface; + +use function FastForward\Container\container; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +$reported = 0; +$psr17 = new Psr17Factory(); +$reporter = new CallableThrowableHandler(static function () use (&$reported): void { + ++$reported; +}); +$application = new ArrayServiceProvider([ + ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, + StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, +]); +$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$makeHandler = $services->get(ErrorHandlerFactory::class); +$first = $makeHandler($services); +$second = $makeHandler($services); +$makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class); +$outer = $makeMiddleware($services); +$inner = $makeMiddleware($services); +$downstream = new class () implements RequestHandlerInterface { + /** + * Throws a synthetic downstream failure for the inner scope. + * + * @param ServerRequestInterface $request The synthetic request. + * @return ResponseInterface The response produced by the inner middleware. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Nested failure'); + } +}; +$next = new class ($inner, $downstream) implements RequestHandlerInterface { + /** + * Stores the independently constructed inner middleware and application handler. + * + * @param MiddlewareInterface $inner The inner error middleware. + * @param RequestHandlerInterface $downstream The application request handler. + */ + public function __construct(private MiddlewareInterface $inner, private RequestHandlerInterface $downstream) + { + } + + /** + * Enters the inner scope while the outer scope remains registered. + * + * @param ServerRequestInterface $request The incoming request. + * @return ResponseInterface The inner response. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->inner->process($request, $this->downstream); + } +}; +$process = $services->get(ErrorHandlerInterface::class); +$process->register(); +try { + $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next); + trigger_error('Process handler restored', E_USER_WARNING); +} finally { + $process->unregister(); +} +printf( + "Fresh handlers: %s | Fresh middleware: %s\n", + $first !== $second ? 'yes' : 'no', + $outer !== $inner ? 'yes' : 'no' +); +printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..635f6ed --- /dev/null +++ b/package-lock.json @@ -0,0 +1,551 @@ +{ + "name": "@coisa/error-handler", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@coisa/error-handler", + "version": "2.0.0", + "devDependencies": { + "@changesets/cli": "3.0.3" + }, + "engines": { + "node": "^22.11 || ^24 || >=26", + "npm": ">=10.9.0" + } + }, + "node_modules/@changesets/apply-release-plan": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-8.1.1.tgz", + "integrity": "sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/config": "^4.0.1", + "@changesets/format": "^0.1.2", + "@changesets/git": "^4.0.1", + "@changesets/should-skip-package": "^1.0.0", + "@changesets/types": "^7.0.0", + "import-meta-resolve": "^4.2.0", + "jsonc-parser": "^3.3.1", + "semver": "^7.8.1" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/assemble-release-plan": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-7.0.0.tgz", + "integrity": "sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^1.0.0", + "@changesets/get-dependents-graph": "^3.0.0", + "@changesets/should-skip-package": "^1.0.0", + "@changesets/types": "^7.0.0", + "semver": "^7.8.1" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/changelog-git": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-1.0.0.tgz", + "integrity": "sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^7.0.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/cli": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-3.0.3.tgz", + "integrity": "sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/apply-release-plan": "^8.1.1", + "@changesets/assemble-release-plan": "^7.0.0", + "@changesets/changelog-git": "^1.0.0", + "@changesets/config": "^4.0.1", + "@changesets/errors": "^1.0.0", + "@changesets/get-dependents-graph": "^3.0.0", + "@changesets/git": "^4.0.1", + "@changesets/pre": "^3.0.0", + "@changesets/read": "^1.0.1", + "@changesets/should-skip-package": "^1.0.0", + "@changesets/types": "^7.0.0", + "@changesets/write": "^1.0.1", + "@clack/prompts": "^1.7.0", + "@manypkg/get-packages": "^3.1.0", + "@pnpm/deps.graph-sequencer": "^1100.0.1", + "cac": "^7.0.0", + "import-meta-resolve": "^4.2.0", + "launch-editor": "^2.14.1", + "package-manager-detector": "^1.6.0", + "semver": "^7.8.1", + "tinyexec": "^1.3.1" + }, + "bin": { + "changeset": "bin.js" + }, + "engines": { + "node": "^22.11 || ^24 || >=26", + "npm": ">=10.9.0", + "pnpm": ">=10.0.0", + "yarn": ">=4.5.2" + } + }, + "node_modules/@changesets/config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-4.0.1.tgz", + "integrity": "sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/get-dependents-graph": "^3.0.0", + "@changesets/should-skip-package": "^1.0.0", + "@changesets/types": "^7.0.0", + "@manypkg/get-packages": "^3.1.0", + "picomatch": "^4.0.7" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-1.0.0.tgz", + "integrity": "sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/format": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@changesets/format/-/format-0.1.2.tgz", + "integrity": "sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/get-dependents-graph": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-3.0.0.tgz", + "integrity": "sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^7.0.0", + "semver": "^7.8.1" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/git": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@changesets/git/-/git-4.0.1.tgz", + "integrity": "sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^1.0.0", + "@changesets/types": "^7.0.0", + "@manypkg/get-packages": "^3.1.0", + "picomatch": "^4.0.4", + "tinyexec": "^1.3.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-1.0.0.tgz", + "integrity": "sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^7.0.0", + "yaml": "^2.9.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/pre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-3.0.0.tgz", + "integrity": "sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^1.0.0", + "@changesets/types": "^7.0.0", + "@manypkg/get-packages": "^3.1.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/read": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@changesets/read/-/read-1.0.1.tgz", + "integrity": "sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/git": "^4.0.1", + "@changesets/parse": "^1.0.0", + "@changesets/types": "^7.0.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/should-skip-package": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-1.0.0.tgz", + "integrity": "sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^7.0.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/types": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-7.0.0.tgz", + "integrity": "sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@changesets/write": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@changesets/write/-/write-1.0.1.tgz", + "integrity": "sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/format": "^0.1.1", + "@changesets/types": "^7.0.0", + "human-id": "^4.2.0" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@manypkg/find-root": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-3.1.0.tgz", + "integrity": "sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@manypkg/tools": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@manypkg/get-packages": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-3.1.0.tgz", + "integrity": "sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@manypkg/find-root": "^3.1.0", + "@manypkg/tools": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@manypkg/tools": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@manypkg/tools/-/tools-2.1.2.tgz", + "integrity": "sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jju": "^1.4.0", + "tinyglobby": "^0.2.13", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@pnpm/deps.graph-sequencer": { + "version": "1100.0.1", + "resolved": "https://registry.npmjs.org/@pnpm/deps.graph-sequencer/-/deps.graph-sequencer-1100.0.1.tgz", + "integrity": "sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.13" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/cac": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.1.0.tgz", + "integrity": "sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/human-id": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.2.tgz", + "integrity": "sha512-Z+8unD6em/MJwyrkWQ2M3wygRGI1+MztbutiNwk1DCuEPXOZRWGjtcMPZYP1exY9iNZMhd7ZOSMQSo9WYtiQRw==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/launch-editor": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz", + "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.10.0" + } + }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5391701 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "@coisa/error-handler", + "version": "2.0.0", + "private": true, + "description": "Development-only release tooling for the coisa/error-handler Composer package", + "scripts": { + "changeset": "changeset", + "changeset:status": "changeset status", + "version:packages": "changeset version && npm install --package-lock-only --ignore-scripts && git diff --check", + "test:release": "node --test tests/Release/*.test.mjs", + "release:github": "node scripts/release.mjs" + }, + "engines": { + "node": "^22.11 || ^24 || >=26", + "npm": ">=10.9.0" + }, + "devDependencies": { + "@changesets/cli": "3.0.3" + } +} diff --git a/phpcs.xml b/phpcs.xml index 7b9e967..1e4b70c 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -10,6 +10,7 @@ src tests + examples vendor/* tests/html/* diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..8bfdbf1 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,89 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const name = '@coisa/error-handler'; +const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const shaPattern = /^[a-f0-9]{40}$/; + +/** Permit publication only for the exact merged bot version PR and its notes. */ +export function planRelease({ manifest, previous, changelog, sha, repository, prs }) { + if (manifest.name !== name || manifest.private !== true || !versionPattern.test(manifest.version)) { + throw new Error('Expected a private tooling manifest with a stable version.'); + } + if (!previous || previous.version === manifest.version) return null; + if (previous.name !== name || !versionPattern.test(previous.version)) throw new Error('Invalid baseline.'); + const current = manifest.version.split('.').map(Number); + const old = previous.version.split('.').map(Number); + const difference = current.findIndex((value, index) => value !== old[index]); + if (current[difference] < old[difference]) throw new Error('Release versions must increase.'); + if (!shaPattern.test(sha)) throw new Error('Expected an immutable merge commit.'); + const matches = prs.filter(pr => pr.merged_at && pr.merge_commit_sha === sha && + pr.base?.ref === 'master' && pr.base?.repo?.full_name === repository && + pr.head?.ref === 'changeset-release/master' && pr.head?.repo?.full_name === repository && + pr.user?.login === 'github-actions[bot]'); + if (matches.length !== 1) throw new Error('Expected one merged Changesets bot version PR.'); + const lines = changelog.split(/\r?\n/); + const starts = lines.flatMap((line, index) => line === '## ' + manifest.version ? [index] : []); + if (starts.length !== 1) throw new Error('Expected exactly one changelog section.'); + const end = lines.findIndex((line, index) => index > starts[0] && /^## /.test(line)); + const notes = lines.slice(starts[0] + 1, end < 0 ? undefined : end).join('\n').trim(); + if (!notes) throw new Error('Release notes must not be empty.'); + return { tag: manifest.version, sha, notes, repository }; +} + +/** Reuse matching tags/releases; never move a tag or overwrite an existing release. */ +export function publishRelease(plan, api) { + const root = 'repos/' + plan.repository; + const optional = path => { + try { return api(path); } catch (error) { + if (error.status === 404) return null; + throw error; + } + }; + const ref = optional(root + '/git/ref/tags/' + plan.tag); + let object = ref?.object; + const seen = new Set(); + while (object?.type === 'tag') { + if (!shaPattern.test(object.sha) || seen.has(object.sha)) throw new Error('Invalid annotated tag chain.'); + seen.add(object.sha); + object = api(root + '/git/tags/' + object.sha).object; + } + if (ref && (object?.type !== 'commit' || object.sha !== plan.sha)) throw new Error('Tag points elsewhere.'); + if (!ref) api(root + '/git/refs', { ref: 'refs/tags/' + plan.tag, sha: plan.sha }); + const release = optional(root + '/releases/tags/' + plan.tag); + if (release) { + if (release.draft || release.tag_name !== plan.tag) throw new Error('Incompatible existing release.'); + return; + } + api(root + '/releases', { tag_name: plan.tag, target_commitish: plan.sha, name: plan.tag, + body: plan.notes, draft: false, prerelease: false }); +} + +/** Pass arguments and JSON through stdin without shell interpolation or credential output. */ +function api(path, body) { + try { + return JSON.parse(execFileSync('gh', ['api', path, ...(body ? ['--method', 'POST', '--input', '-'] : [])], + { encoding: 'utf8', input: body ? JSON.stringify(body) : undefined, stdio: ['pipe', 'pipe', 'pipe'] })); + } catch (error) { + if (/HTTP 404/.test(String(error.stderr))) error.status = 404; + throw error; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { GITHUB_EVENT_NAME: event, GITHUB_REF: ref, GITHUB_SHA: sha, + GITHUB_REPOSITORY: repository, RELEASE_BEFORE: before } = process.env; + if (event !== 'push' || ref !== 'refs/heads/master' || repository !== 'coisa/error-handler' || + !shaPattern.test(sha ?? '') || !shaPattern.test(before ?? '')) throw new Error('Expected a verified master push.'); + const manifest = JSON.parse(readFileSync('package.json', 'utf8')); + const exists = !/^0{40}$/.test(before) && + execFileSync('git', ['ls-tree', '--name-only', before, '--', 'package.json'], { encoding: 'utf8' }).trim(); + const previous = exists ? JSON.parse(execFileSync('git', ['show', before + ':package.json'], { encoding: 'utf8' })) : null; + const plan = planRelease({ manifest, previous, sha, repository, changelog: readFileSync('CHANGELOG.md', 'utf8'), + prs: previous && previous.version !== manifest.version + ? api('repos/' + repository + '/commits/' + sha + '/pulls?per_page=100') + .map(pr => api('repos/' + repository + '/pulls/' + pr.number)) : [] }); + if (plan) publishRelease(plan, api); + console.log(plan ? 'GitHub release ' + plan.tag + ' is present at ' + sha : 'No version change to publish.'); +} diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index ade629e..e5b5683 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -166,7 +166,7 @@ public function handleThrowable(\Throwable $throwable): void if ($this->isHandlingThrowable) { throw new \LogicException('Recursive throwable handling is not supported.', 0, $throwable); } - if ($this->handlerFailed) { + if ($this->hasReporterFailed()) { throw $throwable; } @@ -174,13 +174,26 @@ public function handleThrowable(\Throwable $throwable): void try { $this->throwableHandler->handleThrowable($throwable); } catch (\Throwable $failure) { - $this->handlerFailed = true; + $this->handlerFailed = $this->isRegistered; throw $failure; } finally { $this->isHandlingThrowable = false; } } + /** + * Indicates whether reporting has failed within the active registration. + * + * Integrations MUST propagate the current failure instead of rendering through + * the same reporter again. Independent unregistered deliveries have no retained failure state. + * + * @return bool True when the active registration must not retry its reporter. + */ + public function hasReporterFailed(): bool + { + return $this->isRegistered && $this->handlerFailed; + } + /** * Handles PHP errors. * diff --git a/src/Http/Middleware/ErrorHandlerMiddleware.php b/src/Http/Middleware/ErrorHandlerMiddleware.php index 97f1d47..ddef0a5 100644 --- a/src/Http/Middleware/ErrorHandlerMiddleware.php +++ b/src/Http/Middleware/ErrorHandlerMiddleware.php @@ -16,6 +16,7 @@ namespace CoiSA\ErrorHandler\Http\Middleware; +use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -82,6 +83,9 @@ public function process( try { $response = $handler->handle($request); } catch (\Throwable $throwable) { + if ($this->errorHandler instanceof ErrorHandler && $this->errorHandler->hasReporterFailed()) { + throw $throwable; + } $response = $this->throwableResponseFactory->createResponseFromThrowable($throwable); } finally { $this->errorHandler->unregister(); diff --git a/tests/Distribution/run.py b/tests/Distribution/run.py new file mode 100644 index 0000000..b7d4c02 --- /dev/null +++ b/tests/Distribution/run.py @@ -0,0 +1,37 @@ +"""Verify a Git archive contains only runtime distribution files and installs.""" + +import io +import subprocess +import tarfile +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +payload = subprocess.check_output(["git", "archive", "--format=tar", "HEAD"], cwd=ROOT) +allowed = {"composer.json", "LICENSE", "README.md", "CHANGELOG.md"} +with tarfile.open(fileobj=io.BytesIO(payload)) as archive: + paths = {member.name for member in archive.getmembers() if member.isfile()} + unexpected = {path for path in paths if path not in allowed and not path.startswith("src/")} + assert not unexpected, "Development files leaked into the archive: " + str(unexpected) + assert allowed <= paths and "src/ServiceProvider/ErrorHandlerServiceProvider.php" in paths + with tempfile.TemporaryDirectory(prefix="error-handler-distribution-") as directory: + # Every archive entry comes from this trusted Git commit, with checked paths. + archive.extractall(directory) + subprocess.run([ + "composer", "install", "--no-dev", "--no-plugins", "--no-scripts", + "--no-interaction", "--prefer-dist", "--no-progress", + ], cwd=directory, check=True, timeout=120) + code = r''' +require 'vendor/autoload.php'; +$provider = new CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider(); +$services = FastForward\Container\container($provider); +$handler = $services->get(CoiSA\ErrorHandler\ErrorHandlerInterface::class); +if (!$handler instanceof CoiSA\ErrorHandler\ErrorHandler || count($provider->getFactories()) !== 9) { + throw new RuntimeException('Production archive provider resolution failed.'); +} +if (class_exists(CoiSA\ErrorHandler\Container\ConfigProvider::class)) { + throw new RuntimeException('Legacy ConfigProvider remains available.'); +} +''' + subprocess.run(["php", "-r", code], cwd=directory, check=True, timeout=5) +print("PASS runtime archive exclusions and production-only Composer/provider autoload") diff --git a/tests/Examples/run.py b/tests/Examples/run.py new file mode 100644 index 0000000..fba9b8c --- /dev/null +++ b/tests/Examples/run.py @@ -0,0 +1,71 @@ +"""Execute documented examples and verify the displayed code matches its source.""" + +import re +import subprocess +from html.parser import HTMLParser +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +EXPECTED = { + "fast-forward.php": "Reported: FastForward provider warning\n", + "reporters.php": "first: Synthetic failure\nsecond: Synthetic failure\n", + "event-dispatcher.php": "throwable: Synthetic warning\nevent: Synthetic warning\nPrivate log records: 1\n", + "http-middleware.php": "HTTP 503 | Internal Server Error\nReported: 2\n", + "http-rendering.php": 'HTTP 503 | application/json\n{"error":"Service unavailable"}\nPrivate reports: 1\n', + "custom-handlers.php": "PHP errors: 1 | Shutdown notifications: 1\n", + "scopes.php": "Fresh handlers: yes | Fresh middleware: yes\nHTTP 500 | Reports: 2\n", +} + + +class Examples(HTMLParser): + """Collect inline recipe text and validate internal navigation targets.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.current = None + self.code = {} + self.ids = set() + self.targets = set() + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if "id" in attrs: + assert attrs["id"] not in self.ids, "Duplicate HTML id: " + attrs["id"] + self.ids.add(attrs["id"]) + if attrs.get("href", "").startswith("#"): + self.targets.add(attrs["href"][1:]) + if tag == "code" and "data-example" in attrs: + self.current = attrs["data-example"] + assert self.current not in self.code + self.code[self.current] = "" + + def handle_data(self, data): + if self.current is not None: + self.code[self.current] += data + + def handle_endtag(self, tag): + if tag == "code": + self.current = None + + +page = Examples() +page.feed((ROOT / "docs/index.html").read_text()) +assert page.targets <= page.ids, "Broken HTML anchors: " + str(page.targets - page.ids) +cookbook = (ROOT / "docs/examples.md").read_text() +paths = sorted((ROOT / "examples").glob("*.php")) +assert set(p.name for p in paths) == set(EXPECTED) | {"cli.php"}, "Update the example contracts." +for path in paths: + result = subprocess.run(["php", str(path)], capture_output=True, text=True, timeout=5, cwd=ROOT) + if path.name == "cli.php": + assert result.returncode == 1 and result.stdout == "" + assert "Example warning" in result.stderr and "Example uncaught exception" in result.stderr + else: + assert result.returncode == 0 and result.stderr == "", (path.name, result.stderr) + assert result.stdout == EXPECTED[path.name], (path.name, result.stdout) + source = re.sub(r"/\*\*[\s\S]*?\*/\n\n", "", path.read_text(), count=1).rstrip() + key = path.relative_to(ROOT).as_posix() + assert page.code.get(key) == source, "Stale website example: " + key + assert "```php\n" + source + "\n```" in cookbook, "Stale cookbook example: " + key + print("PASS " + key) +assert len(page.code) == len(paths) +print("Eight executable examples, cookbook snippets and HTML anchors verified.") diff --git a/tests/Fire/run.php b/tests/Fire/run.php index 3623ce5..d1cccef 100644 --- a/tests/Fire/run.php +++ b/tests/Fire/run.php @@ -31,6 +31,9 @@ 'parse-error' => [1, ['error'], ParseError::class], 'compile-fatal' => [255, ['error'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_COMPILE_ERROR], 'nested-fatal' => [255, ['error'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_COMPILE_ERROR], + // An engine fatal inside shutdown aborts the remaining shutdown queue itself. + 'reregister-shutdown-fatal' => [255, []], + 'fresh-shutdown-fatal' => [255, []], 'memory-limit' => [255, ['error'], 'CoiSA\\ErrorHandler\\Exception\\ErrorException', E_ERROR], 'shutdown-once' => [0, ['shutdown']], 'unregistered-shutdown' => [0, ['continued']], @@ -115,7 +118,8 @@ } } $nativeDiagnostic = in_array($name, [ - 'compile-fatal', 'nested-fatal', 'memory-limit', 'reporter-throws', 'shutdown-reporter-throws', + 'compile-fatal', 'nested-fatal', 'reregister-shutdown-fatal', 'fresh-shutdown-fatal', + 'memory-limit', 'reporter-throws', 'shutdown-reporter-throws', 'recursive-reporter', 'reporter-warning', ], true); $ok = $ok && ($nativeDiagnostic ? $stderr !== '' : $stderr === ''); diff --git a/tests/Fire/scenario.php b/tests/Fire/scenario.php index 452e459..915eca8 100644 --- a/tests/Fire/scenario.php +++ b/tests/Fire/scenario.php @@ -112,6 +112,17 @@ static function () use ($scenario): void { // Allocation fails only inside this child, capped at 16 MiB by the runner. str_repeat('x', 32 * 1024 * 1024); break; + case 'reregister-shutdown-fatal': + case 'fresh-shutdown-fatal': + if ($scenario === 'reregister-shutdown-fatal') { + $handler->register(); + $handler->unregister(); + } + register_shutdown_function(static function (): void { + require __DIR__ . '/compile-fatal.inc'; + }); + $handler->register(); + break; case 'shutdown-once': $handler = new ErrorHandler($reporter, null, new CallableShutdownHandler(static function (): void { event('shutdown'); diff --git a/tests/Functional/ErrorHandlerLifecycleTest.php b/tests/Functional/ErrorHandlerLifecycleTest.php new file mode 100644 index 0000000..4914ee5 --- /dev/null +++ b/tests/Functional/ErrorHandlerLifecycleTest.php @@ -0,0 +1,143 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +namespace CoiSA\ErrorHandler\Test\Functional; + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Covers independent direct deliveries and reporter failure within a middleware scope. + */ +final class ErrorHandlerLifecycleTest extends TestCase +{ + /** + * Verifies a failed direct delivery does not poison later independent calls. + */ + public function testDirectDeliveryCanResumeAfterReporterFailure(): void + { + $calls = 0; + $failure = new \RuntimeException('Temporary reporter failure'); + $handler = new ErrorHandler(new CallableThrowableHandler( + static function () use (&$calls, $failure): void { + if (++$calls === 1) { + throw $failure; + } + } + )); + try { + $handler->handleThrowable(new \RuntimeException('First delivery')); + self::fail('The reporter failure must propagate.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } + $handler->handleThrowable(new \RuntimeException('Independent delivery')); + self::assertSame(2, $calls); + } + + /** + * Verifies unregistering ends suppression even without a new registration. + */ + public function testDirectDeliveryAfterFailedRegistrationIsIndependent(): void + { + $calls = 0; + $handler = new ErrorHandler(new CallableThrowableHandler(static function () use (&$calls): void { + if (++$calls === 1) { + throw new \RuntimeException('Reporter failed'); + } + })); + $handler->register(); + try { + try { + $handler->handleThrowable(new \RuntimeException('First delivery')); + } catch (\RuntimeException) { + } + $second = new \RuntimeException('Suppressed retry'); + try { + $handler->handleThrowable($second); + self::fail('The failed registration must suppress retries.'); + } catch (\RuntimeException $caught) { + self::assertSame($second, $caught); + } + self::assertSame(1, $calls); + } finally { + $handler->unregister(); + } + $handler->handleThrowable(new \RuntimeException('Independent delivery')); + self::assertSame(2, $calls); + } + + /** + * Verifies middleware propagates a failed reporter without rendering through it again. + */ + public function testMiddlewareDoesNotRetryReporterAfterPhpWarning(): void + { + $previousError = set_error_handler(static fn (): bool => false); + restore_error_handler(); + $previousException = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + $calls = 0; + $failure = new \RuntimeException('Reporter failed'); + $reporter = new CallableThrowableHandler(static function () use (&$calls, $failure): void { + ++$calls; + throw $failure; + }); + $psr17 = new Psr17Factory(); + $errors = new ErrorHandler($reporter); + $middleware = new ErrorHandlerMiddleware( + $errors, + new ThrowableResponseFactory(new ThrowableStreamFactory($psr17, $reporter), $psr17) + ); + $downstream = new class () implements RequestHandlerInterface { + /** + * Triggers a reportable warning inside the active middleware scope. + * + * @param ServerRequestInterface $request The synthetic request. + * @return ResponseInterface A response that must never be reached. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + trigger_error('Downstream warning', E_USER_WARNING); + throw new \LogicException('The warning reporter should have failed.'); + } + }; + $previousReporting = error_reporting(E_ALL); + try { + $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); + self::fail('Reporter failure must propagate to the application.'); + } catch (\RuntimeException $caught) { + self::assertSame($failure, $caught); + } finally { + error_reporting($previousReporting); + } + self::assertSame(1, $calls); + $activeError = set_error_handler(static fn (): bool => false); + restore_error_handler(); + $activeException = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + self::assertSame($previousError, $activeError); + self::assertSame($previousException, $activeException); + } +} diff --git a/tests/Release/release.test.mjs b/tests/Release/release.test.mjs new file mode 100644 index 0000000..6324f10 --- /dev/null +++ b/tests/Release/release.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { planRelease, publishRelease } from '../../scripts/release.mjs'; + +const sha = 'a'.repeat(40); +const repository = 'coisa/error-handler'; +const pr = { merged_at: '2026-10-15T00:00:00Z', merge_commit_sha: sha, + base: { ref: 'master', repo: { full_name: repository } }, + head: { ref: 'changeset-release/master', repo: { full_name: repository } }, + user: { login: 'github-actions[bot]' } }; +const input = { manifest: { name: '@coisa/error-handler', private: true, version: '3.0.0' }, + previous: { name: '@coisa/error-handler', private: true, version: '2.0.0' }, + changelog: '# Changelog\n## 3.0.0\n\n### Major Changes\n\nNew lifecycle.\n\n## 2.0.0\nOld notes.', + sha, repository, prs: [pr] }; +const plan = planRelease(input); +test('baseline and unchanged versions do not publish', () => { + assert.equal(planRelease({ ...input, previous: null }), null); + assert.equal(planRelease({ ...input, previous: input.manifest }), null); +}); +test('extracts only the exact release notes', () => { + assert.equal(plan.tag, '3.0.0'); + assert.equal(plan.notes, '### Major Changes\n\nNew lifecycle.'); +}); +for (const [label, changes] of [ + ['ordinary feature PR', { prs: [{ ...pr, head: { ...pr.head, ref: 'feature' } }] }], + ['unmerged PR', { prs: [{ ...pr, merged_at: null }] }], + ['fork', { prs: [{ ...pr, head: { ...pr.head, repo: { full_name: 'other/fork' } } }] }], + ['different merge SHA', { prs: [{ ...pr, merge_commit_sha: 'b'.repeat(40) }] }], + ['non-bot author', { prs: [{ ...pr, user: { login: 'someone' } }] }], + ['duplicate version PR', { prs: [pr, pr] }], + ['missing notes', { changelog: '# Changelog\n## 2.0.0\nOld' }], + ['duplicate notes', { changelog: '## 3.0.0\nA\n## 3.0.0\nB' }], + ['empty notes', { changelog: '## 3.0.0\n\n## 2.0.0\nOld' }], + ['downgrade', { manifest: { ...input.manifest, version: '1.0.0' } }], + ['public npm package', { manifest: { ...input.manifest, private: false } }], +]) test('rejects ' + label, () => assert.throws(() => planRelease({ ...input, ...changes }))); +test('creates an immutable tag and GitHub release', () => { + const writes = []; + publishRelease(plan, (path, body) => { + if (!body) throw Object.assign(new Error('Missing'), { status: 404 }); + writes.push([path, body]); return {}; + }); + assert.deepEqual(writes.map(([path]) => path.split('/').at(-1)), ['refs', 'releases']); + assert.equal(writes[0][1].sha, sha); + assert.equal(writes[1][1].body, plan.notes); +}); +test('matching existing release is idempotent', () => { + publishRelease(plan, (path, body) => { + assert.equal(body, undefined); + return path.includes('/git/ref/') ? { object: { type: 'commit', sha } } : { tag_name: '3.0.0', draft: false }; + }); +}); +test('refuses conflicting tags', () => { + assert.throws(() => publishRelease(plan, () => ({ object: { type: 'commit', sha: 'b'.repeat(40) } })), /elsewhere/); +}); +test('API errors do not masquerade as absent tags', () => { + assert.throws(() => publishRelease(plan, () => { throw Object.assign(new Error('Denied'), { status: 403 }); }), /Denied/); +}); From 48b10c573fb838cc68d89db5cf6f40f9aa73c46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 15 Oct 2026 09:50:26 -0300 Subject: [PATCH 09/27] ci: correct setup-node commit reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .github/workflows/changesets.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index bfa2a08..5e17f7e 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -19,7 +19,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@66d7d1ddafddf1ef45f98ee51a7496f9cdadb2f4 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '24' cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5cd1c3..1e7c086 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@66d7d1ddafddf1ef45f98ee51a7496f9cdadb2f4 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '24' cache: npm From 82f8d3d16c374f9b10c11e4d8e7d4472204125d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 15 Oct 2026 09:58:52 -0300 Subject: [PATCH 10/27] ci: report coverage in a SHA-aware sticky PR note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 1 + .../coverage-templates/with-base-coverage.hbs | 20 +++++ .../without-base-coverage.hbs | 18 ++++ .github/workflows/ci.yml | 62 +++++++++++++ .github/workflows/coverage-comment.yml | 88 +++++++++++++++++++ docs/testing.md | 23 +++++ 6 files changed, 212 insertions(+) create mode 100644 .github/coverage-templates/with-base-coverage.hbs create mode 100644 .github/coverage-templates/without-base-coverage.hbs create mode 100644 .github/workflows/coverage-comment.yml diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 93867f1..d73ed30 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -45,3 +45,4 @@ Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package - Remove the redundant Dockerfile, Compose service and Docker ignore rules; the PHP matrix runs directly in CI. - Stop tracking composer.lock in this library; validate lowest/highest dependency bounds and keep application locks application-owned. - Exclude development tools, npm manifests/lock, Changesets, tests, examples and documentation from Git archives, and verify a production-only installation from the exported package. +- Add PHPUnit coverage reporting, baseline comparison and a single SHA-aware PR sticky note, with a read-only producer and trusted default-branch publisher. diff --git a/.github/coverage-templates/with-base-coverage.hbs b/.github/coverage-templates/with-base-coverage.hbs new file mode 100644 index 0000000..245b97a --- /dev/null +++ b/.github/coverage-templates/with-base-coverage.hbs @@ -0,0 +1,20 @@ +### PHP test coverage + +{{#with overall_coverage}} +Line coverage: **{{new_coverage}}** (base: **{{base_coverage}}**, change: **{{difference}}**). +{{/with}} + +_Minimum: `{{minimum_allowed_coverage}}`. This measures PHPUnit; bounded fatal/HTTP fixtures run separately._ + +{{#if coverage}} +
+Files with coverage changes + +| File | Base | Current | Change | +| :--- | :---: | :---: | :---: | +{{#each coverage}} +| `{{package}}` | {{base_coverage}} | {{new_coverage}} | {{difference}} | +{{/each}} + +
+{{/if}} diff --git a/.github/coverage-templates/without-base-coverage.hbs b/.github/coverage-templates/without-base-coverage.hbs new file mode 100644 index 0000000..2284a1d --- /dev/null +++ b/.github/coverage-templates/without-base-coverage.hbs @@ -0,0 +1,18 @@ +### PHP test coverage + +Line coverage: **{{new_coverage}}**. No default-branch baseline is available yet. + +_Minimum: `{{minimum_allowed_coverage}}`. This measures PHPUnit; bounded fatal/HTTP fixtures run separately._ + +{{#if coverage}} +
+Coverage by file + +| File | Coverage | +| :--- | :---: | +{{#each coverage}} +| `{{package}}` | {{base_coverage}} | +{{/each}} + +
+{{/if}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e82f41..4fe89a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,65 @@ jobs: run: python3 tests/Examples/run.py - name: Verify runtime archive and production-only installation run: python3 tests/Distribution/run.py + + coverage: + name: PHPUnit coverage + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + pull-requests: read + env: + MINIMUM_COVERAGE: ${{ vars.MINIMUM_COVERAGE_THRESHOLD || vars.MINIMUM_COVERAGE || '85' }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2 + with: + php-version: '8.5' + extensions: dom, mbstring, xml, xmlwriter, pdo + coverage: xdebug + tools: composer:v2 + - name: Validate coverage configuration + run: | + if [[ ! "$MINIMUM_COVERAGE" =~ ^([0-9]|[1-9][0-9]|100)$ ]]; then + echo "Minimum coverage must be an integer from 0 to 100." >&2 + exit 1 + fi + - run: composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress + - name: Run PHPUnit with coverage + run: composer --no-plugins test -- --coverage-cobertura tmp/coverage/cobertura.xml + env: + XDEBUG_MODE: coverage + - name: Upload the coverage report + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: coverage-report-php + path: tmp/coverage/cobertura.xml + if-no-files-found: error + - name: Compare coverage and prepare the PR report + id: coverage-diff + uses: clearlyip/code-coverage-report-action@110af9d4ec87b6706f182fd90e3102af9e5203bf # v7 + with: + filename: tmp/coverage/cobertura.xml + artifact_download_workflow_names: CI + artifact_name: php-coverage-%name% + overall_coverage_fail_threshold: ${{ env.MINIMUM_COVERAGE }} + fail_on_negative_difference: 'true' + only_list_changed_files: 'true' + file_coverage_error_min: ${{ env.MINIMUM_COVERAGE }} + file_coverage_warning_max: '90' + without_base_coverage_template: .github/coverage-templates/without-base-coverage.hbs + with_base_coverage_template: .github/coverage-templates/with-base-coverage.hbs + - name: Export markdown for the default-branch sticky-note publisher + if: >- + !cancelled() && github.event_name == 'pull_request' && + steps.coverage-diff.outputs.file != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: coverage-pr-comment + path: ${{ steps.coverage-diff.outputs.file }} + if-no-files-found: error + diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml new file mode 100644 index 0000000..60f2d29 --- /dev/null +++ b/.github/workflows/coverage-comment.yml @@ -0,0 +1,88 @@ +name: PR coverage comment + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: read + actions: read + pull-requests: write + +jobs: + comment: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion != 'cancelled' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # No checkout: privileged code comes from the default branch. + - name: Find the current PR and its report + id: report + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const run = context.payload.workflow_run; + const candidates = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, { + ...context.repo, commit_sha: run.head_sha, per_page: 100, + }); + const repository = context.repo.owner + '/' + context.repo.repo; + const matches = candidates.filter(pr => + pr.state === 'open' && pr.head.sha === run.head_sha && + pr.base.repo.full_name === repository && + pr.head.repo?.full_name === run.head_repository.full_name + ); + if (matches.length !== 1) { + core.info('No unique open PR at this SHA; skipping stale or closed run.'); + return; + } + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, run_id: run.id, + }); + const reports = artifacts.filter(item => item.name === 'coverage-pr-comment' && !item.expired); + if (reports.length !== 1) { + core.info('No unique coverage report; skipping.'); + return; + } + core.setOutput('number', matches[0].number); + core.setOutput('artifact', reports[0].id); + - name: Download only the triggering run's report + if: steps.report.outputs.artifact != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ steps.report.outputs.artifact }} + merge-multiple: true + path: coverage-comment + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + - name: Validate the report and recheck the PR head + id: current + if: steps.report.outputs.artifact != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ steps.report.outputs.number }} + with: + script: | + const fs = require('node:fs'); + const number = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(number) || number <= 0) throw new Error('Invalid PR number.'); + const run = context.payload.workflow_run; + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); + if (pr.state !== 'open' || pr.head.sha !== run.head_sha || + pr.head.repo?.full_name !== run.head_repository.full_name) return; + const path = 'coverage-comment/code-coverage-results.md'; + const stat = fs.lstatSync(path); + if (!stat.isFile() || stat.size > 60000) throw new Error('Invalid coverage markdown artifact.'); + const report = fs.readFileSync(path, 'utf8') + + '\n\nCommit: `' + run.head_sha + '`. [Test run](' + run.html_url + ') — ' + run.conclusion + '.\n'; + fs.writeFileSync('coverage-comment.md', report); + core.setOutput('number', number); + - name: Update the sticky coverage note + if: steps.current.outputs.number != '' + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: code-coverage-overview + number: ${{ steps.current.outputs.number }} + path: coverage-comment.md diff --git a/docs/testing.md b/docs/testing.md index 6c2821c..2d409c4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -116,6 +116,29 @@ from this library. PHPDoc and licensing remain explicit review requirements. ## Release and distribution verification +## Coverage and the sticky PR note + +The dedicated coverage job runs PHPUnit on PHP 8.5 with coverage enabled, +exports Cobertura and uses the same report/sticky-comment flow as the CLI +reference. It compares the latest available default-branch baseline, checks a +minimum of 85% line coverage and rejects negative differences when a baseline +exists. Owners can override the minimum through `MINIMUM_COVERAGE_THRESHOLD` +or `MINIMUM_COVERAGE`; values must be integers from 0 to 100. + +The local PHPUnit snapshot before the HTTP redesign is **87.04% lines +(168/193)**. This excludes the separately executed bounded fatal and loopback +HTTP fixtures; low shutdown-method coverage does not imply those child scenarios +were skipped. It is evidence for this exact PHP/dependency snapshot. + +Read-only CI exports Markdown data. A separate default-branch `workflow_run` +publisher checks the current PR SHA and triggering artifact, reads a bounded +regular Markdown file and updates one sticky note with the commit and test-run +link. It does not check out or execute PR code. The publisher handles same-repo, +fork and Dependabot PRs through the same path. Its first live publication requires +the workflow to reach the default branch; this PR does not merge it automatically. + +## Release and distribution verification + ```sh npm ci --ignore-scripts npm run test:release From 611ea938178fb480abf24d51285deb264033ea56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 15 Oct 2026 10:25:23 -0300 Subject: [PATCH 11/27] refactor: separate HTTP reporting from public rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .agents/skills/coisa-error-handler/SKILL.md | 15 +- .changeset/modernize-error-handler.md | 2 + AGENTS.md | 9 +- CHANGELOG.md | 5 +- README.md | 6 +- composer.json | 2 +- docs/api.md | 177 +++++++++++------- docs/examples.md | 111 +++++++++-- docs/fast-forward.md | 21 ++- docs/index.html | 92 +++++++-- docs/migration.md | 18 ++ docs/releases.md | 4 +- docs/testing.md | 21 ++- examples/http-rendering.php | 56 +++++- examples/output-capture.php | 30 +++ src/ErrorHandler.php | 15 +- .../Message/GenericThrowableStreamFactory.php | 46 +++++ src/Http/Message/ThrowableStreamFactory.php | 6 +- .../Middleware/ErrorHandlerMiddleware.php | 18 +- .../ErrorHandlerServiceProvider.php | 4 +- tests/Distribution/run.py | 4 + tests/Examples/run.py | 3 +- .../Middleware/ErrorHandlerMiddlewareTest.php | 156 ++++++++++----- .../ErrorHandlerCompositionTest.php | 19 +- .../ErrorHandlerServiceProviderTest.php | 35 +++- tests/Http/router.php | 4 +- 26 files changed, 661 insertions(+), 218 deletions(-) create mode 100644 examples/output-capture.php create mode 100644 src/Http/Message/GenericThrowableStreamFactory.php diff --git a/.agents/skills/coisa-error-handler/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md index aadcb88..63b3026 100644 --- a/.agents/skills/coisa-error-handler/SKILL.md +++ b/.agents/skills/coisa-error-handler/SKILL.md @@ -94,10 +94,17 @@ after processing and after renderer failure. Create separate middleware for nested scopes. This library does not make callbacks local to concurrent fibers or requests, and it does not emit HTTP responses. -Reporters and renderers have different output responsibilities. Empty captured -reporter output becomes `Internal Server Error`; emitted text becomes the public -body. Keep private diagnostics out of renderer output. Renderers must use -removable, balanced buffers and must not close caller-owned buffers. +Middleware requires one scoped object implementing ErrorHandlerInterface and +ThrowableHandlerInterface. It reports downstream failures once, then invokes the +response factory. Reporters remain void and send diagnostics privately. The +provider's GenericThrowableStreamFactory returns a safe body directly, without +reading reporter output. Implement the stream/response interfaces for JSON; +`docs/examples.md#http-rendering` shows a complete pure renderer. + +The retained ThrowableStreamFactory is an explicit adapter for public echo-based +templates. Pass a separate public renderer there, never a diagnostic reporter +middleware already called. Only this adapter converts output into the body. +Its renderers must balance removable buffers and never close caller-owned buffers. ## Verify the integration diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index d73ed30..2e6d5f8 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -46,3 +46,5 @@ Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package - Stop tracking composer.lock in this library; validate lowest/highest dependency bounds and keep application locks application-owned. - Exclude development tools, npm manifests/lock, Changesets, tests, examples and documentation from Git archives, and verify a production-only installation from the exported package. - Add PHPUnit coverage reporting, baseline comparison and a single SHA-aware PR sticky note, with a read-only producer and trusted default-branch publisher. + +- Separate HTTP reporting and rendering: middleware requires the combined lifecycle/reporting contracts, reports once, then invokes a pure renderer. Keep reporters void, default to GenericThrowableStreamFactory without buffering, demonstrate JSON streams directly, and retain explicit public output capture as a compatibility adapter. diff --git a/AGENTS.md b/AGENTS.md index b90fd95..01fa83a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,10 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed factories for reporter composition, optional handlers and fresh middleware scopes; use autowiring for plain constructor injection. A possible move to php-fast-forward does not itself change package identity or namespace. +- HTTP middleware requires lifecycle and throwable-reporting contracts on the same + scoped handler. Report once, then invoke a pure response renderer. Keep the generic + stream as the provider default and output capture as an explicit public-renderer + adapter; never invoke the diagnostic reporter again during rendering. - Reporters own diagnostic output; HTTP fallback bodies must not leak exception details. A shutdown callback reports a fatal error and does not recover it. @@ -43,8 +47,9 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed Inspect the current branch and relevant interfaces before changes. Update source, meaningful regression tests, examples, affected guides and a pending -Changeset together. Keep runtime code independent of Node release tooling. Keep the skill aligned with tested public APIs, without -copying the full manual or adding host-specific dependencies. +Changeset together. Keep runtime code independent of Node release tooling. Keep +the skill aligned with tested public APIs, without copying the full manual or +adding host-specific dependencies. Run fatal-error and process-termination experiments only through bounded child fixtures. Reuse the existing fire/HTTP runners and their temporary data. Never diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f25ca0..1a59d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,7 @@ # Changelog -This branch describes an **unreleased next major** after 2.0.0. The complete pending -release notes are in [.changeset/modernize-error-handler.md](.changeset/modernize-error-handler.md). -Changesets will generate a versioned section when the reviewed version PR is prepared. +Pending release notes are maintained through [Changesets](.changeset/README.md). +Versioned sections are generated in the reviewed version PR. See [release maintenance](docs/releases.md). Historical [2.0.0](https://github.com/coisa/error-handler/releases/tag/2.0.0) and diff --git a/README.md b/README.md index 2851ec5..90eb944 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,11 @@ Dependabot is configured to propose Composer, npm release-tooling and GitHub Act ## Complete usage recipes The [usage cookbook](docs/examples.md) and [documentation site](docs/index.html#examples) -show all built-in adapters with eight runnable examples: process bootstrap, +show all built-in adapters with nine runnable examples: process bootstrap, Interop provider, ordered reporters, PSR-14 events with PSR-3 logging, HTTP -middleware, safe JSON rendering, custom PHP/shutdown handlers and nested scopes. +middleware, safe JSON rendering, custom PHP/shutdown handlers, nested scopes and explicit compatibility output capture. +Reporters keep their void contract; HTTP middleware reports once, then uses a +pure renderer. JSON examples return streams directly without echo or buffering. CI executes every recipe and verifies that the displayed code matches its source. ## Releases diff --git a/composer.json b/composer.json index f81831e..537573f 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "@http-test" ], "analyze": "phpstan analyze --no-progress", - "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-check": "php-cs-fixer fix --dry-run --diff --using-cache=no", "cs-fix": [ "php-cs-fixer fix", "phpcbf" diff --git a/docs/api.md b/docs/api.md index c60784a..20791c6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,81 +1,132 @@ # API and composition -All classes are under `CoiSA\ErrorHandler`. Handler interfaces remain small so reporting, application termination, HTTP rendering and event dispatch can be composed explicitly. +All classes are under `CoiSA\ErrorHandler`. This describes the unreleased next +major after 2.0. Complete runnable integrations are in the [cookbook](examples.md). -Complete, executable recipes for all adapters are in the [usage cookbook](examples.md) -and inline on the [documentation site](index.html#examples). - -## Lifecycle +## Lifecycle and diagnostic reporting ```php -$handler = new ErrorHandler($reporter, $optionalPhpErrorHandler, $optionalShutdownHandler); -$handler->register(); +$errors = new ErrorHandler($reporter, $optionalPhpErrorHandler, $optionalShutdownHandler); +$errors->register(); try { - // Work that must return or propagate to a caller. + // Work whose caller owns propagated failures. } finally { - $handler->unregister(); + $errors->unregister(); } ``` -Use this scoped form for code whose caller handles propagated exceptions. For process-wide uncaught exceptions, register at application bootstrap and keep the handler active through termination, as in [`examples/cli.php`](../examples/cli.php). PHP executes `finally` before dispatching an uncaught exception, so unregistering there intentionally returns responsibility to the previous exception handler. - -Registration pushes PHP callbacks onto the native stacks. Previous callbacks are restored, including their error-level masks, when unregistered in reverse order. They are **not automatically called** while this handler is active. A second registration of the same instance is a no-op, not a nested reference count. Shutdown registration happens once per instance because PHP provides no shutdown-unregister function; inactive instances do nothing at shutdown. Reuse an instance when practical: PHP retains registered shutdown callbacks until process exit. +For process-wide uncaught handling, keep the handler registered through termination, +as in [the CLI example](../examples/cli.php). PHP executes finally before dispatching +an uncaught exception, so unregistering there returns responsibility to the previous +handler. Direct `handleThrowable()` delivers the original object and returns; the +registered uncaught callback exits 1 when reporting returns normally. -Only the active instance receives shutdown events. This includes a custom shutdown callback after an uncaught throwable has been reported: PHP temporarily clears the exception callback during its invocation, and the library tracks that terminating path. Replacing either callback with another integration still transfers ownership away. Restore that callback before unregistering this instance; otherwise `unregister()` rejects the operation without altering either stack. +Registration installs process-wide PHP callbacks. Unregister in reverse order, +restoring earlier callback identities and severity masks. Previous callbacks are +not automatically called while this handler is active. Re-registering an active +instance is a no-op. PHP retains one shutdown callback per instance; inactive +instances do nothing. Reuse handlers for sequential work and use distinct instances +for nested scopes. These stacks do not provide concurrent-request/fiber isolation. -| API | Behavior | +| Contract | Responsibility | | --- | --- | -| `ErrorHandlerInterface::register(): void` | Installs error, exception and shutdown handling | -| `ErrorHandlerInterface::unregister(): void` | Restores previous error/exception callbacks; deactivates shutdown | -| `ThrowableHandlerInterface::handleThrowable(Throwable): void` | Reports the original object; a direct call does not exit | -| `PhpErrorHandlerInterface::handlePhpError(int, string, string, int): void` | Processes a PHP error with severity, message, file and line | -| `ShutdownHandlerInterface::handleShutdown(): void` | Handles a shutdown notification | - -`ErrorHandler` defaults to `ThrowErrorExceptionPhpErrorHandler` and `PhpLastErrorShutdownHandler`. The former respects the current `error_reporting()` bitmask (including `@`) and converts reportable errors to the package's `ErrorException`. The outer `ErrorHandler` delivers that exception and then resumes normal execution for recoverable warnings. The shutdown handler inspects `error_get_last()` for `E_ERROR`, `E_PARSE`, `E_CORE_ERROR` or `E_COMPILE_ERROR`; it does not turn fatal termination into recovery. An engine fatal inside another shutdown callback aborts the remaining shutdown queue, including callbacks registered after it. Re-registering with a new callback cannot recover that path. - -A reporter that throws is marked failed and is not retried during the same registration. Recursive calls through the same `ErrorHandler` raise `LogicException`. Independent unregistered direct calls propagate reporter failures without retaining the failed flag. `ErrorHandler::hasReporterFailed()` exposes failure suppression only during an active registration. The built-in middleware checks this state and propagates a reporter failure without rendering through that reporter again. Custom `ErrorHandlerInterface` implementations own their failure policy. Direct calls propagate reporter failures; uncaught reporter failures fall back to PHP's native failure path. `unregister()` followed by `register()` begins a fresh lifecycle. The library does not choose an alternative logger or retry remote delivery. - -## Reporter composition and PSR-14 - -`CallableThrowableHandler` adapts `callable(Throwable): void`. `ThrowableHandlerAggregate` accepts variadic reporters and supports `attach()`; it calls them in attachment order with the same throwable object. It stops if a reporter throws. Use a custom composite when independent best-effort reporting is required. - -`DispatchThrowableHandler` dispatches the throwable itself through a PSR-14 dispatcher. `DispatchErrorEventThrowableHandler` dispatches an `ErrorEvent` containing it. `ThrowableListenerProvider` and `ErrorEventListenerProvider` select compatible events; the callable listeners adapt application callbacks. `LogErrorEventListener` forwards an error event to a PSR-3 logger. Event stringification contains exception details and is intended for private diagnostic sinks, not untrusted HTTP output. - -## PSR-15/PSR-17 HTTP +| `ErrorHandlerInterface` | Register and unregister lifecycle callbacks | +| `ThrowableHandlerInterface::handleThrowable(Throwable): void` | Deliver a diagnostic without producing an HTTP representation | +| `PhpErrorHandlerInterface` | Apply policy to PHP severity/message/file/line | +| `ShutdownHandlerInterface` | Handle a shutdown notification | + +The default `ThrowErrorExceptionPhpErrorHandler` honors `error_reporting()` and +suppression, converts reportable errors and lets the outer handler report them. +Recoverable warnings continue. The default `PhpLastErrorShutdownHandler` inspects +`E_ERROR`, `E_PARSE`, `E_CORE_ERROR` and `E_COMPILE_ERROR`, without recovery. +A fatal inside another shutdown callback aborts the remaining queue even for a +fresh handler; adding generations cannot rescue that engine path. + +Only the active lifecycle handles shutdown, including custom shutdown after an +uncaught throwable. Replacing a callback transfers ownership away. Restore it +before unregistering; otherwise unregister rejects the operation without altering +the native stacks. + +A reporter failure suppresses retries for the current registration. Recursion +through the same handler raises LogicException. Independent unregistered direct +calls retain no failed flag. Unregister/register starts a fresh lifecycle. +No alternative logger or remote retry is installed implicitly. + +## Callable reporters, aggregates and PSR-14 + +`CallableThrowableHandler` adapts `callable(Throwable): void`. +`ThrowableHandlerAggregate` accepts variadic reporters and `attach()`, delivering +the same object in order and stopping on the first failure. + +`DispatchThrowableHandler` dispatches the original throwable through PSR-14. +`DispatchErrorEventThrowableHandler` dispatches an `ErrorEvent` wrapper. +The two listener providers select Throwable objects or the built-in ErrorEvent; +callable listeners adapt application callbacks. `LogErrorEventListener` forwards +the diagnostic event to an application PSR-3 logger. Stringifying ErrorEvent +includes private diagnostics and must not produce a public HTTP body. +See [the complete event/logger example](examples.md#events). + +## HTTP: report once, then render explicitly + +`ErrorHandlerMiddleware` accepts an +`ErrorHandlerInterface&ThrowableHandlerInterface` instance and a +`ThrowableResponseFactoryInterface`. The built-in ErrorHandler already implements +both contracts. A custom lifecycle implementation must support reporting too. + +On a downstream Throwable, middleware first calls the scoped handler, then asks +the response factory for a public representation. A reporter failure propagates +after cleanup; its failed-registration guard prevents a second reporter attempt +when a PHP warning caused that failure. Rendering failure also propagates after +cleanup. Successful downstream responses pass through unchanged. + +Diagnostic reporters keep their `void` contract, work in CLI/events/shutdown, +and do not need PSR-7. In HTTP, send diagnostics to private sinks. Reporter echo +is unsolicited output; the default middleware does not treat it as the response +body. Pure renderers return StreamInterface or ResponseInterface directly. ```php -$streams = new ThrowableStreamFactory($psr17Factory, $reporter); +$streams = new GenericThrowableStreamFactory($psr17Factory); $responses = new ThrowableResponseFactory($streams, $psr17Factory); -$middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); -$response = $middleware->process($serverRequest, $requestHandler); +$middleware = new ErrorHandlerMiddleware(new ErrorHandler($privateReporter), $responses); +$response = $middleware->process($request, $application); ``` -Use the `Http\Message` and `Http\Middleware` namespaces for these classes. The PSR-17 factory must implement both stream and response factories. A complete runnable composition is in [`tests/Http/router.php`](../tests/Http/router.php). - -The middleware returns normal downstream responses unchanged and catches downstream `Throwable` objects to generate an error response. It always unregisters its handler after processing. Use a distinct handler instance for each nested middleware scope; do not share/pre-register one instance across simultaneous requests or fibers. PHP's global callback stacks are process state, not request-local storage. - -`ThrowableStreamFactory` captures reporter output. Empty output maps to `Internal Server Error`; `0` is a valid body. On renderer failure, cleanup removes reachable removable buffers and preserves the renderer exception. It stops if the top buffer is non-removable or cleanup makes no progress. PHP cannot remove a non-removable buffer or reach the buffers beneath it; those remain active, so renderers must never create non-removable buffers. Renderers must balance their own successful nested buffers and must not close the caller's buffers. `ThrowableResponseFactory` keeps integer codes 400–599, uses 500 otherwise, and lets the PSR-17 factory supply the standard reason phrase. - -Neither the middleware nor the global handler emits PSR-7 responses to a server. The application/framework owns emission, headers, content type and transport. A PHP engine fatal error bypasses normal middleware recovery; shutdown reporting cannot reliably replace an already-started HTTP response. - -## Interop service-provider integration - -`ServiceProvider\ErrorHandlerServiceProvider` implements the Interop contract -used by FastForward, with callable factories and no automatic callback -registration. See [FastForward integration](fast-forward.md) for process-service -aliases, application overrides and fresh middleware scopes. - -FastForward Container is required at runtime and owns service resolution, -caching and autowiring. Concrete dispatch and HTTP adapters use constructor -autowiring. Explicit factories remain for optional PHP/shutdown handlers, reporter -composition and independent middleware scopes. `ErrorHandlerContainer` and its -private alias/exception machinery have been removed. - -The Interop provider is the single supported container integration entrypoint. -`Container\ConfigProvider` and its Laminas discovery metadata have been removed. -The three lifecycle factories live under `ServiceProvider\Factory`. Consumers -combine the provider with constructor autowiring, as FastForward's `container()` -does; PSR-11 alone does not define provider registration or autowiring. -See [migration](migration.md) for removed and moved class names. - -Provide `ThrowableHandlerInterface` for a custom reporter. Otherwise `ThrowableHandlerAggregateFactory` composes any available callable/event-dispatch reporters; an empty aggregate intentionally produces no diagnostics. HTTP factories also require PSR-17 `ResponseFactoryInterface` and `StreamFactoryInterface` services. PSR-14 integration needs `EventDispatcherInterface`. These implementations belong to the application; the package ships interfaces and adapters, not a logger, event bus or HTTP framework. +Imports are under `Http\Message` and `Http\Middleware`. The PSR-17 implementation +must provide response and stream factories. The default generic renderer always +returns `Internal Server Error`, without reporter invocation or buffering. +`ThrowableResponseFactory` keeps integer codes 400–599, otherwise uses 500, and +lets PSR-17 supply the standard reason phrase. + +Implement `ThrowableStreamFactoryInterface` for a pure custom body, such as JSON, +or `ThrowableResponseFactoryInterface` when also choosing Content-Type/headers. +See [JSON without echo](examples.md#http-rendering). The application owns emission, +headers, transport and public payload policy; diagnostic details are never a safe +default. A ready opt-in JSON adapter is [tracked separately](https://github.com/coisa/error-handler/issues/7). + +## Optional output-capture adapter + +`ThrowableStreamFactory` remains available explicitly for public echo-based +templates. In that adapter only, output from its injected callable becomes the +stream; a JSON callback would need to echo JSON. It is not the provider default. + +Pass a separate public renderer, not the diagnostic reporter middleware already +called. Empty captured output becomes `Internal Server Error`; `0` remains valid. +The adapter cleans reachable removable buffers after failure and preserves a +renderer failure over cleanup failure. Cleanup stops at a non-removable buffer +or lack of progress. Renderers must balance their own removable buffers and never +close caller-owned buffers. See [the capture recipe](examples.md#output-capture). + +## Interop provider + +`ServiceProvider\ErrorHandlerServiceProvider` uses FastForward Container's +required runtime resolution, caching and constructor autowiring. It never +registers PHP callbacks during discovery/construction. The explicit lifecycle +factories live under `ServiceProvider\Factory`; no parallel standalone container +or native Laminas ConfigProvider remains. + +Merge library definitions first and the application last in one AggregateServiceProvider. +Bind required reporters/renderers explicitly through their interfaces. The default +reporter aggregate discovers optional callable/PSR-14 reporters and is silent when +none are configured. The default HTTP stream alias resolves GenericThrowableStreamFactory. +Applications supply PSR-14, PSR-17 and logger implementations. See [provider wiring](fast-forward.md) +and [migration](migration.md). diff --git a/docs/examples.md b/docs/examples.md index 64912b9..b3b9f66 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,12 +1,14 @@ # Usage cookbook -This guide describes the **unreleased next major** of `coisa/error-handler` for PHP 8.3–8.5. Every recipe below is a complete executable file in `examples/`. Run it from the repository after `composer install --no-plugins --no-scripts`. Load the matching checkout; the published 2.0 API is different. If copying a recipe into an application, adjust the Composer autoload path. +This guide describes the **unreleased next major** of `coisa/error-handler` for PHP 8.3–8.5. Every recipe below is a complete executable file. Run it after `composer install --no-plugins --no-scripts`. Applications resolve their own Composer locks. If copying a recipe, adjust its autoload path. Published 2.0 has different behavior. -The checkout's development dependencies supply Nyholm PSR-7 and Phly EventDispatcher for these examples. Applications choose their own PSR implementations; these packages are not runtime dependencies of this library. To use the same implementations in an application, install `nyholm/psr7` for HTTP and `phly/phly-event-dispatcher` for events. The full FastForward framework is unnecessary. +The checkout supplies Nyholm PSR-7 and Phly EventDispatcher as development dependencies. Applications choose their own PSR implementations; use `composer require nyholm/psr7 phly/phly-event-dispatcher` for the same HTTP/event examples. No full framework is required. + +**Reporting and rendering are separate.** Reporters return void and own private diagnostics. Middleware reports once, then calls a pure response factory. JSON can be returned as a stream without echo or buffering. The optional compatibility capture adapter is demonstrated separately; it converts deliberately public output to a body. ## Built-in adapters -Names are relative to `CoiSA\ErrorHandler`; use the imports in each recipe. +Names below are relative to `CoiSA\ErrorHandler`; use each example's imports. | Adapter or contract | Responsibility | Recipe | | --- | --- | --- | @@ -22,7 +24,8 @@ Names are relative to `CoiSA\ErrorHandler`; use the imports in each recipe. | `ThrowableListenerProvider / ErrorEventListenerProvider` | Select the supported event category | [Example](#events) | | `LogErrorEventListener` | Send an ErrorEvent to an application PSR-3 logger | [Example](#events) | | `ErrorHandlerServiceProvider / ThrowableHandlerAggregateFactory` | Lazy Interop definitions and optional reporter discovery | [Example](#fast-forward) | -| `ThrowableStreamFactory / ThrowableStreamFactoryInterface` | Capture deliberate output; generic fallback when empty | [Example](#http-rendering) | +| `GenericThrowableStreamFactory` | Default public stream; no reporting or output buffer | [Example](#http-middleware) | +| `ThrowableStreamFactory / ThrowableStreamFactoryInterface` | Optional capture adapter for deliberately public templates | [Example](#output-capture) | | `ThrowableResponseFactory / ThrowableResponseFactoryInterface` | Map integer error codes 400–599; otherwise 500 | [Example](#http-rendering) | | `ErrorHandlerMiddleware` | Scoped PSR-15 processing; return a PSR-7 response | [Example](#http-middleware) | | `ErrorHandlerFactory / ErrorHandlerMiddlewareFactory` | Fresh handler and middleware lifetimes | [Example](#scopes) | @@ -241,7 +244,7 @@ printf("Private log records: %d\n", count($logger->records)); ## PSR-15 HTTP middleware with the provider -Bind the application's PSR-17 response and stream factories and a private reporter. ErrorHandlerMiddleware returns successful downstream responses unchanged and maps a downstream Throwable to an error response. The example returns HTTP 503 with Internal Server Error, reports the failure privately, then reports a warning through the restored process handler. It prints Reported: 2. Your application owns response emission, headers and content type. +Bind application PSR-17 factories and a private diagnostic reporter. The middleware reports a downstream Throwable once, then asks a separate factory for a response. GenericThrowableStreamFactory is the provider default: it returns Internal Server Error without invoking a reporter or capturing output. Reporter output is not the response body; HTTP reporters must keep diagnostics in private sinks. The example returns 503 and then reports a warning through the restored process handler, for two private reports. Applications own emission and headers. Run `php examples/http-middleware.php`. [Executable source](../examples/http-middleware.php). @@ -309,9 +312,9 @@ printf("Reported: %d\n", count($reported)); -## Controlled JSON rendering without a container +## Explicit JSON streams without echo or buffering -Compose ThrowableStreamFactory and ThrowableResponseFactory directly. Separate private diagnostic side effects from deliberately public renderer output. This example emits only a fixed JSON error and adds application/json to the resulting response. Never echo exception messages, traces or credentials in a production renderer. To inject these factories into provider middleware, bind ThrowableResponseFactoryInterface explicitly in the application provider. +Keep the reporter's void contract for private diagnostics. Implement ThrowableStreamFactoryInterface to return a JSON StreamInterface directly, and compose it with ThrowableResponseFactory. Middleware reports once before rendering. This complete example adds application/json and exposes only a fixed public message; private throwable details stay in the reporter. A ready JSON response adapter is tracked separately in issue #7. Run `php examples/http-rendering.php`. [Executable source](../examples/http-rendering.php). @@ -320,27 +323,67 @@ Run `php examples/http-rendering.php`. [Executable source](../examples/http-rend declare(strict_types=1); +use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Server\RequestHandlerInterface; require dirname(__DIR__) . '/vendor/autoload.php'; $privateDiagnostics = []; $reporter = new CallableThrowableHandler( static function (Throwable $error) use (&$privateDiagnostics): void { - $privateDiagnostics[] = $error; // Replace with a private logger. + $privateDiagnostics[] = $error; // Replace with a private logger; emit no response body. } ); -$publicRenderer = new CallableThrowableHandler(static function (): void { - echo json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR); -}); $psr17 = new Psr17Factory(); -$streams = new ThrowableStreamFactory($psr17, new ThrowableHandlerAggregate($reporter, $publicRenderer)); +$streams = new class ($psr17) implements ThrowableStreamFactoryInterface { + /** + * Stores the application's PSR-17 factory. + * + * @param StreamFactoryInterface $streams Creates public response body streams. + */ + public function __construct(private StreamFactoryInterface $streams) + { + } + + /** + * Returns controlled JSON directly, independently of diagnostic reporting. + * + * @param Throwable $throwable The private failure whose details stay undisclosed. + * + * @return StreamInterface A public JSON body without echo or output buffering. + */ + public function createStreamFromThrowable(Throwable $throwable): StreamInterface + { + return $this->streams->createStream( + json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR) + ); + } +}; $responses = new ThrowableResponseFactory($streams, $psr17); -$response = $responses->createResponseFromThrowable(new RuntimeException('Private credentials', 503)) +$middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); +$downstream = new class () implements RequestHandlerInterface { + /** + * Simulates a private application failure with an HTTP error code. + * + * @param ServerRequestInterface $request The incoming request. + * + * @return ResponseInterface The error response produced by middleware. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Private credentials', 503); + } +}; +$response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream) ->withHeader('Content-Type', 'application/json'); printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type')); @@ -520,12 +563,42 @@ printf( printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported); ``` + + +## Optional output-capture compatibility adapter + +ThrowableStreamFactory remains available for deliberately public echo-based templates. In this adapter alone, output from its injected callable becomes the response stream; a JSON template would have to echo JSON. New JSON renderers can return a stream directly as above. The provider does not select this adapter by default. Middleware already reports diagnostics: pass a separate public renderer here, never the same diagnostic reporter again. Empty captured output becomes Internal Server Error; 0 remains a valid body. + +Run `php examples/output-capture.php`. [Executable source](../examples/output-capture.php). + +```php +createStreamFromThrowable(new RuntimeException('Private diagnostics')); + +printf("Captured body: %s\n", (string) $body); +``` + ## Failure and lifetime boundaries -Unregister in reverse registration order and use distinct handlers for nested scopes. A second register on the same active instance is a no-op. PHP retains one shutdown callback per instance until process exit; inactive callbacks do nothing. A fatal inside another shutdown callback can abort the remaining shutdown queue, even for a fresh handler; it is outside the reporting guarantee. +Unregister in reverse order and use distinct handlers for nested scopes. Registering an active instance again is a no-op. PHP retains one shutdown callback per instance; inactive callbacks do nothing. A fatal inside another shutdown callback can abort the remaining queue, even with a fresh handler. -A reporter failure during a registration suppresses further delivery for that lifecycle. `ErrorHandler::hasReporterFailed()` exposes this state for integrations. Built-in middleware propagates that failure and restores callbacks instead of passing it to the renderer and retrying the reporter. A custom implementation of `ErrorHandlerInterface` owns its own failure policy. Independent unregistered direct calls do not retain the failed flag. +Middleware accepts a handler implementing both ErrorHandlerInterface and ThrowableHandlerInterface. The same instance reports PHP errors and downstream throwables, preserving its recursion/failure guard. A reporter failure propagates after callback cleanup and is not re-rendered through that reporter. A renderer failure also propagates after cleanup. Custom implementations own their failure policy. Independent unregistered direct deliveries do not retain a failed flag. -Reporter output captured by `ThrowableStreamFactory` becomes the public response body. Empty output becomes `Internal Server Error`; `0` remains a valid body. Renderers must balance removable buffers and never close caller buffers. A renderer or response-factory failure propagates after middleware cleanup; no HTTP response is emitted automatically. +The default GenericThrowableStreamFactory returns a controlled body and does not inspect reporter output. Reporters used in HTTP must not echo diagnostics: unsolicited output is outside the response contract. The optional ThrowableStreamFactory still captures its injected public renderer's output. Keep it separate from the diagnostic reporter already called by middleware. Its renderers must balance removable buffers and never close caller buffers; renderer failures retain precedence during cleanup. -Use explicit required reporter/renderer interface bindings and application-last merged definitions. FastForward can retry construction or fall back to autowiring concrete classes; do not infer successful delivery from `has()` or assume exception identity survives container resolution. See [provider details](fast-forward.md), [API contracts](api.md), [migration](migration.md) and [validation](testing.md). +Use explicit required reporter/renderer interface bindings and application-last merged definitions. FastForward can retry construction and fall back to autowiring concrete classes. See [provider details](fast-forward.md), [API](api.md), [migration](migration.md) and [validation](testing.md). The ready JSON factory proposal is [issue #7](https://github.com/coisa/error-handler/issues/7). diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 71f610c..9a322b1 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -98,13 +98,13 @@ There is no provider boot hook. The application decides when to register. | `ErrorHandlerInterface` / `ErrorHandler` | The same cached process handler; initially unregistered | | `ThrowableHandlerInterface` | Default aggregate of available callable and PSR-14 reporters | | `DispatchThrowableHandler` / `DispatchErrorEventThrowableHandler` | Existing PSR-14 adapters; require an application dispatcher | -| `ThrowableStreamFactoryInterface` / `ThrowableResponseFactoryInterface` | Existing rendering adapters; require PSR-17 stream/response factories | +| `ThrowableStreamFactoryInterface` / `ThrowableResponseFactoryInterface` | Pure generic stream and response adapters; require PSR-17 factories | | `ErrorHandlerMiddleware` | Cached middleware with a handler separate from the process handler | | `ServiceProvider\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation | | `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` | Reusable callable that creates fresh middleware and a fresh handler | The provider has nine explicit definitions. Dispatch handlers and concrete -stream/response adapters are resolved through FastForward's constructor +generic stream/response adapters are resolved through FastForward's constructor autowiring. Their interface aliases remain explicit, and application definitions can override either an interface or its concrete implementation. The three library factories that remain encode optional-handler defaults, reporter @@ -226,3 +226,20 @@ If the built-in handler's reporter fails during a request warning, middleware propagates that failure and restores callbacks without rendering it through the reporter again. Ordinary downstream failures still use the configured response factory. See [failure boundaries](examples.md#failure-and-lifetime-boundaries). + +## Reporting and rendering in this major + +Middleware requires one scoped instance implementing both `ErrorHandlerInterface` +and `ThrowableHandlerInterface`. It reports downstream failures before invoking +the response factory. The default `ThrowableStreamFactoryInterface` alias now +selects `Http\Message\GenericThrowableStreamFactory`, which uses PSR-17 directly +and produces a controlled body without invoking reporters or output buffering. +A lifecycle-only custom implementation must add reporting to use the middleware. + +Reporters keep their void contract and must write HTTP diagnostics privately. +Implement a stream/response factory for JSON; see the complete +[renderer example](examples.md#http-rendering). The retained `ThrowableStreamFactory` +captures a separate public renderer only when explicitly selected. Binding the +old concrete capture class no longer changes the default alias. Bind the +interface for required custom rendering, and do not inject the diagnostic reporter +into a capture adapter after middleware has already reported it. diff --git a/docs/index.html b/docs/index.html index 725b79f..badb440 100644 --- a/docs/index.html +++ b/docs/index.html @@ -14,7 +14,7 @@
{!}CoiSA / Error HandlerView source ↗
- +
A small PHP library. An explicit failure contract.
@@ -49,9 +49,9 @@

Make failure
observable.

$errors = new ErrorHandler($reporter); $errors->register();
-

Keep diagnostic output in an appropriate private sink. A reporter that emits nothing produces the generic HTTP body Internal Server Error.

+

Keep diagnostic output in an appropriate private sink. HTTP middleware reports once, then uses a separate renderer. The default body is Internal Server Error; JSON renderers return a stream directly.

-
Usage cookbook

Choose an integration. Run the complete example.

These examples target the unreleased next major. The checkout includes Nyholm PSR-7 and Phly EventDispatcher as development dependencies. Applications own their PSR-17, PSR-14 and PSR-3 implementations. Adjust the autoload path when copying a file into an application.

Install the example implementations in an application with composer require nyholm/psr7 phly/phly-event-dispatcher when using both HTTP and event recipes.

Built-in adapterResponsibilityUsage
ErrorHandler / ErrorHandlerInterfaceProcess registration, callback restoration and direct deliveryExample
CallableThrowableHandler / ThrowableHandlerInterfaceApplication-owned reporting adapterExample
ThrowableHandlerAggregateOrdered reporters; stop when a reporter throwsExample
ThrowErrorExceptionPhpErrorHandler / PhpErrorHandlerInterfaceDefault severity-aware conversion and custom PHP policyExample
PhpLastErrorShutdownHandler / ShutdownHandlerInterfaceDefault fatal inspection and application shutdown policyExample
CallableShutdownHandlerAdapt a shutdown callbackExample
DispatchThrowableHandler / DispatchErrorEventThrowableHandlerDispatch the original object or an ErrorEvent wrapperExample
ErrorEvent / ErrorEventInterfaceAccess getThrowable(); diagnostic __toString() for private sinksExample
ThrowableCallableListener / ErrorEventCallableListenerAdapt application event callbacksExample
ThrowableListenerProvider / ErrorEventListenerProviderSelect the supported event categoryExample
LogErrorEventListenerSend an ErrorEvent to an application PSR-3 loggerExample
ErrorHandlerServiceProvider / ThrowableHandlerAggregateFactoryLazy Interop definitions and optional reporter discoveryExample
ThrowableStreamFactory / ThrowableStreamFactoryInterfaceCapture deliberate output; generic fallback when emptyExample
ThrowableResponseFactory / ThrowableResponseFactoryInterfaceMap integer error codes 400–599; otherwise 500Example
ErrorHandlerMiddlewareScoped PSR-15 processing; return a PSR-7 responseExample
ErrorHandlerFactory / ErrorHandlerMiddlewareFactoryFresh handler and middleware lifetimesExample
+
Usage cookbook

Choose an integration. Run the complete example.

Nine complete recipes cover every built-in adapter. This is the unreleased next major. The checkout includes Nyholm PSR-7 and Phly EventDispatcher as development dependencies; applications own PSR implementations. Adjust the Composer autoload path when copying a file.

For these HTTP/event examples, applications can install nyholm/psr7 and phly/phly-event-dispatcher. Diagnostic reporters return void; HTTP renderers return streams/responses.

Built-in adapterResponsibilityUsage
ErrorHandler / ErrorHandlerInterfaceProcess registration, callback restoration and direct deliveryExample
CallableThrowableHandler / ThrowableHandlerInterfaceApplication-owned reporting adapterExample
ThrowableHandlerAggregateOrdered reporters; stop when a reporter throwsExample
ThrowErrorExceptionPhpErrorHandler / PhpErrorHandlerInterfaceDefault severity-aware conversion and custom PHP policyExample
PhpLastErrorShutdownHandler / ShutdownHandlerInterfaceDefault fatal inspection and application shutdown policyExample
CallableShutdownHandlerAdapt a shutdown callbackExample
DispatchThrowableHandler / DispatchErrorEventThrowableHandlerDispatch the original object or an ErrorEvent wrapperExample
ErrorEvent / ErrorEventInterfaceAccess getThrowable(); diagnostic __toString() for private sinksExample
ThrowableCallableListener / ErrorEventCallableListenerAdapt application event callbacksExample
ThrowableListenerProvider / ErrorEventListenerProviderSelect the supported event categoryExample
LogErrorEventListenerSend an ErrorEvent to an application PSR-3 loggerExample
ErrorHandlerServiceProvider / ThrowableHandlerAggregateFactoryLazy Interop definitions and optional reporter discoveryExample
GenericThrowableStreamFactoryDefault public stream; no reporting or output bufferExample
ThrowableStreamFactory / ThrowableStreamFactoryInterfaceOptional capture adapter for deliberately public templatesExample
ThrowableResponseFactory / ThrowableResponseFactoryInterfaceMap integer error codes 400–599; otherwise 500Example
ErrorHandlerMiddlewareScoped PSR-15 processing; return a PSR-7 responseExample
ErrorHandlerFactory / ErrorHandlerMiddlewareFactoryFresh handler and middleware lifetimesExample
Executable recipe · examples/cli.php

Process bootstrap

Keep one handler registered through process termination. Recoverable warnings report and continue; a successfully reported uncaught exception exits 1. This example deliberately exits 1 and writes two diagnostic lines to stderr. Do not wrap process bootstrap in a finally that unregisters before the uncaught callback runs.

@@ -240,7 +240,7 @@

Make failure
observable.

Executable recipe · examples/http-middleware.php

PSR-15 HTTP middleware with the provider

-

Bind the application's PSR-17 response and stream factories and a private reporter. ErrorHandlerMiddleware returns successful downstream responses unchanged and maps a downstream Throwable to an error response. The example returns HTTP 503 with Internal Server Error, reports the failure privately, then reports a warning through the restored process handler. It prints Reported: 2. Your application owns response emission, headers and content type.

+

Bind application PSR-17 factories and a private diagnostic reporter. The middleware reports a downstream Throwable once, then asks a separate factory for a response. GenericThrowableStreamFactory is the provider default: it returns Internal Server Error without invoking a reporter or capturing output. Reporter output is not the response body; HTTP reporters must keep diagnostics in private sinks. The example returns 503 and then reports a warning through the restored process handler, for two private reports. Applications own emission and headers.

RUN FROM THE CHECKOUT
php examples/http-middleware.php
Complete PHP example
examples/http-middleware.php
<?php
 
@@ -303,34 +303,74 @@ 

Make failure
observable.

// Your HTTP framework/emitter sends the response; this example only inspects it.
-
Executable recipe · examples/http-rendering.php

Controlled JSON rendering without a container

-

Compose ThrowableStreamFactory and ThrowableResponseFactory directly. Separate private diagnostic side effects from deliberately public renderer output. This example emits only a fixed JSON error and adds application/json to the resulting response. Never echo exception messages, traces or credentials in a production renderer. To inject these factories into provider middleware, bind ThrowableResponseFactoryInterface explicitly in the application provider.

+
Executable recipe · examples/http-rendering.php

Explicit JSON streams without echo or buffering

+

Keep the reporter's void contract for private diagnostics. Implement ThrowableStreamFactoryInterface to return a JSON StreamInterface directly, and compose it with ThrowableResponseFactory. Middleware reports once before rendering. This complete example adds application/json and exposes only a fixed public message; private throwable details stay in the reporter. A ready JSON response adapter is tracked separately in issue #7.

RUN FROM THE CHECKOUT
php examples/http-rendering.php
Complete PHP example
examples/http-rendering.php
<?php
 
 declare(strict_types=1);
 
+use CoiSA\ErrorHandler\ErrorHandler;
 use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
-use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;
 use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
-use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
+use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface;
+use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
 use Nyholm\Psr7\Factory\Psr17Factory;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Server\RequestHandlerInterface;
 
 require dirname(__DIR__) . '/vendor/autoload.php';
 
 $privateDiagnostics = [];
 $reporter = new CallableThrowableHandler(
     static function (Throwable $error) use (&$privateDiagnostics): void {
-        $privateDiagnostics[] = $error; // Replace with a private logger.
+        $privateDiagnostics[] = $error; // Replace with a private logger; emit no response body.
     }
 );
-$publicRenderer = new CallableThrowableHandler(static function (): void {
-    echo json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR);
-});
 $psr17 = new Psr17Factory();
-$streams = new ThrowableStreamFactory($psr17, new ThrowableHandlerAggregate($reporter, $publicRenderer));
+$streams = new class ($psr17) implements ThrowableStreamFactoryInterface {
+    /**
+     * Stores the application's PSR-17 factory.
+     *
+     * @param StreamFactoryInterface $streams Creates public response body streams.
+     */
+    public function __construct(private StreamFactoryInterface $streams)
+    {
+    }
+
+    /**
+     * Returns controlled JSON directly, independently of diagnostic reporting.
+     *
+     * @param Throwable $throwable The private failure whose details stay undisclosed.
+     *
+     * @return StreamInterface A public JSON body without echo or output buffering.
+     */
+    public function createStreamFromThrowable(Throwable $throwable): StreamInterface
+    {
+        return $this->streams->createStream(
+            json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR)
+        );
+    }
+};
 $responses = new ThrowableResponseFactory($streams, $psr17);
-$response = $responses->createResponseFromThrowable(new RuntimeException('Private credentials', 503))
+$middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses);
+$downstream = new class () implements RequestHandlerInterface {
+    /**
+     * Simulates a private application failure with an HTTP error code.
+     *
+     * @param ServerRequestInterface $request The incoming request.
+     *
+     * @return ResponseInterface The error response produced by middleware.
+     */
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        throw new RuntimeException('Private credentials', 503);
+    }
+};
+$response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream)
     ->withHeader('Content-Type', 'application/json');
 
 printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type'));
@@ -496,6 +536,30 @@ 

Make failure
observable.

$outer !== $inner ? 'yes' : 'no' ); printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported);
+
+
+
Executable recipe · examples/output-capture.php

Optional output-capture compatibility adapter

+

ThrowableStreamFactory remains available for deliberately public echo-based templates. In this adapter alone, output from its injected callable becomes the response stream; a JSON template would have to echo JSON. New JSON renderers can return a stream directly as above. The provider does not select this adapter by default. Middleware already reports diagnostics: pass a separate public renderer here, never the same diagnostic reporter again. Empty captured output becomes Internal Server Error; 0 remains a valid body.

+
RUN FROM THE CHECKOUT
php examples/output-capture.php
+
Complete PHP example
examples/output-capture.php
<?php
+
+declare(strict_types=1);
+
+use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
+use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
+use Nyholm\Psr7\Factory\Psr17Factory;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+// Optional compatibility adapter: only deliberately public output belongs here.
+// Middleware now reports privately before rendering; do not inject its reporter again.
+$publicRenderer = new CallableThrowableHandler(static function (): void {
+    echo 'Public failure page';
+});
+$streams = new ThrowableStreamFactory(new Psr17Factory(), $publicRenderer);
+$body = $streams->createStreamFromThrowable(new RuntimeException('Private diagnostics'));
+
+printf("Captured body: %s\n", (string) $body);
03 / Observable behavior

Know what each boundary does.

diff --git a/docs/migration.md b/docs/migration.md index 244cb05..948cb43 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -60,3 +60,21 @@ registered lifecycle. Independent direct calls remain usable after a reporter failure. Middleware propagates a failed reporter instead of retrying it during response rendering. Custom lifecycle implementations retain their own policy. See the [complete usage recipes](examples.md) and [release workflow](releases.md). + +## Separate HTTP reporting from rendering + +The middleware constructor now requires `ErrorHandlerInterface&ThrowableHandlerInterface`. +The built-in ErrorHandler already satisfies this. Custom lifecycle-only implementations +must also implement diagnostic delivery. Middleware reports a downstream Throwable +once, then invokes the response factory; reporter failures propagate without rendering. + +The provider's stream alias now selects GenericThrowableStreamFactory. Reporter +output is not implicitly captured as the response body. HTTP reporters must use +private sinks. Implement ThrowableStreamFactoryInterface to return JSON or another +public stream directly; the cookbook includes a complete JSON example without echo. +ThrowableHandlerInterface remains void, preserving CLI, event and aggregate roles. + +ThrowableStreamFactory retains explicit output capture for public templates. +Configure a separate public renderer there, rather than a reporter middleware +already invoked. Its old concrete service ID no longer controls the default +stream alias. A ready opt-in JSON response adapter remains a separate additive feature. diff --git a/docs/releases.md b/docs/releases.md index 2e9b6d8..16a55d0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -41,7 +41,9 @@ PR as for any other public API change. 2. Pending notes create or update the bot branch `changeset-release/master`. `npm run version:packages` updates `package.json`, `package-lock.json` and `CHANGELOG.md`, and consumes the notes. It leaves PHP dependency versions alone. -3. Review that version PR, generated notes and exact-head CI. The workflow +3. Review that version PR, generated notes, release-status notices and exact-head CI. + Align development/published-version guidance with the version being released. + The workflow explicitly dispatches PHP CI on the generated branch because PRs created with `GITHUB_TOKEN` do not automatically trigger `pull_request` workflows. 4. Merging the reviewed **bot version PR** authorizes publication. The following diff --git a/docs/testing.md b/docs/testing.md index 2d409c4..aed765d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,7 @@ python3 tests/Examples/run.py The check runs formatting, PHP_CodeSniffer, PHPUnit, maximum-level source PHPStan, bounded child-process fire tests and loopback HTTP tests. Examples are also -formatted and checked. The separate Python command verifies all eight executable +formatted and checked. The separate Python command verifies all nine executable recipes, their exact outputs/exit codes, cookbook/site source parity and HTML anchors. CI resolves lowest and highest allowed dependencies on PHP 8.3, and highest on @@ -73,8 +73,8 @@ construction more than once; exception identity and a single attempt are not pro Independent unregistered `handleThrowable()` calls remain usable after a reporter failure. During registration, failed reporters are suppressed. Built-in middleware -propagates that failure and restores callbacks without invoking its response -renderer through the failed reporter again. A response-factory failure also +propagates that failure and restores callbacks without invoking the response +renderer again. A response-factory failure also propagates after callback restoration. The former Laminas behavior cases now exercise the Interop provider. Removed @@ -89,11 +89,13 @@ delivered duplicate shutdown notifications, and failed/recursive reporters were retried. The current suite adds regressions for the demonstrated defects. The current local PHP 8.5.11/macOS arm64 installation with FastForward 1.6.0 passes -**73 tests / 177 assertions**, maximum source PHPStan, formatting, **31 bounded -scenarios**, three HTTP responses and eight executable examples. Dependency +**75 tests / 180 assertions**, maximum source PHPStan, formatting, **31 bounded +scenarios**, three HTTP responses and nine executable examples. Dependency snapshots used for these local runs are observations, not a committed lock or a promise about future resolution. The earlier isolated highest-dependency run -with FastForward 1.8.0 also passed; new changes require current-head verification. +with FastForward 1.8.0 also passed; the current HTTP follow-up requires current-head verification. Tests additionally +verify report-before-render ordering, no stdout from a private reporter/pure JSON +renderer, successful response identity and concrete response overrides. Earlier Linux aarch64 checks exercised PHP 8.3.33, 8.4.25 and 8.5.10 using official images. The repository Docker/Compose setup is now removed: the version matrix @@ -104,18 +106,17 @@ image experiments are historical evidence, not a supported image interface. The original `coisa/php-cs-fixer:2.1.0` preset installs with PHP CS Fixer 3.95.25, but loading it fails because it extends the removed -`PhpCsFixer\\RuleSet\\AbstractRuleSetDescription`. An isolated installation +`PhpCsFixer\RuleSet\AbstractRuleSetDescription`. An isolated installation reproduced that failure. Its Composer PHP constraint itself allows PHP 8.3+. The original modernization commit did not record a specific rationale. The repository currently selects an explicit PSR-12 baseline for source, tests and examples. This is functional but is not equivalent to the additional CoiSA rules, risky transformations and header/PHPDoc policy of the old preset. -Modernizing that package is a separate PR; do not depend on its unreleased branch +The [preset modernization PR](https://github.com/coisa/php-cs-fixer/pull/3) now passes +its PHP 8.3–8.5 matrix and reusable changed-files workflow; do not depend on its unreleased branch from this library. PHPDoc and licensing remain explicit review requirements. -## Release and distribution verification - ## Coverage and the sticky PR note The dedicated coverage job runs PHPUnit on PHP 8.5 with coverage enabled, diff --git a/examples/http-rendering.php b/examples/http-rendering.php index e24d92c..f460041 100644 --- a/examples/http-rendering.php +++ b/examples/http-rendering.php @@ -13,27 +13,67 @@ * @license https://opensource.org/licenses/MIT MIT License */ +use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Server\RequestHandlerInterface; require dirname(__DIR__) . '/vendor/autoload.php'; $privateDiagnostics = []; $reporter = new CallableThrowableHandler( static function (Throwable $error) use (&$privateDiagnostics): void { - $privateDiagnostics[] = $error; // Replace with a private logger. + $privateDiagnostics[] = $error; // Replace with a private logger; emit no response body. } ); -$publicRenderer = new CallableThrowableHandler(static function (): void { - echo json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR); -}); $psr17 = new Psr17Factory(); -$streams = new ThrowableStreamFactory($psr17, new ThrowableHandlerAggregate($reporter, $publicRenderer)); +$streams = new class ($psr17) implements ThrowableStreamFactoryInterface { + /** + * Stores the application's PSR-17 factory. + * + * @param StreamFactoryInterface $streams Creates public response body streams. + */ + public function __construct(private StreamFactoryInterface $streams) + { + } + + /** + * Returns controlled JSON directly, independently of diagnostic reporting. + * + * @param Throwable $throwable The private failure whose details stay undisclosed. + * + * @return StreamInterface A public JSON body without echo or output buffering. + */ + public function createStreamFromThrowable(Throwable $throwable): StreamInterface + { + return $this->streams->createStream( + json_encode(['error' => 'Service unavailable'], JSON_THROW_ON_ERROR) + ); + } +}; $responses = new ThrowableResponseFactory($streams, $psr17); -$response = $responses->createResponseFromThrowable(new RuntimeException('Private credentials', 503)) +$middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); +$downstream = new class () implements RequestHandlerInterface { + /** + * Simulates a private application failure with an HTTP error code. + * + * @param ServerRequestInterface $request The incoming request. + * + * @return ResponseInterface The error response produced by middleware. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Private credentials', 503); + } +}; +$response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream) ->withHeader('Content-Type', 'application/json'); printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type')); diff --git a/examples/output-capture.php b/examples/output-capture.php new file mode 100644 index 0000000..20eec05 --- /dev/null +++ b/examples/output-capture.php @@ -0,0 +1,30 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use Nyholm\Psr7\Factory\Psr17Factory; + +require dirname(__DIR__) . '/vendor/autoload.php'; + +// Optional compatibility adapter: only deliberately public output belongs here. +// Middleware now reports privately before rendering; do not inject its reporter again. +$publicRenderer = new CallableThrowableHandler(static function (): void { + echo 'Public failure page'; +}); +$streams = new ThrowableStreamFactory(new Psr17Factory(), $publicRenderer); +$body = $streams->createStreamFromThrowable(new RuntimeException('Private diagnostics')); + +printf("Captured body: %s\n", (string) $body); diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index e5b5683..b915659 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -166,7 +166,7 @@ public function handleThrowable(\Throwable $throwable): void if ($this->isHandlingThrowable) { throw new \LogicException('Recursive throwable handling is not supported.', 0, $throwable); } - if ($this->hasReporterFailed()) { + if ($this->isRegistered && $this->handlerFailed) { throw $throwable; } @@ -181,19 +181,6 @@ public function handleThrowable(\Throwable $throwable): void } } - /** - * Indicates whether reporting has failed within the active registration. - * - * Integrations MUST propagate the current failure instead of rendering through - * the same reporter again. Independent unregistered deliveries have no retained failure state. - * - * @return bool True when the active registration must not retry its reporter. - */ - public function hasReporterFailed(): bool - { - return $this->isRegistered && $this->handlerFailed; - } - /** * Handles PHP errors. * diff --git a/src/Http/Message/GenericThrowableStreamFactory.php b/src/Http/Message/GenericThrowableStreamFactory.php new file mode 100644 index 0000000..5fb6abb --- /dev/null +++ b/src/Http/Message/GenericThrowableStreamFactory.php @@ -0,0 +1,46 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +namespace CoiSA\ErrorHandler\Http\Message; + +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; + +/** + * Produces a controlled public body without reporting or capturing output. + */ +final class GenericThrowableStreamFactory implements ThrowableStreamFactoryInterface +{ + /** + * Constructs a renderer using the application's PSR-17 stream factory. + * + * @param StreamFactoryInterface $streams Creates response body streams. + */ + public function __construct(private StreamFactoryInterface $streams) + { + } + + /** + * Creates the generic response body without disclosing throwable diagnostics. + * + * @param \Throwable $throwable The private failure whose details remain undisclosed. + * + * @return StreamInterface A new stream containing the generic public message. + */ + public function createStreamFromThrowable(\Throwable $throwable): StreamInterface + { + return $this->streams->createStream('Internal Server Error'); + } +} diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php index 8d7780d..06d17bb 100644 --- a/src/Http/Message/ThrowableStreamFactory.php +++ b/src/Http/Message/ThrowableStreamFactory.php @@ -23,9 +23,9 @@ /** * Class ThrowableStreamFactory * - * Creates PSR-7 streams from Throwable instances. - * This class SHALL ensure that throwable instances are converted into a - * readable stream for inclusion in PSR-7 responses or logging mechanisms. + * Captures deliberately public renderer output as an optional compatibility adapter. + * Middleware reports diagnostics separately before rendering. Consumers MUST NOT + * pass that diagnostic reporter here again. The provider uses a pure generic renderer by default. * * @package CoiSA\ErrorHandler\Http\Message */ diff --git a/src/Http/Middleware/ErrorHandlerMiddleware.php b/src/Http/Middleware/ErrorHandlerMiddleware.php index ddef0a5..9085361 100644 --- a/src/Http/Middleware/ErrorHandlerMiddleware.php +++ b/src/Http/Middleware/ErrorHandlerMiddleware.php @@ -16,8 +16,8 @@ namespace CoiSA\ErrorHandler\Http\Middleware; -use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\ErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -33,15 +33,17 @@ * This middleware SHALL use an ErrorHandlerInterface to register * and unregister error handlers and SHALL utilize a ThrowableResponseFactoryInterface * to generate appropriate HTTP responses for captured throwables. + * The scoped handler MUST also report throwables. Reporting runs before the + * response factory; renderers MUST NOT invoke the diagnostic reporter again. * * @package CoiSA\ErrorHandler\Http\Middleware */ final class ErrorHandlerMiddleware implements MiddlewareInterface { /** - * @var ErrorHandlerInterface The error handler responsible for managing errors. + * @var ErrorHandlerInterface&ThrowableHandlerInterface The lifecycle and reporter sharing one failure guard. */ - private ErrorHandlerInterface $errorHandler; + private ErrorHandlerInterface&ThrowableHandlerInterface $errorHandler; /** * @var ThrowableResponseFactoryInterface Factory for creating HTTP responses from throwables. @@ -51,11 +53,11 @@ final class ErrorHandlerMiddleware implements MiddlewareInterface /** * Constructs the ErrorHandlerMiddleware. * - * @param ErrorHandlerInterface $errorHandler The error handler instance. + * @param ErrorHandlerInterface&ThrowableHandlerInterface $errorHandler The scoped lifecycle and reporter. * @param ThrowableResponseFactoryInterface $throwableResponseFactory Factory for creating throwable responses. */ public function __construct( - ErrorHandlerInterface $errorHandler, + ErrorHandlerInterface&ThrowableHandlerInterface $errorHandler, ThrowableResponseFactoryInterface $throwableResponseFactory ) { $this->errorHandler = $errorHandler; @@ -73,6 +75,8 @@ public function __construct( * @param RequestHandlerInterface $handler The next request handler in the chain. * * @return ResponseInterface The generated HTTP response. + * + * @throws \Throwable If reporting or rendering fails; callbacks are restored before propagation. */ public function process( ServerRequestInterface $request, @@ -83,9 +87,7 @@ public function process( try { $response = $handler->handle($request); } catch (\Throwable $throwable) { - if ($this->errorHandler instanceof ErrorHandler && $this->errorHandler->hasReporterFailed()) { - throw $throwable; - } + $this->errorHandler->handleThrowable($throwable); $response = $this->throwableResponseFactory->createResponseFromThrowable($throwable); } finally { $this->errorHandler->unregister(); diff --git a/src/ServiceProvider/ErrorHandlerServiceProvider.php b/src/ServiceProvider/ErrorHandlerServiceProvider.php index ea2800f..71efa9f 100644 --- a/src/ServiceProvider/ErrorHandlerServiceProvider.php +++ b/src/ServiceProvider/ErrorHandlerServiceProvider.php @@ -22,7 +22,7 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; @@ -56,7 +56,7 @@ public function getFactories(): array ThrowableHandlerInterface::class => AliasFactory::get(ThrowableHandlerAggregate::class), ThrowableHandlerAggregate::class => new ThrowableHandlerAggregateFactory(), ThrowableResponseFactoryInterface::class => AliasFactory::get(ThrowableResponseFactory::class), - ThrowableStreamFactoryInterface::class => AliasFactory::get(ThrowableStreamFactory::class), + ThrowableStreamFactoryInterface::class => AliasFactory::get(GenericThrowableStreamFactory::class), ErrorHandlerMiddleware::class => new ErrorHandlerMiddlewareFactory(), ErrorHandlerMiddlewareFactory::class => new InvokableFactory(ErrorHandlerMiddlewareFactory::class), ]; diff --git a/tests/Distribution/run.py b/tests/Distribution/run.py index b7d4c02..1b449c9 100644 --- a/tests/Distribution/run.py +++ b/tests/Distribution/run.py @@ -32,6 +32,10 @@ if (class_exists(CoiSA\ErrorHandler\Container\ConfigProvider::class)) { throw new RuntimeException('Legacy ConfigProvider remains available.'); } +if (!class_exists(CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory::class) + || !class_exists(CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware::class)) { + throw new RuntimeException('Production HTTP adapters cannot load without development dependencies.'); +} ''' subprocess.run(["php", "-r", code], cwd=directory, check=True, timeout=5) print("PASS runtime archive exclusions and production-only Composer/provider autoload") diff --git a/tests/Examples/run.py b/tests/Examples/run.py index fba9b8c..d3d2393 100644 --- a/tests/Examples/run.py +++ b/tests/Examples/run.py @@ -7,6 +7,7 @@ ROOT = Path(__file__).resolve().parents[2] EXPECTED = { + "output-capture.php": "Captured body: Public failure page\n", "fast-forward.php": "Reported: FastForward provider warning\n", "reporters.php": "first: Synthetic failure\nsecond: Synthetic failure\n", "event-dispatcher.php": "throwable: Synthetic warning\nevent: Synthetic warning\nPrivate log records: 1\n", @@ -68,4 +69,4 @@ def handle_endtag(self, tag): assert "```php\n" + source + "\n```" in cookbook, "Stale cookbook example: " + key print("PASS " + key) assert len(page.code) == len(paths) -print("Eight executable examples, cookbook snippets and HTML anchors verified.") +print("Nine executable examples, cookbook snippets and HTML anchors verified.") diff --git a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php index aa96f99..e506b51 100644 --- a/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php +++ b/tests/Functional/Http/Middleware/ErrorHandlerMiddlewareTest.php @@ -18,86 +18,156 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Server\RequestHandlerInterface; /** - * Class ErrorHandlerMiddlewareTest. - * - * @package CoiSA\ErrorHandler\Test\Functional\Http\Middleware + * Verifies reporting and public HTTP rendering remain separate operations. * * @internal - * @coversNothing */ final class ErrorHandlerMiddlewareTest extends TestCase { /** - * Verifies middleware captures downstream exceptions and renders an application response. + * Verifies reporting precedes pure JSON rendering, without captured output or private details. */ - public function testErrorHandlerMiddlewareHandleRequestException(): void + public function testReportsOnceBeforeRenderingTheResponse(): void { - $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); - - $callableThrowableHandler = new CallableThrowableHandler(function (\Throwable $throwable): void { - echo json_encode([ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - }); - - $streamFactory = $reponseFactory = new Psr17Factory(); - $serverRequest = $streamFactory->createServerRequest('GET', 'http://localhost'); + $failure = new \RuntimeException('Private database credentials', 503); + $events = new \ArrayObject(); + $received = []; + $reporter = new CallableThrowableHandler( + static function (\Throwable $throwable) use ($events, &$received): void { + $events[] = 'report'; + $received[] = $throwable; + } + ); + $psr17 = new Psr17Factory(); + $streams = new class ($psr17, $events) implements ThrowableStreamFactoryInterface { + /** + * Stores the public stream factory and the observable operation order. + * + * @param StreamFactoryInterface $streams Creates public body streams. + * @param \ArrayObject $events Receives ordered fixture operations. + */ + public function __construct(private StreamFactoryInterface $streams, private \ArrayObject $events) + { + } - $requestHandler = new class ($exception) implements RequestHandlerInterface { /** - * @var \Throwable The downstream failure to propagate. + * Returns a JSON stream directly without invoking a reporter or writing output. + * + * @param \Throwable $throwable The private failure whose details remain undisclosed. + * + * @return StreamInterface The application's controlled public JSON body. */ - private $exception; + public function createStreamFromThrowable(\Throwable $throwable): StreamInterface + { + $this->events[] = 'render'; + + return $this->streams->createStream('{"error":"Service unavailable"}'); + } + }; + $next = self::downstream($failure); + $middleware = new ErrorHandlerMiddleware( + new ErrorHandler($reporter), + new ThrowableResponseFactory($streams, $psr17) + ); + ob_start(); + try { + $response = $middleware->process($psr17->createServerRequest('GET', '/'), $next); + $output = ob_get_contents(); + } finally { + ob_end_clean(); + } + + self::assertSame('', $output); + self::assertSame(['report', 'render'], $events->getArrayCopy()); + self::assertSame([$failure], $received); + self::assertSame(503, $response->getStatusCode()); + self::assertSame('{"error":"Service unavailable"}', (string) $response->getBody()); + } + /** + * Verifies successful responses pass through without reporting or rendering. + */ + public function testPassesThroughSuccessfulResponses(): void + { + $psr17 = new Psr17Factory(); + $expected = $psr17->createResponse(204); + $reports = 0; + $middleware = new ErrorHandlerMiddleware( + new ErrorHandler(new CallableThrowableHandler(static function () use (&$reports): void { + ++$reports; + })), + new ThrowableResponseFactory(new GenericThrowableStreamFactory($psr17), $psr17) + ); + $next = new class ($expected) implements RequestHandlerInterface { /** - * Stores synthetic collaborators or failures for this test fixture. + * Stores the application's successful response. * - * @param \Throwable $exception The downstream throwable. + * @param ResponseInterface $response The response that must pass through unchanged. */ - public function __construct(\Throwable $exception) + public function __construct(private ResponseInterface $response) { - $this->exception = $exception; } /** - * Executes the synthetic downstream behavior for the requested test route. + * Returns the application's successful response. * - * @param ServerRequestInterface $request The synthetic incoming request. + * @param ServerRequestInterface $request The synthetic request. * - * @return ResponseInterface The fixture result. + * @return ResponseInterface The unchanged downstream response. */ public function handle(ServerRequestInterface $request): ResponseInterface { - throw $this->exception; + return $this->response; } }; - $errorHandler = new ErrorHandler($callableThrowableHandler); - - $throwableStreamFactory = new ThrowableStreamFactory($streamFactory, $callableThrowableHandler); - $throwableResponseFactory = new ThrowableResponseFactory($throwableStreamFactory, $reponseFactory); - - $middleware = new ErrorHandlerMiddleware($errorHandler, $throwableResponseFactory); - $response = $middleware->process($serverRequest, $requestHandler); - - static::assertInstanceOf(ResponseInterface::class, $response); - static::assertSame($exception->getCode(), $response->getStatusCode()); + self::assertSame($expected, $middleware->process($psr17->createServerRequest('GET', '/'), $next)); + self::assertSame(0, $reports); + } - $body = json_decode((string) $response->getBody(), true); + /** + * Creates the failing application used by the rendering regression. + * + * @param \Throwable $failure The exact failure to report and render. + * + * @return RequestHandlerInterface The synthetic downstream application. + */ + private static function downstream(\Throwable $failure): RequestHandlerInterface + { + return new class ($failure) implements RequestHandlerInterface { + /** + * Stores the downstream failure. + * + * @param \Throwable $failure The exact throwable to propagate. + */ + public function __construct(private \Throwable $failure) + { + } - static::assertIsArray($body); - static::assertSame($exception->getCode(), $body['code']); - static::assertSame($exception->getMessage(), $body['message']); + /** + * Throws the private failure for the middleware to handle. + * + * @param ServerRequestInterface $request The synthetic incoming request. + * + * @return ResponseInterface The error response supplied by middleware. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw $this->failure; + } + }; } } diff --git a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php index be03a5a..5eda3f6 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php @@ -291,12 +291,12 @@ public function testErrorHandlerMiddlewareHandleRequestException(): void { $exception = new \InvalidArgumentException(uniqid('test', true), random_int(400, 500)); - $callableThrowableHandler = new CallableThrowableHandler(function (\Throwable $throwable): void { - echo json_encode([ - 'code' => $throwable->getCode(), - 'message' => $throwable->getMessage(), - ]); - }); + $received = []; + $callableThrowableHandler = new CallableThrowableHandler( + static function (\Throwable $throwable) use (&$received): void { + $received[] = $throwable; + } + ); $this->services[ThrowableHandlerInterface::class] = $callableThrowableHandler; $errorHandler = $this->container()->get(ErrorHandler::class); @@ -338,11 +338,8 @@ public function handle(ServerRequestInterface $serverRequest): ResponseInterface static::assertInstanceOf(ResponseInterface::class, $response); static::assertSame($exception->getCode(), $response->getStatusCode()); - $body = json_decode((string) $response->getBody(), true); - - static::assertIsArray($body); - static::assertSame($exception->getCode(), $body['code']); - static::assertSame($exception->getMessage(), $body['message']); + static::assertSame('Internal Server Error', (string) $response->getBody()); + static::assertSame([$exception], $received); } /** diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php index faa032e..ef39bf1 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php @@ -30,6 +30,7 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; @@ -70,9 +71,12 @@ public function testHttpAdaptersAreAutowiredAndShareTheirInterfaceServices(): vo { $received = []; $container = self::httpServices($received); - self::assertInstanceOf(ThrowableStreamFactory::class, $container->get(ThrowableStreamFactory::class)); + self::assertInstanceOf( + GenericThrowableStreamFactory::class, + $container->get(GenericThrowableStreamFactory::class) + ); self::assertSame( - $container->get(ThrowableStreamFactory::class), + $container->get(GenericThrowableStreamFactory::class), $container->get(ThrowableStreamFactoryInterface::class) ); self::assertInstanceOf(ThrowableResponseFactory::class, $container->get(ThrowableResponseFactory::class)); @@ -83,16 +87,16 @@ public function testHttpAdaptersAreAutowiredAndShareTheirInterfaceServices(): vo } /** - * Verifies explicit concrete adapter overrides remain effective under autowiring. + * Verifies an explicit interface binding can select the optional output-capture renderer. */ - public function testApplicationCanOverrideAnAutowiredConcreteAdapter(): void + public function testApplicationCanSelectAnExplicitOutputCaptureRenderer(): void { $renderer = new ThrowableStreamFactory(new Psr17Factory(), new CallableThrowableHandler( static function (): void { echo 'Application response'; } )); - $container = self::services([ThrowableStreamFactory::class => $renderer]); + $container = self::services([ThrowableStreamFactoryInterface::class => $renderer]); self::assertSame($renderer, $container->get(ThrowableStreamFactoryInterface::class)); self::assertSame( @@ -101,6 +105,27 @@ static function (): void { ); } + /** + * Verifies successful concrete response definitions remain effective under autowiring. + */ + public function testApplicationCanOverrideAnAutowiredConcreteResponseAdapter(): void + { + $psr17 = new Psr17Factory(); + $renderer = new ThrowableResponseFactory(new ThrowableStreamFactory( + $psr17, + new CallableThrowableHandler(static function (): void { + echo 'Application response'; + }) + ), $psr17); + $container = self::services([ThrowableResponseFactory::class => $renderer]); + + self::assertSame($renderer, $container->get(ThrowableResponseFactoryInterface::class)); + self::assertSame( + 'Application response', + (string) $renderer->createResponseFromThrowable(new \RuntimeException())->getBody() + ); + } + /** * Verifies missing required HTTP dependencies fail construction instead of returning partial middleware. */ diff --git a/tests/Http/router.php b/tests/Http/router.php index ff09d5f..cc17c9a 100644 --- a/tests/Http/router.php +++ b/tests/Http/router.php @@ -17,7 +17,7 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; +use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseInterface; @@ -31,7 +31,7 @@ // A production application can log privately without rendering the exception. }); $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), new ThrowableResponseFactory( - new ThrowableStreamFactory($factory, $reporter), + new GenericThrowableStreamFactory($factory), $factory )); $next = new class () implements RequestHandlerInterface { From ea30a9420e274f0fbf501f70fdedb171e84a43fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 15 Oct 2026 10:29:18 -0300 Subject: [PATCH 12/27] docs: record validated HTTP redesign evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- docs/testing.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index aed765d..04d7ac6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -93,7 +93,10 @@ The current local PHP 8.5.11/macOS arm64 installation with FastForward 1.6.0 pas scenarios**, three HTTP responses and nine executable examples. Dependency snapshots used for these local runs are observations, not a committed lock or a promise about future resolution. The earlier isolated highest-dependency run -with FastForward 1.8.0 also passed; the current HTTP follow-up requires current-head verification. Tests additionally +with FastForward 1.8.0 also passed. The +[HTTP follow-up CI](https://github.com/coisa/error-handler/actions/runs/38742698841) +passed all four PHP/dependency jobs and coverage on +`611ea938178fb480abf24d51285deb264033ea56`. Tests additionally verify report-before-render ordering, no stdout from a private reporter/pure JSON renderer, successful response identity and concrete response overrides. @@ -126,7 +129,7 @@ minimum of 85% line coverage and rejects negative differences when a baseline exists. Owners can override the minimum through `MINIMUM_COVERAGE_THRESHOLD` or `MINIMUM_COVERAGE`; values must be integers from 0 to 100. -The local PHPUnit snapshot before the HTTP redesign is **87.04% lines +The local PHPUnit snapshot after the HTTP redesign is **87.04% lines (168/193)**. This excludes the separately executed bounded fatal and loopback HTTP fixtures; low shutdown-method coverage does not imply those child scenarios were skipped. It is evidence for this exact PHP/dependency snapshot. From 9cc4c3266eab398a65217afdf0482d647adffd4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 15 Oct 2026 10:31:44 -0300 Subject: [PATCH 13/27] docs: align major release note with final HTTP contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 2e6d5f8..7ec9866 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -8,9 +8,10 @@ Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package ### Changed - Require PHP 8.3 or newer and test PHP 8.3–8.5. PHP 7.x and PHP 8.0–8.2 are outside the support contract. -- Exit with status 1 when the registered uncaught-exception handler completes, preserving failure signals in CLI jobs. Direct `handleThrowable()` calls still return normally. +- Exit with status 1 when the registered uncaught-exception handler completes, preserving failure signals in CLI jobs. Direct `handleThrowable()` calls do not force process termination. - Reject out-of-order `unregister()` calls without removing another handler. Only the active registration handles shutdown events. -- Return a generic HTTP body when a reporter emits nothing; use standard reason phrases instead of exception messages. Preserve valid error status codes through 599. +- Separate HTTP reporting and rendering: middleware requires the combined lifecycle/reporting contracts, reports once, then invokes a pure renderer. Keep reporters void and default to `GenericThrowableStreamFactory` without buffering. Retain explicit public output capture as a compatibility adapter. +- Use standard HTTP reason phrases instead of exception messages. Preserve valid error status codes through 599. - Update PHPUnit, PHPStan, PHP CS Fixer, PHP_CodeSniffer and CI. Declare PSR-11 as a runtime dependency. - Require FastForward Container at runtime and reuse its alias/invokable factories. Remove the standalone `ErrorHandlerContainer`, its alias factory and container exceptions, and constructor-only dispatch/HTTP factories; use autowiring for plain injection. - Keep the Interop service provider as the single container integration entrypoint. Remove the Laminas `ConfigProvider`, discovery metadata and ServiceManager development dependency; move both remaining `Container\Factory` classes to `ServiceProvider\Factory` and migrate their behavior tests. @@ -20,7 +21,7 @@ Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package - Skip an optional callable reporter when an autowiring container advertises it but retrieval reports PSR-11 not-found; continue propagating other construction exceptions. - Re-registering an instance no longer adds duplicate shutdown callbacks. -- Failing or recursive reporters are not invoked repeatedly during exception/shutdown handling. +- Failing or recursive reporters are not invoked repeatedly within a registered lifecycle. Independent direct deliveries remain usable after a reporter failure. - Custom shutdown callbacks still run after a reported uncaught throwable; replacing the active callback continues to suppress the old registration. - Throwable rendering cleans reachable removable buffers when the renderer throws, stops at non-removable buffers, preserves the original failure if cleanup also throws, preserves the body `0`, and accepts SQLSTATE exception codes without raising a secondary type error. - Shutdown reporting excludes nonfatal core and compile warnings. @@ -40,11 +41,9 @@ Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package - Limit failed-reporter suppression to registered lifecycles and preserve independent direct deliveries. - Propagate middleware reporter failures without rendering through the failed reporter a second time. - Characterize fatal shutdown-queue termination with fresh and reused handlers in bounded subprocesses. -- Expose eight complete executable recipes and all built-in adapters in the cookbook and static site; verify source parity in CI. +- Expose nine complete executable recipes and all built-in adapters in the cookbook and static site, including direct JSON streams; verify source parity in CI. - Introduce private Changesets tooling for reviewed version PRs, immutable Git tags and GitHub releases; Composer remains the distribution channel. - Remove the redundant Dockerfile, Compose service and Docker ignore rules; the PHP matrix runs directly in CI. - Stop tracking composer.lock in this library; validate lowest/highest dependency bounds and keep application locks application-owned. - Exclude development tools, npm manifests/lock, Changesets, tests, examples and documentation from Git archives, and verify a production-only installation from the exported package. - Add PHPUnit coverage reporting, baseline comparison and a single SHA-aware PR sticky note, with a read-only producer and trusted default-branch publisher. - -- Separate HTTP reporting and rendering: middleware requires the combined lifecycle/reporting contracts, reports once, then invokes a pure renderer. Keep reporters void, default to GenericThrowableStreamFactory without buffering, demonstrate JSON streams directly, and retain explicit public output capture as a compatibility adapter. From 4a5508ac5adda959fbfc66c8bceaa738e251fe5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 15 Oct 2026 11:13:35 -0300 Subject: [PATCH 14/27] docs: simplify provider examples and improve reading flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .agents/skills/coisa-error-handler/SKILL.md | 22 ++++- .changeset/modernize-error-handler.md | 1 + AGENTS.md | 7 +- docs/api.md | 7 +- docs/examples.md | 82 ++++++++++++---- docs/fast-forward.md | 56 ++++++++--- docs/index.html | 102 ++++++++++++++------ docs/migration.md | 11 ++- examples/cli.php | 3 + examples/custom-handlers.php | 13 ++- examples/event-dispatcher.php | 12 ++- examples/fast-forward.php | 9 +- examples/http-middleware.php | 16 ++- examples/http-rendering.php | 4 + examples/output-capture.php | 2 + examples/reporters.php | 3 + examples/scopes.php | 18 +++- 17 files changed, 274 insertions(+), 94 deletions(-) diff --git a/.agents/skills/coisa-error-handler/SKILL.md b/.agents/skills/coisa-error-handler/SKILL.md index 63b3026..49793b4 100644 --- a/.agents/skills/coisa-error-handler/SKILL.md +++ b/.agents/skills/coisa-error-handler/SKILL.md @@ -45,8 +45,20 @@ conversion honors suppression and `error_reporting()`. Use `CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider` with `FastForward\Container\container()`. It implements the Interop provider contract (`getFactories()` and `getExtensions()`), and resolves -`CoiSA\ErrorHandler\ErrorHandlerInterface`. To override the reporter binding -`CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface`, merge definitions with +`CoiSA\ErrorHandler\ErrorHandlerInterface`. The usual bootstrap accepts the +provider class and application provider directly: + +```php +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; + +use function FastForward\Container\container; + +$services = container(ErrorHandlerServiceProvider::class, $application); +``` + +Supply external dependencies and optional `CallableThrowableHandler`/PSR-14 +reporters through `$application`. To override an existing library service ID, +such as `CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface`, merge maps with `FastForward\Container\ServiceProvider\AggregateServiceProvider`: library first, application **last**, then pass that one provider into `container(...)`. @@ -59,8 +71,10 @@ The Interop provider is the single container integration entrypoint; factories live under `ServiceProvider\Factory`. Consult the migration guide for removed and moved class names. -Separate variadic providers allow FastForward to fall back to the library's -silent default if the application's reporter fails with a PSR container error. +FastForward 1.6/1.8 prioritize the first of separate variadic providers. A duplicate +reporter interface in the application can therefore be shadowed by the library; +placing the application first can still fall back to the library's silent default +after a PSR container error. Merged definitions replace that default and make required-reporter construction fail. FastForward may translate the PSR error to not-found; do not promise that its original exception cause survives. Use the executable example in the diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 7ec9866..14cbf0f 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -41,6 +41,7 @@ Modernize the PHP 8.3–8.5 development line after 2.0 while keeping the package - Limit failed-reporter suppression to registered lifecycles and preserve independent direct deliveries. - Propagate middleware reporter failures without rendering through the failed reporter a second time. - Characterize fatal shutdown-queue termination with fresh and reused handlers in bounded subprocesses. +- Simplify ordinary provider bootstrap with class-name initializers, distinguish required override semantics, and separate example stages with readable spacing. - Expose nine complete executable recipes and all built-in adapters in the cookbook and static site, including direct JSON streams; verify source parity in CI. - Introduce private Changesets tooling for reviewed version PRs, immutable Git tags and GitHub releases; Composer remains the distribution channel. - Remove the redundant Dockerfile, Compose service and Docker ignore rules; the PHP matrix runs directly in CI. diff --git a/AGENTS.md b/AGENTS.md index 01fa83a..5b6ee2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,11 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed handlers for nested scopes. Follow [the lifecycle contract](docs/api.md). - FastForward caches services. Keep the middleware handler separate from the process handler and use the provider's factory services for fresh scopes. - Merge application overrides after library definitions in one - `AggregateServiceProvider`, avoiding silent reporter fallback on PSR errors. + Prefer `container(ErrorHandlerServiceProvider::class, $application)` for normal + dependency and optional-reporter composition. For required overrides, merge + application definitions after library definitions in one + `AggregateServiceProvider`: separate providers prioritize the first and may + silently fall back on PSR errors. - Support container integration through the Interop service provider only; do not restore the removed ConfigProvider or framework-specific adapters. Keep all provider factories under `ServiceProvider\Factory`. Preserve explicit diff --git a/docs/api.md b/docs/api.md index 20791c6..7e2fb8d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -124,8 +124,11 @@ registers PHP callbacks during discovery/construction. The explicit lifecycle factories live under `ServiceProvider\Factory`; no parallel standalone container or native Laminas ConfigProvider remains. -Merge library definitions first and the application last in one AggregateServiceProvider. -Bind required reporters/renderers explicitly through their interfaces. The default +Use `container(ErrorHandlerServiceProvider::class, $application)` to supply +application dependencies and optional reporters. To override existing library +service IDs, merge library definitions first and the application last in one +AggregateServiceProvider. Bind required reporters/renderers explicitly through +their interfaces. The default reporter aggregate discovers optional callable/PSR-14 reporters and is silent when none are configured. The default HTTP stream alias resolves GenericThrowableStreamFactory. Applications supply PSR-14, PSR-17 and logger implementations. See [provider wiring](fast-forward.md) diff --git a/docs/examples.md b/docs/examples.md index b3b9f66..3e66426 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -51,10 +51,13 @@ require dirname(__DIR__) . '/vendor/autoload.php'; $reporter = new CallableThrowableHandler(static function (Throwable $error): void { fwrite(STDERR, get_class($error) . ': ' . $error->getMessage() . PHP_EOL); }); + $handler = new ErrorHandler($reporter); + $handler->register(); trigger_error('Example warning; execution continues.', E_USER_WARNING); + throw new RuntimeException('Example uncaught exception; exit status is 1.'); ``` @@ -62,7 +65,7 @@ throw new RuntimeException('Example uncaught exception; exit status is 1.'); ## Interop service provider -Implement ServiceProviderInterface with lazy getFactories() and getExtensions() maps. Merge the library first and the application last in one AggregateServiceProvider. FastForward resolves and caches services; registration remains an explicit application decision. This example prints Reported: FastForward provider warning and restores the earlier callbacks. +Implement ServiceProviderInterface with lazy getFactories() and getExtensions() maps. Pass the library provider class and the application provider directly to container(). This example supplies an optional CallableThrowableHandler that the library aggregate discovers. FastForward resolves and caches services; registration remains an explicit application decision. It prints Reported: FastForward provider warning and restores the earlier callbacks. Run `php examples/fast-forward.php`. [Executable source](../examples/fast-forward.php). @@ -73,9 +76,7 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use Interop\Container\ServiceProviderInterface; use function FastForward\Container\container; @@ -91,7 +92,7 @@ $application = new class () implements ServiceProviderInterface { public function getFactories(): array { return [ - ThrowableHandlerInterface::class => static fn (): CallableThrowableHandler => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { printf("Reported: %s\n", $error->getMessage()); }), @@ -109,12 +110,15 @@ $application = new class () implements ServiceProviderInterface { } }; -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$services = container(ErrorHandlerServiceProvider::class, $application); + /** * @var ErrorHandlerInterface $errors */ $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('FastForward provider warning', E_USER_WARNING); } finally { @@ -147,6 +151,7 @@ $reporters = new ThrowableHandlerAggregate( $deliveries[] = 'first: ' . $error->getMessage(); }) ); + $reporters->attach(new CallableThrowableHandler( static function (Throwable $error) use (&$deliveries): void { $deliveries[] = 'second: ' . $error->getMessage(); @@ -154,8 +159,10 @@ $reporters->attach(new CallableThrowableHandler( )); $errors = new ErrorHandler($reporters); + // Direct delivery needs no registration and does not terminate the process. $errors->handleThrowable(new RuntimeException('Synthetic failure')); + echo implode(PHP_EOL, $deliveries) . PHP_EOL; ``` @@ -180,7 +187,6 @@ use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Phly\EventDispatcher\EventDispatcher; use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; @@ -193,6 +199,7 @@ use function FastForward\Container\container; require dirname(__DIR__) . '/vendor/autoload.php'; $deliveries = []; + $logger = new class () extends AbstractLogger { /** * @var list Synthetic private log records; replace with an application logger. @@ -213,6 +220,7 @@ $logger = new class () extends AbstractLogger { $this->records[] = $level . ': ' . (string) $message; } }; + $throwables = new ThrowableListenerProvider( new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { $deliveries[] = 'throwable: ' . $error->getMessage(); @@ -224,18 +232,25 @@ $events = new ErrorEventListenerProvider( }) ); $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); + $dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events)); + $application = new ArrayServiceProvider([ EventDispatcherInterface::class => static fn (): EventDispatcherInterface => $dispatcher, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('Synthetic warning', E_USER_WARNING); } finally { $errors->unregister(); } + echo implode(PHP_EOL, $deliveries) . PHP_EOL; printf("Private log records: %d\n", count($logger->records)); ``` @@ -255,10 +270,8 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -272,18 +285,24 @@ use function FastForward\Container\container; require dirname(__DIR__) . '/vendor/autoload.php'; $reported = []; + $psr17 = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function (Throwable $error) use (&$reported): void { $reported[] = $error; // Replace with a private logger; do not echo diagnostics. }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => $reporter, ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $process = $services->get(ErrorHandlerInterface::class); $middleware = $services->get(ErrorHandlerMiddleware::class); + $downstream = new class () implements RequestHandlerInterface { /** * Simulates an application failure with an HTTP error status. @@ -298,15 +317,19 @@ $downstream = new class () implements RequestHandlerInterface { }; $process->register(); + try { $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); + // The process handler is active again after the middleware scope ends. trigger_error('Synthetic post-request warning', E_USER_WARNING); } finally { $process->unregister(); } + printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody()); printf("Reported: %d\n", count($reported)); + // Your HTTP framework/emitter sends the response; this example only inspects it. ``` @@ -343,6 +366,7 @@ $reporter = new CallableThrowableHandler( $privateDiagnostics[] = $error; // Replace with a private logger; emit no response body. } ); + $psr17 = new Psr17Factory(); $streams = new class ($psr17) implements ThrowableStreamFactoryInterface { /** @@ -368,8 +392,10 @@ $streams = new class ($psr17) implements ThrowableStreamFactoryInterface { ); } }; + $responses = new ThrowableResponseFactory($streams, $psr17); $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); + $downstream = new class () implements RequestHandlerInterface { /** * Simulates a private application failure with an HTTP error code. @@ -383,6 +409,7 @@ $downstream = new class () implements RequestHandlerInterface { throw new RuntimeException('Private credentials', 503); } }; + $response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream) ->withHeader('Content-Type', 'application/json'); @@ -409,9 +436,7 @@ use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use function FastForward\Container\container; @@ -439,27 +464,34 @@ $phpErrors = new class () implements PhpErrorHandlerInterface { } } }; + $shutdowns = 0; $shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void { ++$shutdowns; }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { error_log($error->getMessage()); }), PhpErrorHandlerInterface::class => static fn (): PhpErrorHandlerInterface => $phpErrors, ShutdownHandlerInterface::class => static fn (): ShutdownHandlerInterface => $shutdown, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('Application-owned warning', E_USER_WARNING); $errors->handleShutdown(); // Simulate a notification without terminating the process. } finally { $errors->unregister(); } + printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns); ``` @@ -478,11 +510,9 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -497,22 +527,29 @@ use function FastForward\Container\container; require dirname(__DIR__) . '/vendor/autoload.php'; $reported = 0; + $psr17 = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function () use (&$reported): void { ++$reported; }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => $reporter, ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $makeHandler = $services->get(ErrorHandlerFactory::class); $first = $makeHandler($services); $second = $makeHandler($services); + $makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class); $outer = $makeMiddleware($services); $inner = $makeMiddleware($services); + $downstream = new class () implements RequestHandlerInterface { /** * Throws a synthetic downstream failure for the inner scope. @@ -525,6 +562,7 @@ $downstream = new class () implements RequestHandlerInterface { throw new RuntimeException('Nested failure'); } }; + $next = new class ($inner, $downstream) implements RequestHandlerInterface { /** * Stores the independently constructed inner middleware and application handler. @@ -547,14 +585,18 @@ $next = new class ($inner, $downstream) implements RequestHandlerInterface { return $this->inner->process($request, $this->downstream); } }; + $process = $services->get(ErrorHandlerInterface::class); + $process->register(); + try { $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next); trigger_error('Process handler restored', E_USER_WARNING); } finally { $process->unregister(); } + printf( "Fresh handlers: %s | Fresh middleware: %s\n", $first !== $second ? 'yes' : 'no', @@ -587,7 +629,9 @@ require dirname(__DIR__) . '/vendor/autoload.php'; $publicRenderer = new CallableThrowableHandler(static function (): void { echo 'Public failure page'; }); + $streams = new ThrowableStreamFactory(new Psr17Factory(), $publicRenderer); + $body = $streams->createStreamFromThrowable(new RuntimeException('Private diagnostics')); printf("Captured body: %s\n", (string) $body); diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 9a322b1..b37bf39 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -24,8 +24,9 @@ the checkout dependencies, run the complete [example](../examples/fast-forward.p php examples/fast-forward.php ``` -It reports one warning and restores the prior PHP callbacks. Merge the library -and application definitions into one provider, with the application last: +It reports one warning and restores the prior PHP callbacks. Pass the library +provider class and application provider directly to `container()`. This example +adds a callable reporter for the library's optional reporter discovery: ```php static fn (): CallableThrowableHandler => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { printf("Reported: %s\n", $error->getMessage()); }), @@ -70,12 +69,15 @@ $application = new class () implements ServiceProviderInterface { } }; -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$services = container(ErrorHandlerServiceProvider::class, $application); + /** * @var ErrorHandlerInterface $errors */ $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('FastForward provider warning', E_USER_WARNING); } finally { @@ -106,7 +108,8 @@ There is no provider boot hook. The application decides when to register. The provider has nine explicit definitions. Dispatch handlers and concrete generic stream/response adapters are resolved through FastForward's constructor autowiring. Their interface aliases remain explicit, and application definitions -can override either an interface or its concrete implementation. The three +can override either an interface or its concrete implementation when their +maps are merged as shown under [required overrides](#required-overrides). The three library factories that remain encode optional-handler defaults, reporter composition and fresh middleware scopes; autowiring alone does not replace those contracts. The factory-object services use FastForward's `InvokableFactory`. @@ -136,15 +139,36 @@ only a PSR-11 not-found result from `get()`; other construction exceptions are propagated. FastForward 1.6.0 can itself translate a failed concrete dependency into not-found, so the library cannot distinguish that from an absent optional reporter. Use an explicit `ThrowableHandlerInterface` binding for a required -reporter and the merged-provider composition above; the next paragraph explains -why both are needed. - -`AggregateServiceProvider` uses the last definition for each service ID, so the -application's required reporter replaces the default. Passing separate providers -to `container(...)` instead creates a fallback chain: FastForward may suppress -the application's PSR container failure and use the library's silent aggregate. -The merged form prevents that fallback and makes required-reporter failures -fail construction of both process handler and middleware. FastForward still +reporter and the merged-provider composition below. + +## Required overrides + +The short bootstrap above adds optional reporters and external dependencies. +FastForward 1.6.0 and 1.8.0 search separate providers in the supplied order; +they do not merge their factory maps. In +`container(ErrorHandlerServiceProvider::class, $application)`, a duplicate +`ThrowableHandlerInterface` definition in the application is shadowed by the +library's default. Moving the application first gives it priority, but its PSR-11 +construction failure can still fall through to the silent library aggregate. + +When replacing an existing service ID, merge the definitions explicitly, with +the application last: + +```php +use FastForward\Container\ServiceProvider\AggregateServiceProvider; + +$services = container( + new AggregateServiceProvider( + new ErrorHandlerServiceProvider(), + $application + ) +); +``` + +Here `$application` binds the required reporter or renderer interface. +`AggregateServiceProvider` uses its last definition for each service ID. This +replaces the library default and makes required-reporter failures fail +construction of both process handler and middleware. FastForward still translates PSR failures to not-found; it does not preserve their original cause. Container 1.6.0 propagates ordinary application exceptions directly; newer versions such as 1.8.0 normalize those too. The supported contract is failed diff --git a/docs/index.html b/docs/index.html index badb440..3b62194 100644 --- a/docs/index.html +++ b/docs/index.html @@ -68,15 +68,18 @@

Make failure
observable.

$reporter = new CallableThrowableHandler(static function (Throwable $error): void { fwrite(STDERR, get_class($error) . ': ' . $error->getMessage() . PHP_EOL); }); + $handler = new ErrorHandler($reporter); + $handler->register(); trigger_error('Example warning; execution continues.', E_USER_WARNING); + throw new RuntimeException('Example uncaught exception; exit status is 1.');
Executable recipe · examples/fast-forward.php

Interop service provider

-

Implement ServiceProviderInterface with lazy getFactories() and getExtensions() maps. Merge the library first and the application last in one AggregateServiceProvider. FastForward resolves and caches services; registration remains an explicit application decision. This example prints Reported: FastForward provider warning and restores the earlier callbacks.

+

Implement ServiceProviderInterface with lazy getFactories() and getExtensions() maps. Pass the library provider class and the application provider directly to container(). This example supplies an optional CallableThrowableHandler that the library aggregate discovers. FastForward resolves and caches services; registration remains an explicit application decision. It prints Reported: FastForward provider warning and restores the earlier callbacks.

RUN FROM THE CHECKOUT
php examples/fast-forward.php
Complete PHP example
examples/fast-forward.php
<?php
 
@@ -84,9 +87,7 @@ 

Make failure
observable.

use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use Interop\Container\ServiceProviderInterface; use function FastForward\Container\container; @@ -102,9 +103,9 @@

Make failure
observable.

public function getFactories(): array { return [ - ThrowableHandlerInterface::class => static fn (): CallableThrowableHandler => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { - printf("Reported: %s\n", $error->getMessage()); + printf("Reported: %s\n", $error->getMessage()); }), ]; } @@ -120,12 +121,15 @@

Make failure
observable.

} }; -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$services = container(ErrorHandlerServiceProvider::class, $application); + /** * @var ErrorHandlerInterface $errors */ $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('FastForward provider warning', E_USER_WARNING); } finally { @@ -152,6 +156,7 @@

Make failure
observable.

$deliveries[] = 'first: ' . $error->getMessage(); }) ); + $reporters->attach(new CallableThrowableHandler( static function (Throwable $error) use (&$deliveries): void { $deliveries[] = 'second: ' . $error->getMessage(); @@ -159,8 +164,10 @@

Make failure
observable.

)); $errors = new ErrorHandler($reporters); + // Direct delivery needs no registration and does not terminate the process. $errors->handleThrowable(new RuntimeException('Synthetic failure')); + echo implode(PHP_EOL, $deliveries) . PHP_EOL;
@@ -179,7 +186,6 @@

Make failure
observable.

use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Phly\EventDispatcher\EventDispatcher; use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; @@ -192,6 +198,7 @@

Make failure
observable.

require dirname(__DIR__) . '/vendor/autoload.php'; $deliveries = []; + $logger = new class () extends AbstractLogger { /** * @var list<string> Synthetic private log records; replace with an application logger. @@ -212,6 +219,7 @@

Make failure
observable.

$this->records[] = $level . ': ' . (string) $message; } }; + $throwables = new ThrowableListenerProvider( new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { $deliveries[] = 'throwable: ' . $error->getMessage(); @@ -223,20 +231,27 @@

Make failure
observable.

}) ); $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); + $dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events)); + $application = new ArrayServiceProvider([ EventDispatcherInterface::class => static fn (): EventDispatcherInterface => $dispatcher, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('Synthetic warning', E_USER_WARNING); } finally { $errors->unregister(); } + echo implode(PHP_EOL, $deliveries) . PHP_EOL; -printf("Private log records: %d\n", count($logger->records)); +printf("Private log records: %d\n", count($logger->records));
Executable recipe · examples/http-middleware.php

PSR-15 HTTP middleware with the provider

@@ -248,10 +263,8 @@

Make failure
observable.

use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -265,18 +278,24 @@

Make failure
observable.

require dirname(__DIR__) . '/vendor/autoload.php'; $reported = []; + $psr17 = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function (Throwable $error) use (&$reported): void { $reported[] = $error; // Replace with a private logger; do not echo diagnostics. }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => $reporter, ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $process = $services->get(ErrorHandlerInterface::class); $middleware = $services->get(ErrorHandlerMiddleware::class); + $downstream = new class () implements RequestHandlerInterface { /** * Simulates an application failure with an HTTP error status. @@ -291,15 +310,19 @@

Make failure
observable.

}; $process->register(); + try { $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); + // The process handler is active again after the middleware scope ends. trigger_error('Synthetic post-request warning', E_USER_WARNING); } finally { $process->unregister(); } -printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody()); -printf("Reported: %d\n", count($reported)); + +printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody()); +printf("Reported: %d\n", count($reported)); + // Your HTTP framework/emitter sends the response; this example only inspects it.
@@ -330,6 +353,7 @@

Make failure
observable.

$privateDiagnostics[] = $error; // Replace with a private logger; emit no response body. } ); + $psr17 = new Psr17Factory(); $streams = new class ($psr17) implements ThrowableStreamFactoryInterface { /** @@ -355,8 +379,10 @@

Make failure
observable.

); } }; + $responses = new ThrowableResponseFactory($streams, $psr17); $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); + $downstream = new class () implements RequestHandlerInterface { /** * Simulates a private application failure with an HTTP error code. @@ -370,12 +396,13 @@

Make failure
observable.

throw new RuntimeException('Private credentials', 503); } }; + $response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream) ->withHeader('Content-Type', 'application/json'); -printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type')); +printf("HTTP %d | %s\n", $response->getStatusCode(), $response->getHeaderLine('Content-Type')); echo (string) $response->getBody() . PHP_EOL; -printf("Private reports: %d\n", count($privateDiagnostics)); +printf("Private reports: %d\n", count($privateDiagnostics));
Executable recipe · examples/custom-handlers.php

Application PHP-error and shutdown handlers

@@ -390,9 +417,7 @@

Make failure
observable.

use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use function FastForward\Container\container; @@ -420,28 +445,35 @@

Make failure
observable.

} } }; + $shutdowns = 0; $shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void { ++$shutdowns; }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { error_log($error->getMessage()); }), PhpErrorHandlerInterface::class => static fn (): PhpErrorHandlerInterface => $phpErrors, ShutdownHandlerInterface::class => static fn (): ShutdownHandlerInterface => $shutdown, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('Application-owned warning', E_USER_WARNING); $errors->handleShutdown(); // Simulate a notification without terminating the process. } finally { $errors->unregister(); } -printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns); + +printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns);
Executable recipe · examples/scopes.php

Fresh handlers and nested middleware

@@ -453,11 +485,9 @@

Make failure
observable.

use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -472,22 +502,29 @@

Make failure
observable.

require dirname(__DIR__) . '/vendor/autoload.php'; $reported = 0; + $psr17 = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function () use (&$reported): void { ++$reported; }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => $reporter, ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $makeHandler = $services->get(ErrorHandlerFactory::class); $first = $makeHandler($services); $second = $makeHandler($services); + $makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class); $outer = $makeMiddleware($services); $inner = $makeMiddleware($services); + $downstream = new class () implements RequestHandlerInterface { /** * Throws a synthetic downstream failure for the inner scope. @@ -500,6 +537,7 @@

Make failure
observable.

throw new RuntimeException('Nested failure'); } }; + $next = new class ($inner, $downstream) implements RequestHandlerInterface { /** * Stores the independently constructed inner middleware and application handler. @@ -522,20 +560,24 @@

Make failure
observable.

return $this->inner->process($request, $this->downstream); } }; + $process = $services->get(ErrorHandlerInterface::class); + $process->register(); + try { $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next); trigger_error('Process handler restored', E_USER_WARNING); } finally { $process->unregister(); } + printf( - "Fresh handlers: %s | Fresh middleware: %s\n", + "Fresh handlers: %s | Fresh middleware: %s\n", $first !== $second ? 'yes' : 'no', $outer !== $inner ? 'yes' : 'no' ); -printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported); +printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported);
Executable recipe · examples/output-capture.php

Optional output-capture compatibility adapter

@@ -556,10 +598,12 @@

Make failure
observable.

$publicRenderer = new CallableThrowableHandler(static function (): void { echo 'Public failure page'; }); + $streams = new ThrowableStreamFactory(new Psr17Factory(), $publicRenderer); + $body = $streams->createStreamFromThrowable(new RuntimeException('Private diagnostics')); -printf("Captured body: %s\n", (string) $body); +printf("Captured body: %s\n", (string) $body);
03 / Observable behavior

Know what each boundary does.

diff --git a/docs/migration.md b/docs/migration.md index 948cb43..cd48313 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -3,9 +3,12 @@ This is the migration contract for the unreleased next major. It does not describe an already published Packagist release. FastForward applications can now adopt the [Interop service provider](fast-forward.md) -without changing the existing package or namespace. Merge definitions with -`AggregateServiceProvider`, library first and application last, so a required -reporter's failure cannot fall back to the silent default. Registration remains explicit; +without changing the existing package or namespace. The short bootstrap is +`container(ErrorHandlerServiceProvider::class, $application)` for external +dependencies and optional reporters. To override existing library service IDs, +merge definitions with `AggregateServiceProvider`, library first and application +last, so a required reporter is not shadowed or replaced after construction +fails. Registration remains explicit; use factory services for fresh/nested scopes because container lookups are cached. The Interop provider is the single container integration entrypoint. The Laminas `ConfigProvider` adapter and standalone `ErrorHandlerContainer` @@ -19,7 +22,7 @@ have been removed in favor of FastForward Container with this provider. 6. The Docker/Compose development setup is removed. Run the native Composer checks; CI supplies the PHP 8.3–8.5 dependency matrix. No application runtime image is supplied. 7. Replace `new Container\ErrorHandlerContainer($parent)` with - `FastForward\Container\container(new ErrorHandlerServiceProvider(), $parent)` + `FastForward\Container\container(ErrorHandlerServiceProvider::class, $parent)` when the parent only supplies external PSR dependencies. For application overrides, use the merged provider composition above. FastForward is now a runtime dependency, including its transitive packages; consumers resolve them diff --git a/examples/cli.php b/examples/cli.php index 871b027..b7c9eb2 100644 --- a/examples/cli.php +++ b/examples/cli.php @@ -22,8 +22,11 @@ $reporter = new CallableThrowableHandler(static function (Throwable $error): void { fwrite(STDERR, get_class($error) . ': ' . $error->getMessage() . PHP_EOL); }); + $handler = new ErrorHandler($reporter); + $handler->register(); trigger_error('Example warning; execution continues.', E_USER_WARNING); + throw new RuntimeException('Example uncaught exception; exit status is 1.'); diff --git a/examples/custom-handlers.php b/examples/custom-handlers.php index 7c13b9d..5519b9d 100644 --- a/examples/custom-handlers.php +++ b/examples/custom-handlers.php @@ -18,9 +18,7 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use function FastForward\Container\container; @@ -48,25 +46,32 @@ public function handlePhpError(int $code, string $message, string $filename, int } } }; + $shutdowns = 0; $shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void { ++$shutdowns; }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { error_log($error->getMessage()); }), PhpErrorHandlerInterface::class => static fn (): PhpErrorHandlerInterface => $phpErrors, ShutdownHandlerInterface::class => static fn (): ShutdownHandlerInterface => $shutdown, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('Application-owned warning', E_USER_WARNING); $errors->handleShutdown(); // Simulate a notification without terminating the process. } finally { $errors->unregister(); } + printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messages), $shutdowns); diff --git a/examples/event-dispatcher.php b/examples/event-dispatcher.php index 4d3596f..cd34440 100644 --- a/examples/event-dispatcher.php +++ b/examples/event-dispatcher.php @@ -21,7 +21,6 @@ use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Phly\EventDispatcher\EventDispatcher; use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; @@ -34,6 +33,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; $deliveries = []; + $logger = new class () extends AbstractLogger { /** * @var list Synthetic private log records; replace with an application logger. @@ -54,6 +54,7 @@ public function log($level, $message, array $context = []): void $this->records[] = $level . ': ' . (string) $message; } }; + $throwables = new ThrowableListenerProvider( new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries): void { $deliveries[] = 'throwable: ' . $error->getMessage(); @@ -65,17 +66,24 @@ public function log($level, $message, array $context = []): void }) ); $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); + $dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events)); + $application = new ArrayServiceProvider([ EventDispatcherInterface::class => static fn (): EventDispatcherInterface => $dispatcher, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('Synthetic warning', E_USER_WARNING); } finally { $errors->unregister(); } + echo implode(PHP_EOL, $deliveries) . PHP_EOL; printf("Private log records: %d\n", count($logger->records)); diff --git a/examples/fast-forward.php b/examples/fast-forward.php index 9687a92..a5706b9 100644 --- a/examples/fast-forward.php +++ b/examples/fast-forward.php @@ -16,9 +16,7 @@ use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use Interop\Container\ServiceProviderInterface; use function FastForward\Container\container; @@ -34,7 +32,7 @@ public function getFactories(): array { return [ - ThrowableHandlerInterface::class => static fn (): CallableThrowableHandler => + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => new CallableThrowableHandler(static function (Throwable $error): void { printf("Reported: %s\n", $error->getMessage()); }), @@ -52,12 +50,15 @@ public function getExtensions(): array } }; -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); +$services = container(ErrorHandlerServiceProvider::class, $application); + /** * @var ErrorHandlerInterface $errors */ $errors = $services->get(ErrorHandlerInterface::class); + $errors->register(); + try { trigger_error('FastForward provider warning', E_USER_WARNING); } finally { diff --git a/examples/http-middleware.php b/examples/http-middleware.php index e94a83c..91c2bc8 100644 --- a/examples/http-middleware.php +++ b/examples/http-middleware.php @@ -15,10 +15,8 @@ use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -32,18 +30,24 @@ require dirname(__DIR__) . '/vendor/autoload.php'; $reported = []; + $psr17 = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function (Throwable $error) use (&$reported): void { $reported[] = $error; // Replace with a private logger; do not echo diagnostics. }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => $reporter, ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $process = $services->get(ErrorHandlerInterface::class); $middleware = $services->get(ErrorHandlerMiddleware::class); + $downstream = new class () implements RequestHandlerInterface { /** * Simulates an application failure with an HTTP error status. @@ -58,13 +62,17 @@ public function handle(ServerRequestInterface $request): ResponseInterface }; $process->register(); + try { $response = $middleware->process($psr17->createServerRequest('GET', 'https://example.test'), $downstream); + // The process handler is active again after the middleware scope ends. trigger_error('Synthetic post-request warning', E_USER_WARNING); } finally { $process->unregister(); } + printf("HTTP %d | %s\n", $response->getStatusCode(), (string) $response->getBody()); printf("Reported: %d\n", count($reported)); + // Your HTTP framework/emitter sends the response; this example only inspects it. diff --git a/examples/http-rendering.php b/examples/http-rendering.php index f460041..376c5c3 100644 --- a/examples/http-rendering.php +++ b/examples/http-rendering.php @@ -33,6 +33,7 @@ static function (Throwable $error) use (&$privateDiagnostics): void { $privateDiagnostics[] = $error; // Replace with a private logger; emit no response body. } ); + $psr17 = new Psr17Factory(); $streams = new class ($psr17) implements ThrowableStreamFactoryInterface { /** @@ -58,8 +59,10 @@ public function createStreamFromThrowable(Throwable $throwable): StreamInterface ); } }; + $responses = new ThrowableResponseFactory($streams, $psr17); $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $responses); + $downstream = new class () implements RequestHandlerInterface { /** * Simulates a private application failure with an HTTP error code. @@ -73,6 +76,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new RuntimeException('Private credentials', 503); } }; + $response = $middleware->process($psr17->createServerRequest('GET', '/'), $downstream) ->withHeader('Content-Type', 'application/json'); diff --git a/examples/output-capture.php b/examples/output-capture.php index 20eec05..b46d38c 100644 --- a/examples/output-capture.php +++ b/examples/output-capture.php @@ -24,7 +24,9 @@ $publicRenderer = new CallableThrowableHandler(static function (): void { echo 'Public failure page'; }); + $streams = new ThrowableStreamFactory(new Psr17Factory(), $publicRenderer); + $body = $streams->createStreamFromThrowable(new RuntimeException('Private diagnostics')); printf("Captured body: %s\n", (string) $body); diff --git a/examples/reporters.php b/examples/reporters.php index a4fb92a..a3b3c0b 100644 --- a/examples/reporters.php +++ b/examples/reporters.php @@ -25,6 +25,7 @@ $deliveries[] = 'first: ' . $error->getMessage(); }) ); + $reporters->attach(new CallableThrowableHandler( static function (Throwable $error) use (&$deliveries): void { $deliveries[] = 'second: ' . $error->getMessage(); @@ -32,6 +33,8 @@ static function (Throwable $error) use (&$deliveries): void { )); $errors = new ErrorHandler($reporters); + // Direct delivery needs no registration and does not terminate the process. $errors->handleThrowable(new RuntimeException('Synthetic failure')); + echo implode(PHP_EOL, $deliveries) . PHP_EOL; diff --git a/examples/scopes.php b/examples/scopes.php index 7901bbc..265752e 100644 --- a/examples/scopes.php +++ b/examples/scopes.php @@ -15,11 +15,9 @@ use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -34,22 +32,29 @@ require dirname(__DIR__) . '/vendor/autoload.php'; $reported = 0; + $psr17 = new Psr17Factory(); + $reporter = new CallableThrowableHandler(static function () use (&$reported): void { ++$reported; }); + $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => static fn (): ThrowableHandlerInterface => $reporter, + CallableThrowableHandler::class => static fn (): CallableThrowableHandler => $reporter, ResponseFactoryInterface::class => static fn (): ResponseFactoryInterface => $psr17, StreamFactoryInterface::class => static fn (): StreamFactoryInterface => $psr17, ]); -$services = container(new AggregateServiceProvider(new ErrorHandlerServiceProvider(), $application)); + +$services = container(ErrorHandlerServiceProvider::class, $application); + $makeHandler = $services->get(ErrorHandlerFactory::class); $first = $makeHandler($services); $second = $makeHandler($services); + $makeMiddleware = $services->get(ErrorHandlerMiddlewareFactory::class); $outer = $makeMiddleware($services); $inner = $makeMiddleware($services); + $downstream = new class () implements RequestHandlerInterface { /** * Throws a synthetic downstream failure for the inner scope. @@ -62,6 +67,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new RuntimeException('Nested failure'); } }; + $next = new class ($inner, $downstream) implements RequestHandlerInterface { /** * Stores the independently constructed inner middleware and application handler. @@ -84,14 +90,18 @@ public function handle(ServerRequestInterface $request): ResponseInterface return $this->inner->process($request, $this->downstream); } }; + $process = $services->get(ErrorHandlerInterface::class); + $process->register(); + try { $response = $outer->process($psr17->createServerRequest('GET', 'https://example.test'), $next); trigger_error('Process handler restored', E_USER_WARNING); } finally { $process->unregister(); } + printf( "Fresh handlers: %s | Fresh middleware: %s\n", $first !== $second ? 'yes' : 'no', From f2d789d0e8239140db6048f33151bfbc5256f07f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 16 Oct 2026 03:18:02 -0300 Subject: [PATCH 15/27] Keep major Changeset rehearsal free of trailing whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 9e541cc..2db07f0 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -4,7 +4,6 @@ Prepare the unreleased PHP 8.3+ major while retaining the package `coisa/error-handler` and namespace `CoiSA\ErrorHandler`. - - Require PHP `^8.3`, with a tested CI matrix of PHP 8.3–8.5. - Require FastForward Container and expose the Interop service provider as the container entrypoint. Remove the standalone Container/ConfigProvider APIs, @@ -26,7 +25,6 @@ Prepare the unreleased PHP 8.3+ major while retaining the package distribution checks and organized PHPUnit Unit/Functional/Process coverage. Preserve the existing regression gate; retire the prototype Fire/HTTP/Python parents and custom release publisher instead of duplicating master tooling. - The current 2.x manifest version and published changelog remain unchanged. Native callable constructor unions and ready JSON/developer-HTML adapters remain separate work; this note does not claim their implementation or perform a release. From 4ea93dbf197a0bf265093395449bd149592deb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Fri, 16 Oct 2026 15:02:38 -0300 Subject: [PATCH 16/27] test: cover buffer replacement during native handler destruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 2 + docs/testing.md | 7 ++ .../Message/ThrowableStreamFactoryTest.php | 118 ++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 2db07f0..f9f2a46 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -25,6 +25,8 @@ Prepare the unreleased PHP 8.3+ major while retaining the package distribution checks and organized PHPUnit Unit/Functional/Process coverage. Preserve the existing regression gate; retire the prototype Fire/HTTP/Python parents and custom release publisher instead of duplicating master tooling. +- Add a direct native regression for output-handler destruction that replaces its + removed buffer, proving bounded cleanup, primary-failure and caller ownership. The current 2.x manifest version and published changelog remain unchanged. Native callable constructor unions and ready JSON/developer-HTML adapters remain separate work; this note does not claim their implementation or perform a release. diff --git a/docs/testing.md b/docs/testing.md index b5a96c8..b957ddf 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -96,5 +96,12 @@ can terminate PHP or have arbitrary side effects; cleanup only removes reachable removable scopes and stops without progress. Reporters own private diagnostics; a safe PSR response body cannot prevent an application reporter from echoing. +An output-handler object's destructor can open a replacement buffer after PHP +removes its original scope. The direct stream-factory unit test verifies cleanup +stops when depth does not decrease, preserves the original failure and caller +bytes, and never creates a stream. The test explicitly removes its replacement +scopes and restores the fixture in finally; the factory does not promise to +remove every scope after native callbacks or destructors change the stack. + Release maintenance uses the existing [Changesets procedure](releases.md). Preparing or testing this draft does not merge, publish a release or deploy Pages. diff --git a/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php b/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php index f986d87..e45c609 100644 --- a/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php +++ b/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php @@ -435,4 +435,122 @@ static function (string $bytes) use ($secondary, &$calls): string { } } } + + /** + * Stops bounded cleanup when a native handler destructor replaces its removed buffer. + * + * The replacement remains removable for fixture cleanup; the caller's bytes and + * callback remain untouched until this test explicitly discards its own scopes. + */ + public function testCleanupStopsWhenAHandlerDestructorReplacesItsBuffer(): void + { + $original = new \Error('incoming event before native destruction'); + $primary = new \RuntimeException('renderer failure before native destruction'); + $received = null; + + /** @var \ArrayObject $events observed native callback and destructor counts */ + $events = new \ArrayObject(['callback' => 0, 'destructor' => 0, 'caller' => 0]); + $renderer = new CallableThrowableHandler( + /** + * Leaves the native output stack as the sole owner of its invokable handler. + * + * @param \Throwable $event the unchanged incoming event + * + * @throws \RuntimeException the primary renderer failure + */ + static function (\Throwable $event) use ($primary, $events, &$received): void { + $received = $event; + ob_start( + // Replaces its native output scope only when PHP releases this object. + new class ($events) { + /** + * Shares observations without retaining a reference to this handler. + * + * @param \ArrayObject $events native callback/destructor counts + */ + public function __construct(private \ArrayObject $events) + { + } + + /** + * Receives bytes during the actual native buffer close. + * + * @param string $bytes the renderer-owned output being discarded + * + * @return string the same bytes, with no stack manipulation in the callback + */ + public function __invoke(string $bytes): string + { + ++$this->events['callback']; + + return $bytes; + } + + /** Opens a removable replacement after PHP has popped the original buffer. */ + public function __destruct() + { + ++$this->events['destructor']; + ob_start(); + echo 'replacement renderer bytes'; + } + }, + ); + echo 'discarded renderer bytes'; + + throw $primary; + }, + ); + $streams = $this->createMock(StreamFactoryInterface::class); + $streams->expects(self::never())->method('createStream'); + $entry = ob_get_level(); + ob_start( + /** + * Records premature cleanup of the caller while retaining its original bytes. + * + * @param string $bytes the caller-owned output + * + * @return string unchanged caller-owned bytes + */ + static function (string $bytes) use ($events): string { + ++$events['caller']; + + return $bytes; + }, + ); + echo 'caller-owned bytes'; + $caller = ob_get_level(); + + try { + try { + (new ThrowableStreamFactory($streams, $renderer))->createStreamFromThrowable($original); + self::fail('The primary renderer failure must propagate.'); + } catch (\RuntimeException $caught) { + self::assertSame($primary, $caught); + } + + self::assertSame($original, $received); + self::assertSame(1, $events['callback']); + self::assertSame(1, $events['destructor']); + self::assertSame(0, $events['caller']); + self::assertSame($caller + 2, ob_get_level()); + self::assertSame('replacement renderer bytes', ob_get_contents()); + $flags = ob_get_status()['flags']; + self::assertIsInt($flags); + self::assertSame(PHP_OUTPUT_HANDLER_REMOVABLE, $flags & PHP_OUTPUT_HANDLER_REMOVABLE); + + ob_end_clean(); + ob_end_clean(); + + self::assertSame($caller, ob_get_level()); + self::assertSame('caller-owned bytes', ob_get_contents()); + self::assertSame(0, $events['caller']); + } finally { + while (ob_get_level() > $entry) { + ob_end_clean(); + } + } + + self::assertSame($entry, ob_get_level()); + self::assertSame(1, $events['caller']); + } } From e9fe5235988ef96562b046e9d9c95f72f6d34b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 17 Oct 2026 03:04:23 -0300 Subject: [PATCH 17/27] chore(release): avoid whitespace in generated major notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 7fe9f1c..955e124 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -4,7 +4,6 @@ Prepare the unreleased PHP 8.3+ major while retaining the package `coisa/error-handler` and namespace `CoiSA\ErrorHandler`. - - Require PHP `^8.3`, with a tested CI matrix of PHP 8.3–8.5. - Require FastForward Container and expose the Interop service provider as the container entrypoint. Remove the standalone Container/ConfigProvider APIs, @@ -38,7 +37,6 @@ Prepare the unreleased PHP 8.3+ major while retaining the package 2.5.0. Compose independent response providers with pure renderer aliases after the major middleware report; retain trusted debug configuration, lazy page overrides and fresh middleware scopes without a second diagnostic delivery. - -The current 2.x manifest version and published changelog remain unchanged. Native -callable constructor unions and content negotiation remain separate work; this -note does not claim their implementation or perform a release. +- Keep the current 2.x manifest version and published changelog unchanged. Native + callable constructor unions and content negotiation remain separate work; this + note does not claim their implementation or perform a release. From 1f33d4dfcb4b1fca302205db1e0da50580003166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 17 Oct 2026 12:23:59 -0300 Subject: [PATCH 18/27] feat: accept callable ErrorHandler constructor inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize matching callables with the existing adapters while preserving interface precedence, defaults and explicit lifecycle ownership. Cover real FastForward configuration and align the source documentation and complete consumer examples. Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 9 +- README.md | 23 +- docs/api.md | 28 +- docs/examples.md | 58 ++- docs/fast-forward.md | 19 +- docs/index.html | 3 +- docs/migration.md | 9 +- examples/README.md | 5 +- examples/custom-handlers.php | 10 +- examples/php-errors.php | 43 +- resources/skills/coisa-error-handler/SKILL.md | 14 +- .../ConfiguredErrorHandlerFactory.php | 6 +- .../coisa-error-handler/examples/README.md | 12 +- .../examples/configured.php | 11 + .../examples/container.php | 2 +- .../references/reporters.md | 27 +- src/ErrorHandler.php | 45 +- .../Factory/ErrorHandlerFactory.php | 11 +- tests/Fixtures/lifecycle.php | 25 +- .../Functional/ConsumerSkillExamplesTest.php | 13 +- .../ErrorHandlerServiceProviderTest.php | 115 +++++ tests/Unit/ErrorHandlerConstructorTest.php | 394 ++++++++++++++++++ 22 files changed, 757 insertions(+), 125 deletions(-) create mode 100644 tests/Unit/ErrorHandlerConstructorTest.php diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 955e124..cd3b1e2 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -37,6 +37,9 @@ Prepare the unreleased PHP 8.3+ major while retaining the package 2.5.0. Compose independent response providers with pure renderer aliases after the major middleware report; retain trusted debug configuration, lazy page overrides and fresh middleware scopes without a second diagnostic delivery. -- Keep the current 2.x manifest version and published changelog unchanged. Native - callable constructor unions and content negotiation remain separate work; this - note does not claim their implementation or perform a release. +- Accept matching callables in all three ErrorHandler constructor inputs using + existing adapters. Preserve interface identity and precedence, required reporter, + optional null defaults, inert construction and the existing reporting policies. + Keep explicit provider factories for cached services and fresh scopes. +- Keep the current 2.x manifest version and published changelog unchanged. Content + negotiation remains separate work; this note does not perform a release. diff --git a/README.md b/README.md index 85de803..246860a 100644 --- a/README.md +++ b/README.md @@ -35,22 +35,19 @@ Create `report.php` beside your application's `vendor/` directory: declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; require __DIR__ . '/vendor/autoload.php'; $reports = []; -$reporter = new CallableThrowableHandler( - /** - * Retain the synthetic failure without public diagnostic output. - * - * @param Throwable $error the failure to report - */ - static function (Throwable $error) use (&$reports): void { - $reports[] = $error; - }, -); +/** + * Retain the synthetic failure without public diagnostic output. + * + * @param Throwable $error the failure to report + */ +$reporter = static function (Throwable $error) use (&$reports): void { + $reports[] = $error; +}; $errors = new ErrorHandler($reporter); $failure = new RuntimeException('Example failure'); @@ -65,6 +62,10 @@ installs no PHP callbacks, and direct reporting returns when the reporter returns. Reporter failures propagate. Replace the in-memory reporter with your application's private diagnostic sink. +All three constructor inputs accept the matching handler interface or a callable; +omitted PHP-error and shutdown inputs retain their defaults. Existing interface +objects keep their identity. See the [complete three-callback recipe](examples/php-errors.php). + ## 🧰 Choose an integration | Need | Entry point and guide | diff --git a/docs/api.md b/docs/api.md index e43da71..d586627 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,6 +37,22 @@ for nested scopes. These stacks do not provide concurrent-request/fiber isolatio | `PhpErrorHandlerInterface` | Apply policy to PHP severity/message/file/line | | `ShutdownHandlerInterface` | Handle a shutdown notification | +The required `$throwableHandler` constructor argument accepts +`ThrowableHandlerInterface|callable`. Optional `$phpErrorHandler` and +`$shutdownHandler` accept their matching interface, a callable or null. The +parameter names and order are unchanged. Interface instances retain their identity +and take precedence over `__invoke()` when an object supports both contracts. +Other callables are wrapped with the existing `CallableThrowableHandler`, +`CallablePhpErrorHandler` or `CallableShutdownHandler`, without being invoked +during construction. Invalid non-callable inputs raise TypeError at that boundary. + +Callbacks receive one Throwable, four PHP-error arguments, or no shutdown +arguments respectively; their return values are ignored. Explicit adapter objects +remain useful when another API requires an interface. Null and omitted options +retain the default policies below, including shutdown reporting through the same +ErrorHandler. The [complete callback recipe](../examples/php-errors.php) supplies +all three callables and pairs active shutdown notification with cleanup. + The default `ThrowErrorExceptionPhpErrorHandler` honors `error_reporting()` and suppression, converts reportable errors and lets the outer handler report them. Recoverable warnings continue. The default `PhpLastErrorShutdownHandler` inspects @@ -118,12 +134,12 @@ unchanged. Through ErrorHandler, a thrown ErrorException follows the normal reporting boundary; other failures propagate. The [complete PHP-error example](../examples/php-errors.php) explicitly checks `error_reporting() & $code` before converting an enabled error. -Inject the wrapper as the second ErrorHandler argument, or bind the instance as -`PhpErrorHandlerInterface` in the application provider. Null keeps the existing -default. All three ErrorHandler constructor positions still require their typed -interface instances in this candidate. Bare-callable constructor inputs remain -[separate major work](https://github.com/coisa/error-handler/issues/70), not an -implemented API. Construction and discovery never register native callbacks. +Pass the callable directly as the second ErrorHandler argument, or keep the +explicit wrapper when an interface instance is needed. The provider's explicit +handler factory also accepts either form under `PhpErrorHandlerInterface`; +use FastForward's `ServiceFactory` to bind a callback as a value rather than a +construction factory. Null keeps the existing default. Construction and discovery +never register native callbacks. ## HTTP: report once, then render explicitly diff --git a/docs/examples.md b/docs/examples.md index 2958098..c07ba22 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -400,12 +400,12 @@ Supply explicit interface implementations and a shutdown callback. [Source](../e declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandlerInterface; -use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use FastForward\Container\Factory\ServiceFactory; use function FastForward\Container\container; @@ -434,9 +434,11 @@ $phpErrors = new class implements PhpErrorHandlerInterface { }; $shutdowns = 0; -$shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void { + +/** Records the explicit notification on the active lifecycle scope. */ +$shutdown = static function () use (&$shutdowns): void { ++$shutdowns; -}); +}; $application = new ArrayServiceProvider([ CallableThrowableHandler::class => static fn(): CallableThrowableHandler => new CallableThrowableHandler( @@ -450,7 +452,7 @@ $application = new ArrayServiceProvider([ }, ), PhpErrorHandlerInterface::class => static fn(): PhpErrorHandlerInterface => $phpErrors, - ShutdownHandlerInterface::class => static fn(): ShutdownHandlerInterface => $shutdown, + ShutdownHandlerInterface::class => new ServiceFactory($shutdown), ]); $services = container(ErrorHandlerServiceProvider::class, $application); @@ -736,7 +738,8 @@ echo 'explicit filtering, severity selection and original-object delivery passed ## Callable PHP-error conversion -Keep four original arguments and application-owned mask policy. [Source](../examples/php-errors.php). +Pass three callbacks directly to ErrorHandler, retaining four original PHP-error arguments, +application-owned mask policy and explicit active shutdown notification. [Source](../examples/php-errors.php). ```php @@ -746,16 +749,23 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Exception\ErrorException; -use CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; require __DIR__ . '/bootstrap.php'; $calls = []; $reports = []; -$phpErrors = new CallablePhpErrorHandler( +$shutdowns = 0; +$errors = new ErrorHandler( + /** + * Record a converted synthetic failure without output or a real transport. + * + * @param Throwable $error the callback's converted error + */ + static function (Throwable $error) use (&$reports): void { + $reports[] = $error; + }, /** - * Own conversion and suppression explicitly; the adapter adds neither policy. + * Own conversion and suppression explicitly; normalization adds neither policy. * * @param int $code the synthetic PHP severity * @param string $message the synthetic diagnostic message @@ -770,19 +780,13 @@ $phpErrors = new CallablePhpErrorHandler( throw ErrorException::fromPhpError($message, $code, $code, $filename, $line); } }, -); -$errors = new ErrorHandler(new CallableThrowableHandler( - /** - * Record a converted synthetic failure without output or a real transport. - * - * @param Throwable $error the callback's converted error - */ - static function (Throwable $error) use (&$reports): void { - $reports[] = $error; + /** Count explicit active shutdown notifications without recovering or terminating a process. */ + static function () use (&$shutdowns): void { + ++$shutdowns; }, -), $phpErrors); +); -exampleCheck([] === $calls && [] === $reports, 'Construction invoked a callback.'); +exampleCheck([] === $calls && [] === $reports && 0 === $shutdowns, 'Construction invoked a callback.'); $previous = error_reporting(E_ALL); try { @@ -803,6 +807,18 @@ exampleCheck( ); exampleCheck('Synthetic enabled input' === $reports[0]->getMessage(), 'Converted diagnostic metadata was lost.'); +$errors->handleShutdown(); +exampleCheck(0 === $shutdowns, 'An inactive handler invoked the shutdown policy.'); +$errors->register(); + +try { + $errors->handleShutdown(); +} finally { + $errors->unregister(); +} + +exampleCheck(1 === $shutdowns, 'The active shutdown callback was not invoked once.'); + echo 'callable PHP errors: four arguments, explicit mask policy and conversion passed' . PHP_EOL; ``` @@ -1434,4 +1450,4 @@ Middleware requires lifecycle and reporting on one scoped object. It reports onc Unregister in reverse order and use distinct nested instances. Failed-reporter suppression applies within a registered lifecycle; independent direct calls remain usable. Arbitrary exit/die, engine fatals, callback side effects and concurrent native callback scopes are not recovered. See [provider](fast-forward.md), [API](api.md), [migration](migration.md) and [testing](testing.md). -The explicit logging, filtering, callable PHP-error and best-effort adapters are retained from delivered 2.x APIs. The default aggregate remains fail-fast; no implicit adapter discovery is added. The JSON/developer-HTML adapters and independent profiles delivered in 2.5.0 are retained with pure renderers after this major's middleware report. Native callable constructor inputs remain planned in #70. +The explicit logging, filtering, callable PHP-error and best-effort adapters are retained from delivered 2.x APIs. The default aggregate remains fail-fast; no implicit adapter discovery is added. The JSON/developer-HTML adapters and independent profiles delivered in 2.5.0 are retained with pure renderers after this major's middleware report. ErrorHandler accepts matching callables directly while preserving interface precedence, default policies and explicit lifecycle ownership. diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 1102aa0..aa9a67b 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -154,13 +154,18 @@ ThrowableHandlerInterface using the merged-provider pattern below. Neither adapt is added to the optional discovery list. A logger binding alone does not opt into direct logging, and a filter does not change the separate public HTTP renderer. -For an explicit PHP-error policy, construct CallablePhpErrorHandler and bind its -instance under PhpErrorHandlerInterface. ErrorHandlerFactory passes that optional -interface into process and fresh middleware handlers, preserving four arguments, -ignored callback results and the callback's own mask/conversion policy. It adds no -factory or automatic registration. The three constructor inputs remain typed -interfaces; [bare-callable inputs](https://github.com/coisa/error-handler/issues/70) -are separate pending major work. See [adapter contracts](api.md#callable-php-error-policy) +For an explicit PHP-error policy, bind an interface instance or callable under +PhpErrorHandlerInterface. ErrorHandlerFactory passes the configured value into +process and fresh middleware handlers, preserving four arguments, ignored callback +results and the callback's own mask/conversion policy. The same choice is available +under ThrowableHandlerInterface and ShutdownHandlerInterface. Bind callbacks as +values with `ServiceFactory($callback)`; a raw callable in a factory map describes +service construction instead. Required overrides use the aggregate below. + +The explicit ErrorHandlerFactory remains responsible for optional defaults and +fresh scopes; this does not require a container to autowire arbitrary union types. +Interface instances take precedence over their callable capability. No callback +is executed or registered during construction. See [adapter contracts](api.md#callable-php-error-policy) and the [logging](../examples/logging.php), [filtering](../examples/filtering.php) and [PHP-error](../examples/php-errors.php) examples. diff --git a/docs/index.html b/docs/index.html index 47dc4d1..594982b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -181,7 +181,8 @@

Supply PHP-error policy explicitly

throw ErrorException::fromPhpError($message, $code, $code, $filename, $line); } -

The void interface ignores false/null callback results; they do not restore native PHP fall-through. There is no fifth error-context argument. The default adapter honors suppression. All three core constructor inputs are still interface-typed in this candidate; bare-callable constructor inputs remain separate unimplemented work.

+

All three core constructor inputs accept their matching interface or a callable. Existing interface objects retain identity and take precedence over __invoke(). The reporter is required; omitted or null PHP-error and shutdown inputs keep their defaults. Construction invokes no callback and installs nothing.

+

The void interface ignores false/null callback results; they do not restore native PHP fall-through. There is no fifth error-context argument. A custom PHP callback owns suppression/conversion; the default adapter honors suppression. The complete PHP-error recipe supplies all three callbacks and pairs active shutdown notification with cleanup.

diff --git a/docs/migration.md b/docs/migration.md index 67bfecc..119f0b5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -62,8 +62,13 @@ keeps the exact message and reserved exception context; filtering skips only an explicit delegate; PHP-error callbacks retain four arguments, ignored returns and application-owned mask policy. They are not automatically discovered or attached to the default aggregate. See [their API contracts](api.md#direct-psr-3-logging). -The constructor remains interface-typed in this candidate; accepting bare -callables directly is [tracked separately in #70](https://github.com/coisa/error-handler/issues/70). +All three ErrorHandler constructor inputs now also accept callables. Existing +interface instances keep their identity and take precedence over `__invoke()`; +no migration of existing adapters is required. The reporter remains required; +the second and third arguments still default to null and retain the original PHP +error and fatal-shutdown policies. Callable results are ignored, including false +from the four-argument PHP callback. Construction remains inert. See the +[complete callback recipe](../examples/php-errors.php). Before upgrading a real application, run the [documented checks](testing.md) and application-level tests for the actual reporter, middleware stack and PHP SAPI. These isolated fixtures do not establish reliability of third-party loggers, remote services or production infrastructure. diff --git a/examples/README.md b/examples/README.md index 1753904..9ec5cab 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,8 +28,9 @@ The [cookbook](../docs/examples.md) contains all seventeen complete sources, inc imports and every executable branch, with only the leading license header omitted. Its exact source parity and recipe inventory are checked alongside the [site](../docs/index.html#examples). The four explicit logging/filtering/PHP-error/ -best-effort adapters are preserved from delivered 2.x APIs; callable constructor -unions remain planned. +best-effort adapters are preserved from delivered 2.x APIs. The PHP-error recipe +passes three callbacks directly to ErrorHandler while retaining explicit mask +policy and active-scope shutdown ownership. The recipes do not start a server, emit HTTP to clients or deliver real logs. Applications own transports, private diagnostics and emission. diff --git a/examples/custom-handlers.php b/examples/custom-handlers.php index 4b6560f..5c1d91f 100644 --- a/examples/custom-handlers.php +++ b/examples/custom-handlers.php @@ -15,12 +15,12 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandlerInterface; -use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use FastForward\Container\Factory\ServiceFactory; use function FastForward\Container\container; @@ -49,9 +49,11 @@ public function handlePhpError(int $code, string $message, string $filename, int }; $shutdowns = 0; -$shutdown = new CallableShutdownHandler(static function () use (&$shutdowns): void { + +/** Records the explicit notification on the active lifecycle scope. */ +$shutdown = static function () use (&$shutdowns): void { ++$shutdowns; -}); +}; $application = new ArrayServiceProvider([ CallableThrowableHandler::class => static fn(): CallableThrowableHandler => new CallableThrowableHandler( @@ -65,7 +67,7 @@ static function (Throwable $error): void { }, ), PhpErrorHandlerInterface::class => static fn(): PhpErrorHandlerInterface => $phpErrors, - ShutdownHandlerInterface::class => static fn(): ShutdownHandlerInterface => $shutdown, + ShutdownHandlerInterface::class => new ServiceFactory($shutdown), ]); $services = container(ErrorHandlerServiceProvider::class, $application); diff --git a/examples/php-errors.php b/examples/php-errors.php index 0918c59..71ab6d2 100644 --- a/examples/php-errors.php +++ b/examples/php-errors.php @@ -16,16 +16,23 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Exception\ErrorException; -use CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; require __DIR__ . '/bootstrap.php'; $calls = []; $reports = []; -$phpErrors = new CallablePhpErrorHandler( +$shutdowns = 0; +$errors = new ErrorHandler( /** - * Own conversion and suppression explicitly; the adapter adds neither policy. + * Record a converted synthetic failure without output or a real transport. + * + * @param Throwable $error the callback's converted error + */ + static function (Throwable $error) use (&$reports): void { + $reports[] = $error; + }, + /** + * Own conversion and suppression explicitly; normalization adds neither policy. * * @param int $code the synthetic PHP severity * @param string $message the synthetic diagnostic message @@ -40,19 +47,13 @@ static function (int $code, string $message, string $filename, int $line) use (& throw ErrorException::fromPhpError($message, $code, $code, $filename, $line); } }, -); -$errors = new ErrorHandler(new CallableThrowableHandler( - /** - * Record a converted synthetic failure without output or a real transport. - * - * @param Throwable $error the callback's converted error - */ - static function (Throwable $error) use (&$reports): void { - $reports[] = $error; + /** Count explicit active shutdown notifications without recovering or terminating a process. */ + static function () use (&$shutdowns): void { + ++$shutdowns; }, -), $phpErrors); +); -exampleCheck([] === $calls && [] === $reports, 'Construction invoked a callback.'); +exampleCheck([] === $calls && [] === $reports && 0 === $shutdowns, 'Construction invoked a callback.'); $previous = error_reporting(E_ALL); try { @@ -73,4 +74,16 @@ static function (Throwable $error) use (&$reports): void { ); exampleCheck('Synthetic enabled input' === $reports[0]->getMessage(), 'Converted diagnostic metadata was lost.'); +$errors->handleShutdown(); +exampleCheck(0 === $shutdowns, 'An inactive handler invoked the shutdown policy.'); +$errors->register(); + +try { + $errors->handleShutdown(); +} finally { + $errors->unregister(); +} + +exampleCheck(1 === $shutdowns, 'The active shutdown callback was not invoked once.'); + echo 'callable PHP errors: four arguments, explicit mask policy and conversion passed' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/SKILL.md b/resources/skills/coisa-error-handler/SKILL.md index ea14714..f488670 100644 --- a/resources/skills/coisa-error-handler/SKILL.md +++ b/resources/skills/coisa-error-handler/SKILL.md @@ -29,8 +29,9 @@ selected source and autoloader; do not install or upgrade dependencies solely to fit these instructions. No published next-major Composer version is assumed. Use the application's configuration and secret mechanisms. Error Handler accepts -objects; it does not load application configuration files or environment values -automatically. FastForward Container is a runtime dependency; the Interop service +handler interfaces or matching callables; it does not load application +configuration files or environment values automatically. FastForward Container +is a runtime dependency; the Interop service provider is the container integration entrypoint. The old container and `ConfigProvider` are removed in this source contract. @@ -47,14 +48,13 @@ composition is verified. declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandler; -use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; require __DIR__ . '/vendor/autoload.php'; $reports = []; $configuration = [ - 'reporter' => new CallableThrowableHandler( + 'reporter' => /** * Retain the original synthetic failure without public output. * @@ -63,7 +63,6 @@ $configuration = [ static function (Throwable $error) use (&$reports): void { $reports[] = $error; }, - ), ]; $errors = new ErrorHandler($configuration['reporter']); @@ -88,8 +87,9 @@ For configuration split across files, use the complete `config/error-handler.php` supplies the reporter, the application factory applies explicit overrides with `array_replace`, and `configured.php` loads the selected consumer autoloader. Override keys win; missing or wrong-type reporters fail -before construction. These keys and the example factory belong to the example -application, not to a library configuration API. +before construction. Either an interface instance or a callable can be selected; +interface instances retain identity and win over `__invoke()`. These keys and the +example factory belong to the example application, not to a library configuration API. ```sh php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/configured.php ./vendor/autoload.php diff --git a/resources/skills/coisa-error-handler/examples/ConfiguredErrorHandlerFactory.php b/resources/skills/coisa-error-handler/examples/ConfiguredErrorHandlerFactory.php index 6c244b5..4a641db 100644 --- a/resources/skills/coisa-error-handler/examples/ConfiguredErrorHandlerFactory.php +++ b/resources/skills/coisa-error-handler/examples/ConfiguredErrorHandlerFactory.php @@ -32,7 +32,7 @@ final class ConfiguredErrorHandlerFactory * @param array $configuration application configuration * @param array $overrides explicit application or test overrides * - * @return ErrorHandler a new handler using the selected reporter + * @return ErrorHandler a new handler using the selected interface or callable reporter * * @throws \InvalidArgumentException when the selected reporter is absent or invalid */ @@ -41,8 +41,8 @@ public function __invoke(array $configuration, array $overrides = []): ErrorHand $settings = array_replace($configuration, $overrides); $reporter = $settings['reporter'] ?? null; - if (!$reporter instanceof ThrowableHandlerInterface) { - throw new \InvalidArgumentException('Configure a ThrowableHandlerInterface reporter.'); + if (!$reporter instanceof ThrowableHandlerInterface && !\is_callable($reporter)) { + throw new \InvalidArgumentException('Configure an interface or callable reporter.'); } return new ErrorHandler($reporter); diff --git a/resources/skills/coisa-error-handler/examples/README.md b/resources/skills/coisa-error-handler/examples/README.md index 9c474b5..e997455 100644 --- a/resources/skills/coisa-error-handler/examples/README.md +++ b/resources/skills/coisa-error-handler/examples/README.md @@ -34,9 +34,10 @@ autoloader. Expect exit 0 and `configured reporting: original delivered once`. `config/error-handler.php` selects a `RecordingThrowableHandler`. -`ConfiguredErrorHandlerFactory` applies flat explicit overrides after that -configuration, rejects absent/invalid reporters and returns a fresh -`ErrorHandler`. The program verifies original-object identity and one delivery. +`ConfiguredErrorHandlerFactory` accepts interface or callable reporters and +applies flat explicit overrides after that configuration, rejects absent/invalid +reporters and returns a fresh `ErrorHandler`. The program verifies original-object +identity and one delivery for the configured interface and a callable override. The factory and its keys are application example code, not library APIs. To integrate in an application, copy those named files into its own source/config @@ -59,8 +60,9 @@ The source-checkout equivalent replaces `vendor/coisa/error-handler/` with `./`. Expect exit 0 and `Interop provider: application reporter, caching and fresh scope passed`. -The application maps its required `ThrowableHandlerInterface` through -FastForward's `ServiceFactory`. One `AggregateServiceProvider` merges library +The application maps its required `ThrowableHandlerInterface` to the reporter's +first-class method callable through FastForward's `ServiceFactory`. +One `AggregateServiceProvider` merges library definitions first and application definitions last, so the explicit reporter wins. The program checks cached core identity and invokes the reusable `ServiceProvider\Factory\ErrorHandlerFactory` service for a distinct scope. diff --git a/resources/skills/coisa-error-handler/examples/configured.php b/resources/skills/coisa-error-handler/examples/configured.php index e12c811..8fa0cf8 100644 --- a/resources/skills/coisa-error-handler/examples/configured.php +++ b/resources/skills/coisa-error-handler/examples/configured.php @@ -35,4 +35,15 @@ throw new RuntimeException('Expected one private delivery of the original throwable.'); } +$selected = new RecordingThrowableHandler(); +$overridden = (new ConfiguredErrorHandlerFactory())($configuration, [ + 'reporter' => $selected->handleThrowable(...), +]); + +$overridden->handleThrowable($failure); + +if ([$failure] !== $selected->getThrowables() || [$failure] !== $configuration['reporter']->getThrowables()) { + throw new RuntimeException('Expected a callable override to replace the configured reporter.'); +} + echo 'configured reporting: original delivered once' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/examples/container.php b/resources/skills/coisa-error-handler/examples/container.php index 421501f..473a2b8 100644 --- a/resources/skills/coisa-error-handler/examples/container.php +++ b/resources/skills/coisa-error-handler/examples/container.php @@ -34,7 +34,7 @@ $reporter = $configuration['reporter']; $application = new ArrayServiceProvider([ - ThrowableHandlerInterface::class => new ServiceFactory($reporter), + ThrowableHandlerInterface::class => new ServiceFactory($reporter->handleThrowable(...)), ]); $services = container(new AggregateServiceProvider( diff --git a/resources/skills/coisa-error-handler/references/reporters.md b/resources/skills/coisa-error-handler/references/reporters.md index 8fe2904..4eb8842 100644 --- a/resources/skills/coisa-error-handler/references/reporters.md +++ b/resources/skills/coisa-error-handler/references/reporters.md @@ -10,11 +10,28 @@ an application collaborator, not a discovered transport. the callback's return value. `CallablePhpErrorHandler($callback)` receives `(int $code, string $message, string $filename, int $line)` unchanged; return values, including false/null, are ignored by its void contract. The callback -owns mask/conversion policy; this is not native false/fall-through. Inject the -adapter instance as the core's second argument. This source contract still expects -typed handler objects, not bare callables. `CallableShutdownHandler($callback)` -is an explicit shutdown policy, not fatal recovery. Adapter failures propagate. Bare-callable constructor inputs are separate -major work, not part of this source contract. +owns mask/conversion policy; this is not native false/fall-through. + +All three ErrorHandler constructor inputs accept the matching interface or a +callable. The reporter is required; PHP-error and shutdown inputs default to null, +retaining their built-in policies. Normalization uses `CallableThrowableHandler`, +`CallablePhpErrorHandler` and `CallableShutdownHandler`; it invokes nothing during +construction. If an object implements the relevant interface and `__invoke()`, the interface method +and original instance win. Invalid non-callable values raise TypeError; callback +signature errors arise when PHP invokes the callback, not through preflight calls. + +The callable shapes are `callable(Throwable): mixed`, +`callable(int, string, string, int): mixed` and `callable(): mixed` respectively. +`CallableShutdownHandler($callback)` or a direct third-argument callback is an +explicit shutdown policy, not fatal recovery. The ErrorHandler lifecycle still +requires an active registration before shutdown delivery. Adapter failures retain +the handler's reporting/propagation policy. Keep explicit adapters when another +API requires an interface, such as an aggregate or filtering delegate. + +For FastForward, bind a callback value through `ServiceFactory($callback)` under +the matching interface service ID and merge required application overrides last. +The explicit handler factory preserves cached process services and fresh scope +creation; arbitrary union autowiring is not a prerequisite. ## Filtering and ordered delivery diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index 1778dc5..29d43e5 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -16,6 +16,9 @@ namespace CoiSA\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\PhpLastErrorShutdownHandler; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; @@ -87,22 +90,40 @@ final class ErrorHandler implements private \Closure $exceptionCallback; /** - * Constructs the ErrorHandler. + * Injects interface implementations or normalizes callables without invoking them. * - * @param ThrowableHandlerInterface $throwableHandler handles throwable exceptions - * @param ?PhpErrorHandlerInterface $phpErrorHandler Handles PHP errors. - * Defaults to ThrowErrorExceptionPhpErrorHandler. - * @param ?ShutdownHandlerInterface $shutdownHandler Handles shutdown events. - * Defaults to PhpLastErrorShutdownHandler. + * Supplied interfaces take precedence over an object's callable capability and + * retain their identity. Callback results are ignored by the matching adapters. + * Native PHP registration remains explicit; default shutdown routes through this + * handler, preserving its reporting and PHP-error policy. + * + * @param callable|ThrowableHandlerInterface $throwableHandler the required reporter + * @param null|callable|PhpErrorHandlerInterface $phpErrorHandler null keeps the default PHP-error policy + * @param null|callable|ShutdownHandlerInterface $shutdownHandler null keeps default shutdown reporting + * + * @phpstan-param ThrowableHandlerInterface|(callable(\Throwable): mixed) $throwableHandler + * @phpstan-param PhpErrorHandlerInterface|(callable(int, string, string, int): mixed)|null $phpErrorHandler + * @phpstan-param ShutdownHandlerInterface|(callable(): mixed)|null $shutdownHandler */ public function __construct( - ThrowableHandlerInterface $throwableHandler, - ?PhpErrorHandlerInterface $phpErrorHandler = null, - ?ShutdownHandlerInterface $shutdownHandler = null, + ThrowableHandlerInterface|callable $throwableHandler, + PhpErrorHandlerInterface|callable|null $phpErrorHandler = null, + ShutdownHandlerInterface|callable|null $shutdownHandler = null, ) { - $this->throwableHandler = $throwableHandler; - $this->phpErrorHandler = $phpErrorHandler ?? new ThrowErrorExceptionPhpErrorHandler(); - $this->shutdownHandler = $shutdownHandler ?? new PhpLastErrorShutdownHandler($this); + $this->throwableHandler = $throwableHandler instanceof ThrowableHandlerInterface + ? $throwableHandler + : new CallableThrowableHandler($throwableHandler); + + $phpErrorHandler ??= new ThrowErrorExceptionPhpErrorHandler(); + $this->phpErrorHandler = $phpErrorHandler instanceof PhpErrorHandlerInterface + ? $phpErrorHandler + : new CallablePhpErrorHandler($phpErrorHandler); + + $shutdownHandler ??= new PhpLastErrorShutdownHandler($this); + $this->shutdownHandler = $shutdownHandler instanceof ShutdownHandlerInterface + ? $shutdownHandler + : new CallableShutdownHandler($shutdownHandler); + $this->errorCallback = $this->onPhpError(...); $this->exceptionCallback = $this->onUncaughtThrowable(...); } diff --git a/src/ServiceProvider/Factory/ErrorHandlerFactory.php b/src/ServiceProvider/Factory/ErrorHandlerFactory.php index 56291ed..69d88fb 100644 --- a/src/ServiceProvider/Factory/ErrorHandlerFactory.php +++ b/src/ServiceProvider/Factory/ErrorHandlerFactory.php @@ -26,8 +26,9 @@ * Class ErrorHandlerFactory. * * A factory responsible for creating instances of ErrorHandler. - * This factory SHALL ensure proper dependency injection of ThrowableHandlerInterface, - * PhpErrorHandlerInterface, and ShutdownHandlerInterface. + * Interface service IDs SHALL supply interface implementations or matching callables. + * Normalization belongs to ErrorHandler; this factory preserves optional defaults + * and constructs a fresh scope without registering native callbacks. * * @package CoiSA\ErrorHandler\ServiceProvider\Factory */ @@ -46,19 +47,19 @@ final class ErrorHandlerFactory public function __invoke(ContainerInterface $container): ErrorHandler { /** - * @var ThrowableHandlerInterface $throwableHandler + * @var (callable(\Throwable): mixed)|ThrowableHandlerInterface $throwableHandler */ $throwableHandler = $container->get(ThrowableHandlerInterface::class); /** - * @var null|PhpErrorHandlerInterface $phpErrorHandler + * @var null|(callable(int, string, string, int): mixed)|PhpErrorHandlerInterface $phpErrorHandler */ $phpErrorHandler = $container->has(PhpErrorHandlerInterface::class) ? $container->get(PhpErrorHandlerInterface::class) : null; /** - * @var null|ShutdownHandlerInterface $shutdownHandler + * @var null|(callable(): mixed)|ShutdownHandlerInterface $shutdownHandler */ $shutdownHandler = $container->has(ShutdownHandlerInterface::class) ? $container->get(ShutdownHandlerInterface::class) diff --git a/tests/Fixtures/lifecycle.php b/tests/Fixtures/lifecycle.php index cc0a998..b725185 100644 --- a/tests/Fixtures/lifecycle.php +++ b/tests/Fixtures/lifecycle.php @@ -21,19 +21,18 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; $case = $argv[1] ?? ''; -$reporter = new CallableThrowableHandler( - /** - * Records actual events delivered by native exception/shutdown callbacks. - * - * @param Throwable $event the event supplied by the real handler - */ - static function (Throwable $event): void { - echo json_encode([ - 'event' => 'error', 'class' => $event::class, - 'severity' => $event instanceof ErrorException ? $event->getSeverity() : null, - ], JSON_THROW_ON_ERROR) . "\n"; - }, -); + +/** + * Records native exception/shutdown events through the constructor's callable adapter. + * + * @param Throwable $event the event supplied by the real handler + */ +$reporter = static function (Throwable $event): void { + echo json_encode([ + 'event' => 'error', 'class' => $event::class, + 'severity' => $event instanceof ErrorException ? $event->getSeverity() : null, + ], JSON_THROW_ON_ERROR) . "\n"; +}; $handler = new ErrorHandler($reporter); switch ($case) { diff --git a/tests/Functional/ConsumerSkillExamplesTest.php b/tests/Functional/ConsumerSkillExamplesTest.php index 1a13742..f63a632 100644 --- a/tests/Functional/ConsumerSkillExamplesTest.php +++ b/tests/Functional/ConsumerSkillExamplesTest.php @@ -42,14 +42,17 @@ final class ConsumerSkillExamplesTest extends TestCase { /** * Configuration overrides select the actual reporter before creating a fresh scope. + * + * @param bool $callback whether to select the reporter's first-class method callable */ - public function testExplicitOverridesWinAndFactoryScopesAreDistinct(): void + #[DataProvider('provideExplicitOverridesWinAndFactoryScopesAreDistinctCases')] + public function testExplicitOverridesWinAndFactoryScopesAreDistinct(bool $callback): void { $default = new RecordingThrowableHandler(); $selected = new RecordingThrowableHandler(); $factory = new ConfiguredErrorHandlerFactory(); $configuration = ['reporter' => $default]; - $overrides = ['reporter' => $selected]; + $overrides = ['reporter' => $callback ? $selected->handleThrowable(...) : $selected]; $first = $factory($configuration, $overrides); $second = $factory($configuration, $overrides); @@ -66,6 +69,12 @@ public function testExplicitOverridesWinAndFactoryScopesAreDistinct(): void self::assertSame($default, $configuration['reporter']); } + /** @return array interface and callable application override forms */ + public static function provideExplicitOverridesWinAndFactoryScopesAreDistinctCases(): iterable + { + return ['interface' => [false], 'callable' => [true]]; + } + /** * Missing and wrong-type reporters fail before the application starts. * diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php index cd936f8..64c7e5b 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php @@ -38,11 +38,14 @@ use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use FastForward\Container\Factory\ServiceFactory; use Interop\Container\ServiceProviderInterface; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\WithoutErrorHandler; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Psr\Container\ContainerExceptionInterface; @@ -55,6 +58,7 @@ use Psr\Http\Server\RequestHandlerInterface; use function FastForward\Container\container; +use function FastForward\Config\config; /** * Verifies real FastForward wiring, reporting failures and independent middleware scopes. @@ -400,6 +404,117 @@ public function testReusableFactoryCreatesFreshHandlersWithoutReplacingTheProces self::assertSame($process, $container->get(ErrorHandlerInterface::class)); } + /** Real configuration can bind callbacks without replacing the provider's explicit scope factories. */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testConfiguredCallbacksPreserveOverridesCachingAndFreshScopes(): void + { + $reports = []; + $phpErrors = []; + $shutdowns = 0; + + /** @param \Throwable $event the original private diagnostic */ + $reportCallback = static function (\Throwable $event) use (&$reports): void { + $reports[] = $event; + }; + + /** + * Records native metadata while returning a value the void adapter ignores. + * + * @param int $code the exact PHP severity + * @param string $message the exact PHP diagnostic + * @param string $filename the actual diagnostic source + * @param int $line the actual source line + * + * @return false no request for native fall-through is implied + */ + $phpErrorCallback = static function ( + int $code, + string $message, + string $filename, + int $line, + ) use (&$phpErrors): false { + $phpErrors[] = [$code, $message, $filename, $line]; + + return false; + }; + + /** Records the explicitly notified active scope. */ + $shutdownCallback = static function () use (&$shutdowns): void { + ++$shutdowns; + }; + $settings = config(['callbacks' => [ + 'reporter' => $reportCallback, + 'php_errors' => $phpErrorCallback, + 'shutdown' => $shutdownCallback, + ]]); + $reporter = $settings->get('callbacks.reporter'); + $phpCallback = $settings->get('callbacks.php_errors'); + $shutdown = $settings->get('callbacks.shutdown'); + self::assertIsCallable($reporter); + self::assertIsCallable($phpCallback); + self::assertIsCallable($shutdown); + $messages = new Psr17Factory(); + $application = new ArrayServiceProvider([ + ThrowableHandlerInterface::class => new ServiceFactory($reporter), + PhpErrorHandlerInterface::class => new ServiceFactory($phpCallback), + ShutdownHandlerInterface::class => new ServiceFactory($shutdown), + ResponseFactoryInterface::class => new ServiceFactory($messages), + StreamFactoryInterface::class => new ServiceFactory($messages), + ]); + $before = self::callbacks(); + $services = self::withApplication($application); + $process = $services->get(ErrorHandler::class); + self::assertInstanceOf(ErrorHandler::class, $process); + self::assertSame($process, $services->get(ErrorHandlerInterface::class)); + self::assertSame($reporter, $services->get(ThrowableHandlerInterface::class)); + $factory = $services->get(ErrorHandlerFactory::class); + self::assertInstanceOf(ErrorHandlerFactory::class, $factory); + $first = $factory($services); + $second = $factory($services); + self::assertNotSame($first, $second); + self::assertNotSame($process, $first); + $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); + self::assertInstanceOf(ErrorHandlerMiddlewareFactory::class, $middlewareFactory); + $middleware = $services->get(ErrorHandlerMiddleware::class); + self::assertInstanceOf(ErrorHandlerMiddleware::class, $middleware); + self::assertSame($middleware, $services->get(ErrorHandlerMiddleware::class)); + self::assertNotSame($middleware, $middlewareFactory($services)); + self::assertSame([[], [], 0], [$reports, $phpErrors, $shutdowns]); + self::assertSame($before, self::callbacks()); + $original = new \RuntimeException('Configured private diagnostic', 503); + $process->handleThrowable($original); + $process->register(); + + try { + $outer = self::callbacks(); + $first->register(); + + try { + self::assertNotSame($outer, self::callbacks()); + $line = __LINE__ + 1; + trigger_error('Configured callback warning', E_USER_WARNING); + $first->handleShutdown(); + } finally { + $first->unregister(); + } + + self::assertSame($outer, self::callbacks()); + $response = $middleware->process(self::request(), self::failsWith($original)); + self::assertSame(503, $response->getStatusCode()); + self::assertSame('Internal Server Error', (string) $response->getBody()); + self::assertSame($outer, self::callbacks()); + } finally { + $process->unregister(); + } + + self::assertSame([$original, $original], $reports); + self::assertSame([[E_USER_WARNING, 'Configured callback warning', __FILE__, $line]], $phpErrors); + self::assertSame(1, $shutdowns); + self::assertSame($process, $services->get(ErrorHandler::class)); + self::assertSame($before, self::callbacks()); + } + /** * Verifies middleware restores the active process handler after a downstream failure. */ diff --git a/tests/Unit/ErrorHandlerConstructorTest.php b/tests/Unit/ErrorHandlerConstructorTest.php new file mode 100644 index 0000000..d4a8be3 --- /dev/null +++ b/tests/Unit/ErrorHandlerConstructorTest.php @@ -0,0 +1,394 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit; + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Exception\ErrorException; +use CoiSA\ErrorHandler\Handler\CallablePhpErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableShutdownHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; +use CoiSA\ErrorHandler\Handler\PhpLastErrorShutdownHandler; +use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Handler\ThrowErrorExceptionPhpErrorHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; + +/** + * Verifies constructor normalization without changing reporting or lifecycle policy. + * + * @internal + */ +#[CoversClass(ErrorHandler::class)] +#[UsesClass(CallableThrowableHandler::class)] +#[UsesClass(CallablePhpErrorHandler::class)] +#[UsesClass(CallableShutdownHandler::class)] +#[UsesClass(ErrorException::class)] +#[UsesClass(ThrowErrorExceptionPhpErrorHandler::class)] +#[UsesClass(PhpLastErrorShutdownHandler::class)] +final class ErrorHandlerConstructorTest extends TestCase +{ + /** + * Normalizes three closures or callable arrays without executing them early. + * + * @param bool $arrays whether each callback is supplied as an object/method pair + */ + #[DataProvider('provideThreeClosuresAreInertAndKeepTheirDelegationContractsCases')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testThreeClosuresAreInertAndKeepTheirDelegationContracts(bool $arrays): void + { + $events = []; + $before = self::callbacks(); + + /** + * Records the original event and returns a value unrelated to diagnostic delivery. + * + * @param \Throwable $event the original event supplied to the reporter + * + * @return string a callback result the void adapter must ignore + */ + $reporter = static function (\Throwable $event) use (&$events): string { + $events[] = ['throwable', $event]; + + return 'ignored'; + }; + + /** + * Retains the exact metadata without adding native false/fall-through semantics. + * + * @param int $code the unmodified severity + * @param string $message the unmodified error message + * @param string $filename the unmodified source filename + * @param int $line the unmodified source line + * + * @return false a callback result that the void adapter must ignore + */ + $phpErrors = static function (int $code, string $message, string $filename, int $line) use (&$events): false { + $events[] = ['php', $code, $message, $filename, $line]; + + return false; + }; + + /** @return int a callback result unrelated to the shutdown interface */ + $shutdown = static function () use (&$events): int { + $events[] = ['shutdown']; + + return 7; + }; + $handler = new ErrorHandler( + $arrays ? [$reporter, '__invoke'] : $reporter, + $arrays ? [$phpErrors, '__invoke'] : $phpErrors, + $arrays ? [$shutdown, '__invoke'] : $shutdown, + ); + self::assertSame([], $events); + self::assertSame($before, self::callbacks()); + $handler->handleShutdown(); + self::assertSame([], $events); + + $original = new \RuntimeException('Original constructor fixture'); + $handler->handleThrowable($original); + $handler->handlePhpError(E_NOTICE, 'Exact metadata', '/synthetic.php', 23); + $handler->register(); + + try { + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + + self::assertSame([ + ['throwable', $original], + ['php', E_NOTICE, 'Exact metadata', '/synthetic.php', 23], + ['shutdown'], + ], $events); + self::assertSame($before, self::callbacks()); + } + + /** @return array native callable forms for all three inputs */ + public static function provideThreeClosuresAreInertAndKeepTheirDelegationContractsCases(): iterable + { + return ['closures' => [false], 'object and method pairs' => [true]]; + } + + /** + * Omitted and explicitly null options retain the same default suppression and conversion policy. + * + * @param bool $explicitNulls whether to supply the two nullable defaults explicitly + */ + #[DataProvider('provideNullableDefaultsKeepTheExistingErrorPolicyCases')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testNullableDefaultsKeepTheExistingErrorPolicy(bool $explicitNulls): void + { + $delegate = $this->createMock(ThrowableHandlerInterface::class); + $delegate->expects(self::once())->method('handleThrowable')->with(self::callback( + /** + * Checks enabled conversion while rejecting suppression or shutdown deliveries. + * + * @param mixed $event the reported diagnostic + * + * @return bool whether all original PHP metadata was preserved + */ + static fn(mixed $event): bool => $event instanceof ErrorException + && 'Enabled default' === $event->getMessage() + && E_USER_WARNING === $event->getSeverity() + && '/defaults.php' === $event->getFile() + && 13 === $event->getLine(), + )); + $reporter = $delegate->handleThrowable(...); + $handler = $explicitNulls ? new ErrorHandler($reporter, null, null) : new ErrorHandler($reporter); + $mask = error_reporting(E_ALL); + + try { + $handler->handlePhpError(E_USER_WARNING, 'Enabled default', '/defaults.php', 13); + error_reporting(0); + $handler->handlePhpError(E_USER_WARNING, 'Suppressed default', '/defaults.php', 14); + $handler->handleShutdown(); + } finally { + error_reporting($mask); + } + } + + /** @return array omission and explicit null for the optional inputs */ + public static function provideNullableDefaultsKeepTheExistingErrorPolicyCases(): iterable + { + return ['omitted' => [false], 'explicit null' => [true]]; + } + + /** Supplied interfaces win over __invoke and their original mutable identity is retained. */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testInvokableInterfacesKeepTheirInstancesAndInterfaceMethods(): void + { + $delegate = new class implements ThrowableHandlerInterface, PhpErrorHandlerInterface, ShutdownHandlerInterface { + /** @var list> observations on the exact application instance */ + public array $events = []; + + /** @param \Throwable $throwable the unchanged explicitly reported event */ + public function handleThrowable(\Throwable $throwable): void + { + $this->events[] = ['throwable', $throwable]; + } + + /** + * Receives the exact PHP-error metadata through its interface method. + * + * @param int $code the selected severity + * @param string $message the selected error message + * @param string $filename the selected source filename + * @param int $line the selected source line + */ + public function handlePhpError(int $code, string $message, string $filename, int $line): void + { + $this->events[] = ['php', $code, $message, $filename, $line]; + } + + /** Records an explicit shutdown notification on the same instance. */ + public function handleShutdown(): void + { + $this->events[] = ['shutdown']; + } + + /** @param mixed ...$arguments inputs that must never take precedence over the interfaces */ + public function __invoke(mixed ...$arguments): void + { + $this->events[] = ['invoked', $arguments]; + } + }; + $handler = new ErrorHandler($delegate, $delegate, $delegate); + self::assertSame([], $delegate->events); + $original = new \RuntimeException('Interface identity'); + $handler->handleThrowable($original); + $handler->handlePhpError(E_WARNING, 'Interface policy', '/interface.php', 41); + $handler->register(); + + try { + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + + self::assertSame([ + ['throwable', $original], + ['php', E_WARNING, 'Interface policy', '/interface.php', 41], + ['shutdown'], + ], $delegate->events); + } + + /** Callable reporters retain original identity and propagate the identical failure without retry. */ + public function testCallableReporterFailurePropagatesUnchanged(): void + { + $original = new \RuntimeException('Original failure'); + $failure = new \LogicException('Reporter failure'); + $received = []; + $handler = new ErrorHandler( + /** @throws \LogicException the exact private reporter failure */ + static function (\Throwable $event) use (&$received, $failure): void { + $received[] = $event; + + throw $failure; + }, + ); + + try { + $handler->handleThrowable($original); + self::fail('Expected the reporter failure.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + + self::assertSame([$original], $received); + } + + /** A valid function name is accepted without invoking it to preflight its incompatible signature. */ + public function testCallbackSignatureErrorsAriseOnlyWhenTheCallbackIsInvoked(): void + { + // @phpstan-ignore argument.type (Deliberately defer the callable's invalid input contract to PHP.) + $handler = new ErrorHandler('strlen'); + + $this->expectException(\TypeError::class); + $handler->handleThrowable(new \RuntimeException('Not a string callback argument')); + } + + /** PHP-error callables preserve the existing ErrorException-routing versus other-failure policy. */ + public function testCallablePhpFailuresPreserveConversionAndFailureIdentity(): void + { + $converted = new \ErrorException('Converted error', 0, E_WARNING); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($converted)); + $handler = new ErrorHandler( + $reporter, + /** @throws \ErrorException the application-converted error */ + static function () use ($converted): never { + throw $converted; + }, + ); + $handler->handlePhpError(E_WARNING, 'Original metadata', '/failure.php', 17); + $failure = new \LogicException('PHP callback failed'); + $handler = new ErrorHandler( + $reporter, + /** @throws \LogicException an unrelated failure that must not be reported again */ + static function () use ($failure): never { + throw $failure; + }, + ); + + try { + $handler->handlePhpError(E_WARNING, 'Original metadata', '/failure.php', 17); + self::fail('Expected the unrelated PHP callback failure.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + } + + /** A callable shutdown failure reaches the reporter once with its original identity. */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCallableShutdownFailureIsReportedUnchanged(): void + { + $failure = new \LogicException('Shutdown callback failed'); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::once())->method('handleThrowable')->with(self::identicalTo($failure)); + $handler = new ErrorHandler( + $reporter, + null, + /** @throws \LogicException the original callback failure to report */ + static function () use ($failure): never { + throw $failure; + }, + ); + $handler->handleShutdown(); + $handler->register(); + + try { + $handler->handleShutdown(); + } finally { + $handler->unregister(); + } + } + + /** + * Native validation rejects unsupported inputs before normalization or execution. + * + * @param int $position the constructor argument to violate deliberately + * @param mixed $input an invalid native argument for that position + */ + #[DataProvider('provideInvalidInputsAreRejectedCases')] + public function testInvalidInputsAreRejected(int $position, mixed $input): void + { + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::never())->method('handleThrowable'); + $this->expectException(\TypeError::class); + + if (0 === $position) { + // @phpstan-ignore argument.type (The test deliberately violates the public native parameter.) + $handler = new ErrorHandler($input); + } elseif (1 === $position) { + // @phpstan-ignore argument.type (The test deliberately violates the public native parameter.) + $handler = new ErrorHandler($reporter, $input); + } else { + // @phpstan-ignore argument.type (The test deliberately violates the public native parameter.) + $handler = new ErrorHandler($reporter, null, $input); + } + + self::fail('Unexpectedly constructed ' . $handler::class); + } + + /** @return iterable invalid native inputs for each constructor position */ + public static function provideInvalidInputsAreRejectedCases(): iterable + { + $inputs = [ + 'false' => false, + 'integer' => 1, + 'unknown function' => 'missing_error_handler_callback', + 'object' => new \stdClass(), + ]; + foreach (['reporter', 'PHP errors', 'shutdown'] as $position => $label) { + foreach ($inputs as $name => $input) { + yield $label . ' ' . $name => [$position, $input]; + } + } + + yield 'required reporter null' => [0, null]; + } + + /** + * Snapshots callback identity while restoring the temporary native probes immediately. + * + * @return array{null|callable, null|callable} the surrounding error and exception callbacks + */ + private static function callbacks(): array + { + /** @return bool leave native diagnostic handling available to the caller */ + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler( + /** @param \Throwable $error the unused temporary probe input */ + static function (\Throwable $error): void { + }, + ); + restore_exception_handler(); + + return [$error, $exception]; + } +} From cc98da398db4a5dfe00843cf2d897e414b316baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 17 Oct 2026 12:45:03 -0300 Subject: [PATCH 19/27] chore(deps): refresh stable Composer ranges for the major MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require FastForward 1.10 and update the development-tool minor lines while retaining PHPUnit and Symfony Process alternatives for PHP 8.3. Preserve compatible PSR ranges and the useful Whoops patch floor; require the fixed Nyholm patch for development recipes. Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 6 ++- README.md | 2 +- composer.json | 20 ++++----- docs/fast-forward.md | 41 +++++++++---------- docs/index.html | 4 +- docs/testing.md | 22 +++++----- examples/README.md | 4 +- resources/skills/coisa-error-handler/SKILL.md | 2 +- .../coisa-error-handler/examples/README.md | 4 +- .../references/container.md | 11 +++-- .../coisa-error-handler/references/json.md | 2 +- .../coisa-error-handler/references/whoops.md | 2 +- 12 files changed, 61 insertions(+), 59 deletions(-) diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index cd3b1e2..27236c3 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -5,7 +5,7 @@ Prepare the unreleased PHP 8.3+ major while retaining the package `coisa/error-handler` and namespace `CoiSA\ErrorHandler`. - Require PHP `^8.3`, with a tested CI matrix of PHP 8.3–8.5. -- Require FastForward Container and expose the Interop service provider as the +- Require FastForward Container `^1.10` and expose the Interop service provider as the container entrypoint. Remove the standalone Container/ConfigProvider APIs, their package-specific exceptions and constructor-only factories. Keep explicit lifecycle/composition factories under `ServiceProvider\Factory`. @@ -41,5 +41,9 @@ Prepare the unreleased PHP 8.3+ major while retaining the package existing adapters. Preserve interface identity and precedence, required reporter, optional null defaults, inert construction and the existing reporting policies. Keep explicit provider factories for cached services and fresh scopes. +- Refresh Composer development-tool ranges to the current stable lines, retaining + PHP 8.3 support through PHPUnit 12.5 and Symfony Process 7.4 alternatives. + Require Nyholm 1.8.2 in development for its PHP 8.5 fixes; retain the compatible + PSR ranges and optional Whoops 2.18.4 minimum. - Keep the current 2.x manifest version and published changelog unchanged. Content negotiation remains separate work; this note does not perform a release. diff --git a/README.md b/README.md index 246860a..9cc20ab 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ composer install --no-plugins --no-scripts ``` Use Composer 2 in this reviewed source checkout on PHP `^8.3`; CI targets -PHP 8.3–8.5. FastForward Container `^1.6` is a runtime dependency. Applications supply +PHP 8.3–8.5. FastForward Container `^1.10` is a runtime dependency. Applications supply their logger, dispatcher, HTTP factories and response emitter as needed. This branch describes the unreleased next major after published 2.5.0. It does diff --git a/composer.json b/composer.json index 4611a9f..c5592a9 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require": { "php": "^8.3", "container-interop/service-provider": "^0.4", - "fast-forward/container": "^1.6", + "fast-forward/container": "^1.10", "psr/container": "^1.1 || ^2.0", "psr/http-server-middleware": "^1.0", "psr/http-factory": "^1.1", @@ -41,17 +41,17 @@ "require-dev": { "coisa/php-cs-fixer": "^3.0", "filp/whoops": "^2.18.4", - "jangregor/phpstan-prophecy": "^2.1", - "nyholm/psr7": "^1.8", + "jangregor/phpstan-prophecy": "^2.3", + "nyholm/psr7": "^1.8.2", "nyholm/psr7-server": "^1.1", - "phly/phly-event-dispatcher": "^1.0", - "phpspec/prophecy": "^1.20", - "phpspec/prophecy-phpunit": "^2.1", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^12.5 || ^13.0", + "phly/phly-event-dispatcher": "^1.6", + "phpspec/prophecy": "^1.26", + "phpspec/prophecy-phpunit": "^2.5", + "phpstan/phpstan": "^2.3", + "phpstan/phpstan-strict-rules": "^2.1", + "phpunit/phpunit": "^12.5 || ^13.4", "squizlabs/php_codesniffer": "^4.0", - "symfony/process": "^5.4 || ^6.4 || ^7.4 || ^8.0" + "symfony/process": "^7.4 || ^8.1" }, "scripts": { "check": [ diff --git a/docs/fast-forward.md b/docs/fast-forward.md index aa9a67b..1832385 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -7,22 +7,20 @@ remains the current API; a possible future move to php-fast-forward has not happ ## Installation and bootstrap -The library requires `fast-forward/container:^1.6` and the +The library requires `fast-forward/container:^1.10` and the `container-interop/service-provider:^0.4` interface package at runtime. Composer installs them for consumers. The provider reuses FastForward's `AliasFactory::get()`, `InvokableFactory` and `CallableFactory`; it does not ship a second container. To install the container explicitly in an application: ```sh -composer require fast-forward/container:^1.6 +composer require fast-forward/container:^1.10 ``` - -The `^1.6` requirement is the declared lower bound, not evidence that every -permitted dependency graph is warning-free. A prior PHP 8.5/E_ALL run with -FastForward 1.6 and PHP-DI 7.0 emitted upstream implicit-nullability deprecations -despite exiting 0. Record the actual PHP and locked versions, and assess those -diagnostics separately from successful resolution; see [test evidence](testing.md#coverage-and-current-evidence). +FastForward 1.10 requires Config `^1.6`; these lines no longer use PHP-DI or +DotAccessData. Record the actual PHP and locked versions rather than treating a +successful Composer resolution as a complete runtime check. See +[test evidence](testing.md#coverage-and-current-evidence). The development checkout includes it in `require`. After installing the checkout dependencies, run the complete [example](../examples/fast-forward.php): @@ -130,7 +128,7 @@ composition and fresh middleware scopes; autowiring alone does not replace those contracts. The factory-object services use FastForward's `InvokableFactory`. Bind a required custom renderer through `ThrowableStreamFactoryInterface` or -`ThrowableResponseFactoryInterface`. FastForward 1.6.0 can discard a concrete +`ThrowableResponseFactoryInterface`. FastForward can discard a concrete class definition's PSR-11 construction failure and fall back to autowiring that class. The same failure on an explicitly bound interface prevents construction, because the interface has no autowire fallback. Successful concrete overrides @@ -172,15 +170,14 @@ and [PHP-error](../examples/php-errors.php) examples. Some autowiring containers advertise `CallableThrowableHandler` through `has()` even when its required callable cannot be constructed. Optional discovery skips only a PSR-11 not-found result from `get()`; other construction exceptions are -propagated. FastForward 1.6.0 can itself translate a failed concrete dependency -into not-found, so the library cannot distinguish that from an absent optional -reporter. Use an explicit `ThrowableHandlerInterface` binding for a required -reporter and the merged-provider composition below. +propagated by the factory. Container fallback can still supply another concrete +instance after construction fails. Use an explicit `ThrowableHandlerInterface` +binding for a required reporter and the merged-provider composition below. ## Required overrides The short bootstrap above adds optional reporters and external dependencies. -FastForward 1.6.0 and 1.8.0 search separate providers in the supplied order; +FastForward 1.10 searches separate providers in the supplied order; they do not merge their factory maps. In `container(ErrorHandlerServiceProvider::class, $application)`, a duplicate `ThrowableHandlerInterface` definition in the application is shadowed by the @@ -204,12 +201,12 @@ $services = container( Here `$application` binds the required reporter or renderer interface. `AggregateServiceProvider` uses its last definition for each service ID. This replaces the library default and makes required-reporter failures fail -construction of both process handler and middleware. FastForward still -translates PSR failures to not-found; it does not preserve their original cause. -Container 1.6.0 propagates ordinary application exceptions directly; newer -versions such as 1.8.0 normalize those too. The supported contract is failed -construction after reaching the required factory, without silent fallback; -it does not guarantee exception type, message or cause preservation. +construction of both process handler and middleware. FastForward normalizes +construction failures into PSR-11 exceptions; its aggregate retains the first +construction failure when no source succeeds. The supported contract is failed +construction after reaching the required factory, without silent fallback to the +library's default reporter. Do not depend on a particular wrapper type or message; +inspect the available cause chain when diagnosing a failed resolution. Container resolution may attempt a failing construction more than once while trying its delegates and autowiring. Application factories must tolerate those attempts; this differs from the handler's no-retry contract after reporting fails. @@ -278,9 +275,9 @@ an explicit package/namespace decision, release and migration guidance; this provider is not a claim that those changes have already been made. -Sources: [FastForward Container 1.6.0](https://github.com/php-fast-forward/container/tree/v1.6.0), +Sources: [FastForward Container 1.10.0](https://github.com/php-fast-forward/container/tree/v1.10.0), [provider contract](https://github.com/container-interop/service-provider/tree/v0.4.1), -[factory lifetime pattern](https://github.com/php-fast-forward/container/blob/v1.6.0/docs/advanced/special-patterns.rst). +[cached provider services](https://github.com/php-fast-forward/container/blob/v1.10.0/src/ServiceProviderContainer.php). If the built-in handler's reporter fails during a request warning, middleware propagates that failure and restores callbacks without rendering it through the diff --git a/docs/index.html b/docs/index.html index 594982b..2babaa5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -120,9 +120,9 @@

Error Handler: next major

Set up the selected major source

composer install --no-plugins --no-scripts -

Use Composer 2 in the reviewed checkout or an application selecting that exact path package. The declared PHP constraint is ^8.3; the CI matrix targets PHP 8.3–8.5. FastForward Container ^1.6 and the Interop provider contract are runtime dependencies.

+

Use Composer 2 in the reviewed checkout or an application selecting that exact path package. The declared PHP constraint is ^8.3; the CI matrix targets PHP 8.3–8.5. FastForward Container ^1.10 and the Interop provider contract are runtime dependencies.

Applications supply loggers, dispatchers, PSR-17 implementations and their emitter as needed. The development checkout supplies Nyholm and Phly for its synthetic recipes; optional filp/whoops:^2.18.4 is needed only where development HTML is selected.

-

Read example setup for the selected autoloader and verifier dependencies. Resolve with the actual PHP runtime and retain application lockfiles. The declared FastForward minimum has a documented dependency-graph limitation; a permitted constraint is not proof that every combination is clean.

+

Read example setup for the selected autoloader and verifier dependencies. Resolve with the actual PHP runtime and retain application lockfiles. Consult the recorded test evidence; a permitted constraint is not proof that every combination is clean.

diff --git a/docs/testing.md b/docs/testing.md index 1222d48..537e679 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,16 +99,18 @@ No runtime exclusions, merged child coverage or artificial coverage ignores are introduced by this reconciliation. PHPStan's output-buffer stub supplies analysis metadata only and is never loaded by the runtime/autoloader/test suite. -The reconciled local graph uses PHP 8.5.11, PHPUnit 13.4.2, FastForward 1.8.0 -and Whoops 2.18.6 on macOS arm64. Coverage is measured separately with PHP 8.5.11 and Xdebug 3.5.3. -These observations do not prove PHP 8.3 or every permitted dependency combination. -The declared FastForward `^1.6` floor includes an older graph with PHP-DI 7.0 -that emitted implicit-nullability deprecations under PHP 8.5/E_ALL despite exit 0. -That earlier observation is a dependency diagnostic limit, not a clean result for -the reconciled candidate. Keep its evidence separate from current-graph checks. -The PR evidence records test totals, native uncovered lines and hosted SHAs. -Earlier PR #3 results using 75 tests, custom runners or PSR-12-only formatting are -historical; the current checkout uses the merged CoiSA 3.x preset and PHPUnit suites. +Resolve development dependencies on the actual PHP runtime. PHP 8.3 uses the +admitted PHPUnit `^12.5` line; PHPUnit `^13.4` requires PHP 8.4.1 or later. +Symfony Process similarly admits `^7.4` for PHP 8.3 and `^8.1` for PHP 8.4.1+. +The manifest retains both compatible lines without raising the library's PHP floor. +Native coverage requires the recorded Xdebug or PCOV version; a lockfile or +successful resolution alone does not prove execution on another PHP version. + +FastForward Container `^1.10` requires Config `^1.6`, removing PHP-DI and +DotAccessData from that runtime graph. The Nyholm development minimum is 1.8.2, +which includes its PHP 8.5 nullable-parameter fixes. Applications select their +own PSR-17 implementation. Keep dependency diagnostics under `E_ALL` visible and +record the graph, test totals, native uncovered lines and hosted SHA in PR evidence. ## Limits diff --git a/examples/README.md b/examples/README.md index 9ec5cab..557913f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,7 @@ select the major provider/lifecycle/HTTP contracts. The source checkout supplies application implementations through development dependencies. In a disposable application using that path package, add -`nyholm/psr7:^1.8` and `phly/phly-event-dispatcher:^1.0` for HTTP/event recipes. +`nyholm/psr7:^1.8.2` and `phly/phly-event-dispatcher:^1.6` for HTTP/event recipes. Add optional `filp/whoops:^2.18.4` wherever the development HTML recipes are used. FastForward Container and PSR interfaces resolve from the library's runtime graph. No framework-specific container is required. Keep application locks/caches outside @@ -17,7 +17,7 @@ or the checkout's autoloader and installs no callbacks. To run the bounded verif with an external application autoloader, install its development-only process tool: ```sh -composer require --dev 'symfony/process:^5.4 || ^6.4 || ^7.4 || ^8.0' --no-plugins --no-scripts --no-interaction +composer require --dev 'symfony/process:^7.4 || ^8.1' --no-plugins --no-scripts --no-interaction ERROR_HANDLER_AUTOLOAD=/absolute/application/vendor/autoload.php php examples/verify.php ``` diff --git a/resources/skills/coisa-error-handler/SKILL.md b/resources/skills/coisa-error-handler/SKILL.md index f488670..cbd0d55 100644 --- a/resources/skills/coisa-error-handler/SKILL.md +++ b/resources/skills/coisa-error-handler/SKILL.md @@ -2,7 +2,7 @@ name: coisa-error-handler description: Integrate coisa/error-handler with configured private reporters, process callbacks, FastForward Interop providers and pure generic, JSON or Whoops PSR-15 responses. Use when an application consumes this library; inspect its installed version before selecting an API. license: MIT; see LICENSE -compatibility: Selected unreleased next-major source of coisa/error-handler; PHP ^8.3, Composer 2 and required fast-forward/container ^1.6. This is not the published 2.x API. Applications supply PSR-14/17 implementations and HTTP emission; development HTML additionally requires optional filp/whoops ^2.18.4 at runtime. No agent SDK or global installation is required. +compatibility: Selected unreleased next-major source of coisa/error-handler; PHP ^8.3, Composer 2 and required fast-forward/container ^1.10. This is not the published 2.x API. Applications supply PSR-14/17 implementations and HTTP emission; development HTML additionally requires optional filp/whoops ^2.18.4 at runtime. No agent SDK or global installation is required. metadata: author: Felipe Sayão Lobato Abreu api-baseline: "unreleased-next-major-source" diff --git a/resources/skills/coisa-error-handler/examples/README.md b/resources/skills/coisa-error-handler/examples/README.md index e997455..12cdfab 100644 --- a/resources/skills/coisa-error-handler/examples/README.md +++ b/resources/skills/coisa-error-handler/examples/README.md @@ -49,7 +49,7 @@ callbacks; register only at the actual process/request boundary. ## FastForward container -The library requires FastForward `^1.6` at runtime. Run with the selected major +The library requires FastForward `^1.10` at runtime. Run with the selected major source: ```sh @@ -75,7 +75,7 @@ Supply Nyholm as the application's PSR-17 implementation if it has not selected one already: ```sh -composer require 'nyholm/psr7:^1.8' --no-plugins --no-scripts +composer require 'nyholm/psr7:^1.8.2' --no-plugins --no-scripts php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/http.php ./vendor/autoload.php ``` diff --git a/resources/skills/coisa-error-handler/references/container.md b/resources/skills/coisa-error-handler/references/container.md index c78df7c..7ba6744 100644 --- a/resources/skills/coisa-error-handler/references/container.md +++ b/resources/skills/coisa-error-handler/references/container.md @@ -4,14 +4,13 @@ Read when an application uses the Interop service provider. The complete [container program](../examples/container.php) and its [setup instructions](../examples/README.md#fastforward-container) supply a private reporter, resolve a cached handler and create a distinct scope. FastForward -Container `^1.6` is required at runtime by this major; do not restore the removed +Container `^1.10` is required at runtime by this major; do not restore the removed `ErrorHandlerContainer`, `ConfigProvider` or Laminas metadata. -Some versions admitted by the minimum dependency graph emit PHP 8.5 deprecations -under `E_ALL`. Inspect the actual locked FastForward/PHP-DI versions and runtime -result; Composer resolution alone does not establish a warning-free graph. -Do not suppress diagnostics or change application constraints merely to fit an -example. Keep that dependency evidence separate from the library's own behavior. +FastForward 1.10 requires Config `^1.6`; these versions no longer use PHP-DI or +DotAccessData. Inspect the actual locked graph and runtime result; Composer +resolution alone does not establish a warning-free application. Keep diagnostics +under `E_ALL` visible and separate dependency evidence from the library's behavior. For optional reporter discovery and external PSR implementations, ordinary composition uses `container(ErrorHandlerServiceProvider::class, $application)` diff --git a/resources/skills/coisa-error-handler/references/json.md b/resources/skills/coisa-error-handler/references/json.md index 4c15f53..636e960 100644 --- a/resources/skills/coisa-error-handler/references/json.md +++ b/resources/skills/coisa-error-handler/references/json.md @@ -17,7 +17,7 @@ php -r 'require "vendor/autoload.php"; if (!class_exists("CoiSA\\ErrorHandler\\H For providers, also verify `ServiceProvider\ErrorHandlerServiceProvider`, `ServiceProvider\JsonErrorHandlerServiceProvider` and `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` under the -`CoiSA\ErrorHandler` namespace. FastForward `^1.6` is a required runtime dependency +`CoiSA\ErrorHandler` namespace. FastForward `^1.10` is a required runtime dependency of this source. Class discovery alone does not distinguish the 2.5 renderer APIs from this major's lifecycle and middleware contracts. Follow the matching source and skill; do not upgrade a consumer automatically to fit this route. diff --git a/resources/skills/coisa-error-handler/references/whoops.md b/resources/skills/coisa-error-handler/references/whoops.md index ac60647..23add9d 100644 --- a/resources/skills/coisa-error-handler/references/whoops.md +++ b/resources/skills/coisa-error-handler/references/whoops.md @@ -74,7 +74,7 @@ First verify that the selected installation contains `ServiceProvider\WhoopsErrorHandlerServiceProvider`, the base `ServiceProvider\ErrorHandlerServiceProvider` and `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`. The application must -run PHP 8.3+; FastForward `^1.6` is required by this source at runtime. +run PHP 8.3+; FastForward `^1.10` is required by this source at runtime. The [complete provider program](../examples/provider-whoops.php) supplies the PSR-17 interfaces and private reporter through its application provider. Compose From 98eba68d134791009e5dc69a69c62fc694e19a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 17 Oct 2026 12:43:48 -0300 Subject: [PATCH 20/27] fix: retain PSR-14 reporters without an optional callable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the native structural autowiring failure only for optional callable discovery. Assert real event delivery and explicit factory causes while retaining the documented concrete-container fallback boundary. Refs #110. Signed-off-by: Felipe Sayão Lobato Abreu (cherry picked from commit 39a661aa26db51ff1e1b7b2d7bad487ecd324aa5) Signed-off-by: Felipe Sayão Lobato Abreu --- ...fix-optional-callable-event-composition.md | 5 + docs/examples.md | 6 +- docs/fast-forward.md | 16 ++- docs/index.html | 2 +- .../references/container.md | 9 ++ .../ThrowableHandlerAggregateFactory.php | 6 + .../ErrorHandlerCompositionTest.php | 58 +++++++--- .../ErrorHandlerServiceProviderTest.php | 106 ++++++++++++++++++ .../ThrowableHandlerAggregateFactoryTest.php | 106 ++++++++++++++++-- 9 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 .changeset/fix-optional-callable-event-composition.md diff --git a/.changeset/fix-optional-callable-event-composition.md b/.changeset/fix-optional-callable-event-composition.md new file mode 100644 index 0000000..5756eae --- /dev/null +++ b/.changeset/fix-optional-callable-event-composition.md @@ -0,0 +1,5 @@ +--- +"@coisa/error-handler": patch +--- + +Skip structural autowiring failure for an unconfigured optional callable reporter so the default major aggregate still delivers PSR-14 events. Preserve configured reporter order and explicit factory failures, and document the container's remaining concrete fallback boundary. diff --git a/docs/examples.md b/docs/examples.md index c07ba22..a865ef2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -143,7 +143,11 @@ echo implode(PHP_EOL, $deliveries) . PHP_EOL; ## PSR-14 dispatch and PSR-3 event logging -Exercise original Throwable and ErrorEvent notification paths. [Source](../examples/event-dispatcher.php). +Exercise original Throwable and ErrorEvent notification paths with only an +application dispatcher. Expect one raw delivery, one wrapped delivery and +`Private log records: 1`; exit 0 alone does not prove reporting occurred. +See [optional discovery and fallback](fast-forward.md) before relying on the +default reporter. [Source](../examples/event-dispatcher.php). ```php diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 1832385..70aef2e 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -169,10 +169,18 @@ and [PHP-error](../examples/php-errors.php) examples. Some autowiring containers advertise `CallableThrowableHandler` through `has()` even when its required callable cannot be constructed. Optional discovery skips -only a PSR-11 not-found result from `get()`; other construction exceptions are -propagated by the factory. Container fallback can still supply another concrete -instance after construction fails. Use an explicit `ThrowableHandlerInterface` -binding for a required reporter and the merged-provider composition below. +a PSR-11 not-found result or FastForward's structural `AutowiringException` only +for that callable lookup. This lets an application dispatcher receive both event +routes without a dummy callable. Other callable-construction failures and all +dispatch-adapter failures propagate from the aggregate factory. + +FastForward 1.10.0 wraps an executing provider factory's failure in +`ContainerException`, retaining its cause; this is not skipped by the optional +lookup. The final container can nevertheless recover from failed concrete +aggregate construction by autowiring an empty `ThrowableHandlerAggregate` and +caching it. Merging maps does not disable that native fallback. Use an explicit +`ThrowableHandlerInterface` binding for a required reporter and the merged-provider +composition below; verify delivery and failures in the actual application graph. ## Required overrides diff --git a/docs/index.html b/docs/index.html index 2babaa5..2f9278c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -201,7 +201,7 @@

Compose adapters deliberately

-

The event example includes providers, callable listeners and PSR-3 logging. Listener providers select listeners; the application dispatcher executes them. Raw and wrapped routes are separate deliveries. See API contracts for extension points and failure policy.

+

The event example includes providers, callable listeners and PSR-3 logging. Listener providers select listeners; the application dispatcher executes them. Raw and wrapped routes are separate deliveries, and the recipe records one private log entry without configuring a callable reporter. Verify delivery after invocation; see optional discovery and native fallback and API contracts for failure boundaries.

diff --git a/resources/skills/coisa-error-handler/references/container.md b/resources/skills/coisa-error-handler/references/container.md index 7ba6744..210a4cc 100644 --- a/resources/skills/coisa-error-handler/references/container.md +++ b/resources/skills/coisa-error-handler/references/container.md @@ -19,6 +19,15 @@ discovers an available `CallableThrowableHandler` and PSR-14 adapters when their dispatcher is available. An empty default aggregate is silent. A logger, filtering reporter or best-effort aggregate is not automatically attached. +The optional callable lookup skips not-found and FastForward's structural +`AutowiringException` when no callback can be inferred. A dispatcher alone still +receives the original throwable and its `ErrorEvent` wrapper. Other construction +failures leave the aggregate factory; in FastForward 1.10.0 an executing provider +factory's failure is wrapped with its cause. The final container may still +autowire and cache an empty concrete aggregate after such a failure. Check actual +deliveries after invocation, and bind `ThrowableHandlerInterface` explicitly for +a required reporter; the default aggregate is not a fail-closed boundary. + For a required reporter or renderer interface override, merge definitions in one `FastForward\Container\ServiceProvider\AggregateServiceProvider`: library first, application last, then pass that provider to `container()`. The bundled diff --git a/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php index 19eec71..21faae2 100644 --- a/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php +++ b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php @@ -20,6 +20,7 @@ use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use FastForward\Container\Exception\AutowiringException; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -42,6 +43,9 @@ final class ThrowableHandlerAggregateFactory * - DispatchThrowableHandler * - DispatchErrorEventThrowableHandler * + * An absent or structurally unconfigured optional callable SHALL be skipped. + * Other construction failures SHALL propagate to the calling container. + * * @param ContainerInterface $container the PSR-11 container instance * * @return ThrowableHandlerAggregate the configured aggregate handler @@ -58,6 +62,8 @@ public function __invoke(ContainerInterface $container): ThrowableHandlerAggrega $callableHandler = $container->get(CallableThrowableHandler::class); $handler->attach($callableHandler); } catch (NotFoundExceptionInterface) { + // The optional reporter entry is absent. + } catch (AutowiringException) { // Autowiring may advertise a class whose required callable is not configured. } } diff --git a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php index 315b665..854fe4d 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php @@ -30,7 +30,6 @@ use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; -use CoiSA\ErrorHandler\Test\Log\AssertThrowableTestCaseLogger; use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; @@ -47,6 +46,8 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\AbstractLogger; +use Psr\Log\LogLevel; use function FastForward\Container\container; @@ -227,28 +228,43 @@ public function testCallableHandlerFromAggregationWillHandleThrowable(): void */ public function testEventDispatcherCallableHandlerWillHandleThrowable(): void { - $message = uniqid('test', true); - $exception = new \InvalidArgumentException($message); + $exception = new \InvalidArgumentException('Default PSR-14 composition'); + $events = []; + $throwables = []; + $logger = new class extends AbstractLogger { + /** @var list Synthetic private records collected after delivery. */ + public array $records = []; - $errorEventCallableListener = new ErrorEventCallableListener(function ($event) use ($exception): void { - $this->assertInstanceOf(ErrorEvent::class, $event); - $this->assertSame($exception, $event->getThrowable()); - $this->assertSame((string) $exception, (string) $event); - }); - - $throwableListener = new ThrowableCallableListener(function (\Throwable $throwable) use ($exception): void { - $this->assertSame($exception, $throwable); - }); - - $logErrorEventListener = new LogErrorEventListener( - new AssertThrowableTestCaseLogger($this, $exception), + /** + * Retains diagnostics for assertions after the reporter has returned. + * + * @param mixed $level the configured PSR-3 level + * @param string|\Stringable $message the private diagnostic + * @param array $context unused synthetic context + */ + public function log($level, $message, array $context = []): void + { + $this->records[] = [$level, (string) $message]; + } + }; + $errorEventCallableListener = new ErrorEventCallableListener( + /** @param ErrorEvent $event The wrapper emitted by the default aggregate. */ + static function (ErrorEvent $event) use (&$events): void { + $events[] = $event; + }, ); + $throwableListener = new ThrowableCallableListener( + /** @param \Throwable $throwable The raw diagnostic emitted by the default aggregate. */ + static function (\Throwable $throwable) use (&$throwables): void { + $throwables[] = $throwable; + }, + ); + $logErrorEventListener = new LogErrorEventListener($logger); $errorEventListenerProvider = new ErrorEventListenerProvider($errorEventCallableListener); $errorEventListenerProvider->attach($logErrorEventListener); $throwableListenerProvider = new ThrowableListenerProvider($throwableListener); - $throwableListenerProvider->attach($throwableListener); $this->listenerProvider->attach($errorEventListenerProvider); $this->listenerProvider->attach($throwableListenerProvider); @@ -256,9 +272,17 @@ public function testEventDispatcherCallableHandlerWillHandleThrowable(): void $this->services[EventDispatcherInterface::class] = $this->eventDispatcher; $errorHandler = $this->errorHandler(); - $errorHandler->register(); + self::assertCount(0, $throwables); + self::assertCount(0, $events); + self::assertSame([], $logger->records); $errorHandler->handleThrowable($exception); + + self::assertSame([$exception], $throwables); + self::assertCount(1, $events); + self::assertSame($exception, $events[0]->getThrowable()); + self::assertSame((string) $exception, (string) $events[0]); + self::assertSame([[LogLevel::ERROR, (string) $exception]], $logger->records); } /** diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php index 64c7e5b..b5b8390 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php @@ -36,6 +36,11 @@ use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\ThrowableHandlerAggregateFactory; +use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use FastForward\Container\Exception\AutowiringException; +use FastForward\Container\Exception\ContainerException; +use FastForward\Container\ServiceProviderContainer; use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use FastForward\Container\Factory\ServiceFactory; @@ -274,6 +279,107 @@ static function (\Throwable $error) use (&$received): void { self::assertSame([$error], $received); } + /** Verifies the native container advertises the optional class without an inferable callback. */ + public function testTheNativeOptionalCallableIsStructurallyUnavailable(): void + { + $container = self::services(); + self::assertTrue($container->has(CallableThrowableHandler::class)); + $this->expectException(AutowiringException::class); + + $container->get(CallableThrowableHandler::class); + } + + /** Verifies real provider-only resolution skips a genuinely absent optional callable. */ + public function testAMissingOptionalCallableDoesNotPreventProviderOnlyEventDelivery(): void + { + $events = []; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::exactly(2))->method('dispatch')->willReturnCallback( + /** + * Retains both routes without invoking listeners during construction. + * + * @param object $event the raw throwable or ErrorEvent wrapper + * + * @return object the unchanged event + */ + static function (object $event) use (&$events): object { + $events[] = $event; + + return $event; + }, + ); + $application = new ArrayServiceProvider([ + EventDispatcherInterface::class => new ServiceFactory($dispatcher), + DispatchThrowableHandler::class => new ServiceFactory(new DispatchThrowableHandler($dispatcher)), + DispatchErrorEventThrowableHandler::class => new ServiceFactory( + new DispatchErrorEventThrowableHandler($dispatcher), + ), + ]); + $container = new ServiceProviderContainer(new AggregateServiceProvider( + new ErrorHandlerServiceProvider(), + $application, + )); + self::assertFalse($container->has(CallableThrowableHandler::class)); + $reporter = $container->get(ThrowableHandlerInterface::class); + self::assertInstanceOf(ThrowableHandlerAggregate::class, $reporter); + self::assertCount(0, $events); + $original = new \RuntimeException('Provider-only event delivery'); + + $reporter->handleThrowable($original); + + self::assertCount(2, $events); + self::assertSame($original, $events[0]); + self::assertInstanceOf(ErrorEvent::class, $events[1]); + self::assertSame($original, $events[1]->getThrowable()); + } + + /** + * Verifies the factory preserves explicit failures while native concrete fallback remains visible. + * + * @param \Throwable $failure the exact failure from an explicitly configured callable factory + */ + #[DataProvider('provideExplicitCallableFactoryFailuresRetainCausesAndNativeFallbackCases')] + public function testExplicitCallableFactoryFailuresRetainCausesAndNativeFallback(\Throwable $failure): void + { + $attempts = 0; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::never())->method('dispatch'); + $application = new ArrayServiceProvider([ + CallableThrowableHandler::class => static function () use ($failure, &$attempts): never { + ++$attempts; + + throw $failure; + }, + EventDispatcherInterface::class => new ServiceFactory($dispatcher), + ]); + $container = self::withApplication($application); + + try { + (new ThrowableHandlerAggregateFactory())($container); + self::fail('The aggregate factory must not swallow an explicit callable factory failure.'); + } catch (ContainerException $caught) { + self::assertSame($failure, $caught->getPrevious()); + } + self::assertGreaterThan(0, $attempts); + + // The final container can still replace the failed concrete aggregate with an empty autowired one. + $reporter = $container->get(ThrowableHandlerInterface::class); + self::assertInstanceOf(ThrowableHandlerAggregate::class, $reporter); + self::assertSame($reporter, $container->get(ThrowableHandlerAggregate::class)); + self::assertSame($reporter, $container->get(ThrowableHandlerInterface::class)); + $reporter->handleThrowable(new \RuntimeException('Native fallback remains silent')); + } + + /** @return iterable Explicit application and structural factory failures. */ + public static function provideExplicitCallableFactoryFailuresRetainCausesAndNativeFallbackCases(): iterable + { + yield 'application failure' => [new \LogicException('Explicit callable factory failed')]; + + yield 'structural failure thrown by an explicit factory' => [ + AutowiringException::forUnresolvableParameter(CallableThrowableHandler::class, 'callable', 'callable'), + ]; + } + /** * Verifies optional callable, raw throwable and wrapped event delivery in order. */ diff --git a/tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php b/tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php index 6fc198f..dc736c3 100644 --- a/tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php +++ b/tests/Unit/ServiceProvider/Factory/ThrowableHandlerAggregateFactoryTest.php @@ -21,6 +21,8 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; +use FastForward\Container\Exception\AutowiringException; +use FastForward\Container\Exception\ContainerException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; @@ -174,17 +176,91 @@ public static function provideOnlyTheAvailableDispatchAdapterIsAttachedCases(): } /** - * Verifies an unconstructible optional callable does not prevent aggregate construction. + * Verifies an unavailable optional callable does not prevent either event route. + * + * @param \Throwable $missing the missing or structurally unconfigured callable failure */ - public function testAnAdvertisedButMissingOptionalCallableIsSkipped(): void + #[DataProvider('provideAnUnavailableOptionalCallableDoesNotPreventEventDeliveryCases')] + public function testAnUnavailableOptionalCallableDoesNotPreventEventDelivery(\Throwable $missing): void { - $missing = new class ('Missing callable') extends \RuntimeException implements NotFoundExceptionInterface { - }; - $container = $this->containerFailingWith($missing); + $original = new \RuntimeException('Event-only reporting'); + $events = []; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::exactly(2))->method('dispatch')->willReturnCallback( + /** + * Retains both deliveries for post-invocation identity assertions. + * + * @param object $event the raw throwable or ErrorEvent wrapper + * + * @return object the unchanged event + */ + static function (object $event) use (&$events): object { + $events[] = $event; + + return $event; + }, + ); + $raw = new DispatchThrowableHandler($dispatcher); + $wrapped = new DispatchErrorEventThrowableHandler($dispatcher); + $container = $this->createMock(ContainerInterface::class); + $container->expects(self::exactly(4))->method('has')->willReturn(true); + $container->expects(self::exactly(3))->method('get')->willReturnCallback( + /** + * Models optional lookup failure while retaining both configured adapters. + * + * @param string $id the requested reporter service + * + * @return DispatchErrorEventThrowableHandler|DispatchThrowableHandler the available adapter + */ + static fn(string $id): DispatchThrowableHandler|DispatchErrorEventThrowableHandler => match ($id) { + CallableThrowableHandler::class => throw $missing, + DispatchThrowableHandler::class => $raw, + DispatchErrorEventThrowableHandler::class => $wrapped, + default => throw new \LogicException('Unexpected reporter lookup: ' . $id), + }, + ); + $aggregate = (new ThrowableHandlerAggregateFactory())($container); + self::assertCount(0, $events); + $aggregate->handleThrowable($original); - $this->expectOutputString(''); - $aggregate->handleThrowable(new \RuntimeException('No reporter')); + self::assertCount(2, $events); + self::assertSame($original, $events[0]); + self::assertInstanceOf(ErrorEvent::class, $events[1]); + self::assertSame($original, $events[1]->getThrowable()); + } + + /** @return iterable Optional callable lookup failures that allow event reporting. */ + public static function provideAnUnavailableOptionalCallableDoesNotPreventEventDeliveryCases(): iterable + { + yield 'missing service' => [ + new class ('Missing callable') extends \RuntimeException implements NotFoundExceptionInterface { + }, + ]; + + yield 'structurally unconfigured callable' => [ + AutowiringException::forUnresolvableParameter(CallableThrowableHandler::class, 'callable', 'callable'), + ]; + } + + /** Verifies structural failures outside the optional callable lookup are not skipped. */ + public function testDispatchAdapterAutowiringFailuresPropagate(): void + { + $failure = AutowiringException::forUnresolvableParameter( + DispatchThrowableHandler::class, + 'eventDispatcher', + EventDispatcherInterface::class, + ); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturnCallback( + static fn(string $id): bool => CallableThrowableHandler::class !== $id, + ); + $container->expects(self::once())->method('get') + ->with(DispatchThrowableHandler::class)->willThrowException($failure) + ; + $this->expectExceptionObject($failure); + + (new ThrowableHandlerAggregateFactory())($container); } /** @@ -196,9 +272,14 @@ public function testAnAdvertisedButMissingOptionalCallableIsSkipped(): void public function testConstructionErrorsAreNotHidden(\Throwable $failure): void { $container = $this->containerFailingWith($failure); - $this->expectExceptionObject($failure); - (new ThrowableHandlerAggregateFactory())($container); + try { + (new ThrowableHandlerAggregateFactory())($container); + self::fail('Unexpectedly swallowed an explicit construction failure.'); + } catch (\Throwable $caught) { + self::assertSame($failure, $caught); + self::assertSame($failure->getPrevious(), $caught->getPrevious()); + } } /** @@ -214,6 +295,13 @@ public static function provideConstructionErrorsAreNotHiddenCases(): iterable ]; yield 'application failure' => [new \LogicException('Reporter initialization failed')]; + + yield 'explicit factory structural failure' => [ + ContainerException::forInvalidService( + CallableThrowableHandler::class, + AutowiringException::forUnresolvableParameter(CallableThrowableHandler::class, 'callable', 'callable'), + ), + ]; } /** From 0fcf15af58da3e552761f3ae4405631e138b1f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 17 Oct 2026 15:07:45 -0300 Subject: [PATCH 21/27] docs: align consumer guidance and release maintenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix callable filtering, installed-document links and checkout setup, clarify stream and fatal-reporting contracts, and keep release guidance current. Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/README.md | 19 ++++---- .changeset/modernize-error-handler.md | 3 ++ README.md | 20 ++++----- docs/api.md | 37 +++++++++++----- docs/container.md | 5 ++- docs/examples.md | 44 +++++++++++-------- docs/fast-forward.md | 14 +++--- docs/http.md | 10 ++--- docs/index.html | 1 + docs/migration.md | 2 +- docs/releases.md | 2 +- docs/testing.md | 2 +- examples/README.md | 31 +++++++++++++ .../references/reporters.md | 3 +- src/Handler/PhpLastErrorShutdownHandler.php | 9 ++-- .../ThrowableStreamFactoryInterface.php | 12 ++--- 16 files changed, 139 insertions(+), 75 deletions(-) diff --git a/.changeset/README.md b/.changeset/README.md index 9742bd6..03d2c56 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -91,8 +91,9 @@ jobs skip `dependabot/` branches because the writer already creates the note; th do not depend on the writer. The general Changesets validation remains independent. No PR code is executed by the writer. -This activates after merge. Observe the first real bot PR and rebase then; local -checks do not establish hosted write permissions. `GITHUB_TOKEN` commits +Verify the generated note and its commit on the current Dependabot PR after +updates or rebases; local checks do not establish hosted write permissions. +`GITHUB_TOKEN` commits [do not trigger ordinary PR workflows](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow), so the generated commit does not automatically get another validation run. The existing `workflow_dispatch` trigger can validate the updated branch manually. @@ -187,9 +188,9 @@ after creating a tag but before creating its GitHub release, inspect that tag and finish the missing release with the matching changelog entry; this minimal workflow does not add a separate recovery implementation. -The first live version PR and release must still be observed after an -owner-authorized merge. Review prerelease needs separately before adding a -prerelease policy to this stable 2.x flow. +After an owner-authorized version-PR merge, verify the workflow run, immutable +tag, matching GitHub release and Packagist reference. Review prerelease needs +separately before adding a prerelease policy to this stable-release flow. ## Maintain the changelog introduction and history @@ -220,9 +221,11 @@ git diff -- package.json package-lock.json CHANGELOG.md .changeset Never commit the token. Adding notes and running `changeset:status` do not need a GitHub token. -With only the initial maintenance note, expect `2.0.1` in both manifests, a patch -changelog entry, and removal of that note. `composer.json` and `src/` must remain -unchanged; any ignored local `composer.lock` is outside release preparation. +Derive the expected version and bump from the current `changeset:status` release +plan. The rehearsal must write that version to both npm manifests, add the +matching changelog entry and consume the planned notes. `composer.json` and +`src/` must remain unchanged; any ignored local `composer.lock` is outside +release preparation. A second run with no pending notes must not bump the version again; Changesets 3 exits with code 1 for that no-op. The validation job skips this command when the release plan is empty. Do not commit a local diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 29dc896..96fece0 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -47,4 +47,7 @@ Modernize the library for PHP 8.3+ while retaining the package PSR ranges and optional Whoops 2.18.4 minimum. - Describe the accompanying API without temporary PR or publication status, and clarify that diagnostic reporter output remains outside the PSR-7 body. +- Correct callable-filtering guidance, clarify generic stream and fatal-reporting + contracts, and distinguish checkout recipes from bundled application examples. + Keep installed documentation links usable and release instructions current. - Keep content negotiation under application control. diff --git a/README.md b/README.md index 6489f8d..5ab93b2 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ application's private diagnostic sink. All three constructor inputs accept the matching handler interface or a callable; omitted PHP-error and shutdown inputs retain their defaults. Existing interface -objects keep their identity. See the [complete three-callback recipe](examples/php-errors.php). +objects keep their identity. See the [complete three-callback recipe](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php). ## 🧰 Choose an integration @@ -74,10 +74,10 @@ objects keep their identity. See the [complete three-callback recipe](examples/p | Filter or fan out | [Filtering][link-filtering], [aggregates][link-aggregates] and [best effort][link-best-effort]. | | Use a container | [Interop/FastForward provider, overrides and fresh scopes](docs/fast-forward.md). | | Return an HTTP response | [PSR-15 middleware and PSR-17 composition](docs/http.md). | -| Return JSON with one private report | [Opt-in JSON rendering](docs/http.md#opt-in-json-responses-and-explicit-reporting) and [complete recipe](examples/http-json.php). | -| Use Interop/FastForward providers | [JSON profile](docs/fast-forward.md#json-profile) and [complete provider recipe](examples/provider-json.php). | -| Render development HTML | [Optional Whoops renderer](docs/http.md#opt-in-development-html-with-whoops) and [manual recipe](examples/http-whoops.php). | -| Configure Whoops services | [Whoops profile](docs/fast-forward.md#development-html-profile) and [complete provider recipe](examples/provider-whoops.php). | +| Return JSON with one private report | [Opt-in JSON rendering](docs/http.md#opt-in-json-responses-and-explicit-reporting) and [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/http-json.php). | +| Use Interop/FastForward providers | [JSON profile](docs/fast-forward.md#json-profile) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). | +| Render development HTML | [Optional Whoops renderer](docs/http.md#opt-in-development-html-with-whoops) and [manual recipe](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). | +| Configure Whoops services | [Whoops profile](docs/fast-forward.md#development-html-profile) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). | JSON and Whoops adapters introduced in 2.5.0 use the same representation APIs. Middleware reports once before the pure renderer; do not add the 2.x reporting @@ -108,9 +108,9 @@ failure propagation, callback ownership and output-buffer limits. ## 📚 Guides and examples -- [Complete cookbook](docs/examples.md), [application setup](examples/README.md) - and [bounded example verification](examples/verify.md). -- [Source documentation](docs/index.html), [web documentation][link-docs] and +- [Complete cookbook](docs/examples.md), [application setup](https://github.com/coisa/error-handler/blob/master/examples/README.md) + and [bounded example verification](https://github.com/coisa/error-handler/blob/master/examples/verify.md). +- [Source documentation](https://github.com/coisa/error-handler/blob/master/docs/index.html), [web documentation][link-docs] and [API reference](docs/api.md). - [Portable `coisa-error-handler` consumer skill](resources/skills/coisa-error-handler/SKILL.md) with complete application examples; [optional installation](docs/agent-skill.md) @@ -120,9 +120,9 @@ failure propagation, callback ownership and output-buffer limits. ## 🤝 Contributing -Follow the [contributor guide](CONTRIBUTING.md) for dependency setup and +Follow the [contributor guide](https://github.com/coisa/error-handler/blob/master/CONTRIBUTING.md) for dependency setup and `composer --no-plugins check`. See [testing](docs/testing.md) for process fixtures -and platform evidence, and [Changesets](.changeset/README.md) for release notes. +and platform evidence, and [Changesets](https://github.com/coisa/error-handler/blob/master/.changeset/README.md) for release notes. Node.js is used only by development release tooling. ## License diff --git a/docs/api.md b/docs/api.md index b6d9a3d..ecb05e9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,7 +17,7 @@ try { ``` For process-wide uncaught handling, keep the handler registered through termination, -as in [the CLI example](../examples/cli.php). PHP executes finally before dispatching +as in [the CLI example](https://github.com/coisa/error-handler/blob/master/examples/cli.php). PHP executes finally before dispatching an uncaught exception, so unregistering there returns responsibility to the previous handler. Direct `handleThrowable()` delivers the original object and returns; the registered uncaught callback exits 1 when reporting returns normally. @@ -49,7 +49,7 @@ Callbacks receive one Throwable, four PHP-error arguments, or no shutdown arguments respectively; their return values are ignored. Explicit adapter objects remain useful when another API requires an interface. Null and omitted options retain the default policies below, including shutdown reporting through the same -ErrorHandler. The [complete callback recipe](../examples/php-errors.php) supplies +ErrorHandler. The [complete callback recipe](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php) supplies all three callables and pairs active shutdown notification with cleanup. The default `ThrowErrorExceptionPhpErrorHandler` honors `error_reporting()` and @@ -73,16 +73,26 @@ that calls exit/die or causes a native fatal does not become recoverable. ## Callable reporters, aggregates and PSR-14 -`CallableThrowableHandler` adapts `callable(Throwable): void`. +`CallableThrowableHandler` adapts `callable(Throwable): mixed` and ignores its +return value. `ThrowableHandlerAggregate` accepts variadic reporters and `attach()`, delivering the same object in order and stopping on the first failure. `DispatchThrowableHandler` dispatches the original throwable through PSR-14. `DispatchErrorEventThrowableHandler` dispatches an `ErrorEvent` wrapper. -The two listener providers select Throwable objects or the built-in ErrorEvent; -callable listeners adapt application callbacks. `LogErrorEventListener` forwards -the diagnostic event to an application PSR-3 logger. Stringifying ErrorEvent -includes private diagnostics and must not produce a public HTTP body. +Under `CoiSA\ErrorHandler\EventDispatcher`, +`ListenerProvider\ThrowableListenerProvider` selects Throwable objects; +`ListenerProvider\ErrorEventListenerProvider` selects the concrete built-in +`Event\ErrorEvent`, not arbitrary implementations of `ErrorEventInterface`. +Both accept variadic callable listeners and `attach()` in order; lookup selects +listeners without invoking them. The application dispatcher performs delivery. + +`Listener\ThrowableCallableListener` and `Listener\ErrorEventCallableListener` +invoke their callbacks, ignore callback results and return the same throwable or +event respectively. Their callback failures propagate. `LogErrorEventListener` +forwards the diagnostic event to an application PSR-3 logger. +`ErrorEvent::getThrowable()` preserves the original object; stringifying the +event includes private diagnostics and must not produce a public HTTP body. See [the complete event/logger example](examples.md#events). ## Direct PSR-3 logging @@ -101,7 +111,7 @@ It is not automatically attached when a logger is available in the container. Construct it explicitly or bind `ThrowableHandlerInterface` using the [merged-provider override](fast-forward.md#required-overrides). -See [the complete logging example](../examples/logging.php). Unlike this direct +See [the complete logging example](https://github.com/coisa/error-handler/blob/master/examples/logging.php). Unlike this direct adapter, `LogErrorEventListener` retains its event-string message policy. ## Conditional reporting @@ -117,7 +127,7 @@ No output, default filtering policy, registration or automatic aggregate discove is added. A skipped report does not recover the operation, change PHP suppression or choose the HTTP response. Rendering remains a separate public boundary regardless of whether a diagnostic reporter forwards the event. -See [the filtering example](../examples/filtering.php); select PHP severity via +See [the filtering example](https://github.com/coisa/error-handler/blob/master/examples/filtering.php); select PHP severity via ErrorException::getSeverity(), not an arbitrary exception code. ## Callable PHP-error policy @@ -130,7 +140,7 @@ There is no fifth errcontext argument, automatic mask check, conversion or outpu The callback owns suppression/conversion policy and its failures propagate unchanged. Through ErrorHandler, a thrown ErrorException follows the normal -reporting boundary; other failures propagate. The [complete PHP-error example](../examples/php-errors.php) +reporting boundary; other failures propagate. The [complete PHP-error example](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php) explicitly checks `error_reporting() & $code` before converting an enabled error. Pass the callable directly as the second ErrorHandler argument, or keep the @@ -140,6 +150,11 @@ use FastForward's `ServiceFactory` to bind a callback as a value rather than a construction factory. Null keeps the existing default. Construction and discovery never register native callbacks. +`Exception\ErrorException::fromPhpError($message, $code, $severity, $filename, $line, $previous)` +keeps exception code and PHP severity independent and preserves the original +previous throwable. Omitted or null location values become an empty filename +and line 0; the factory does not invent a source location or previous cause. + ## HTTP: report once, then render explicitly `ErrorHandlerMiddleware` accepts an @@ -225,7 +240,7 @@ If any reporter throws, including partial success, one returns the nonempty ordered list of identical reporter failures. Its constructor requires the original, one failure and optional remaining failures. The summary is fixed, but stored causes/failures and exception strings remain private diagnostics. -Use the [complete recipe](../examples/best-effort.php) for explicit terminal handling. +Use the [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/best-effort.php) for explicit terminal handling. No retry, deduplication, reentry guard or durable transport is added. Native termination can prevent later attempts. Default provider composition stays diff --git a/docs/container.md b/docs/container.md index 84c9bb6..0fda736 100644 --- a/docs/container.md +++ b/docs/container.md @@ -9,7 +9,10 @@ FastForward caches services. Resolve and invoke the provider's explicit factory services when a fresh handler/middleware scope is required. Discovery does not install native PHP callbacks; applications choose when to register the process handler. Merge application overrides after library definitions in one aggregate -provider. See the [complete example](../examples/fast-forward.php). +provider as shown under [required overrides](fast-forward.md#required-overrides). +The [bundled required-reporter program](../resources/skills/coisa-error-handler/examples/container.php) +provides complete setup; the short optional-discovery recipe uses a different +composition. The best-effort aggregate is also explicit: construct it with application reporters and bind the required reporting interface in the merged map. No factory, alias or diff --git a/docs/examples.md b/docs/examples.md index cf0b600..05d195d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,13 +1,19 @@ # Executable cookbook -These complete examples use PHP `^8.3`. The development dependencies include Nyholm PSR-7 and Phly EventDispatcher; applications own their PSR implementations and Composer lockfiles. FastForward Container is a required runtime dependency. See [example setup](../examples/README.md). +These complete examples use PHP `^8.3`. The development dependencies include Nyholm PSR-7 and Phly EventDispatcher; applications own their PSR implementations and Composer lockfiles. FastForward Container is a required runtime dependency. See [example setup](https://github.com/coisa/error-handler/blob/master/examples/README.md). -Run `php examples/verify.php` to verify every recipe and documented mode in bounded children. The CLI recipe deliberately exits 1. The [static site](index.html#examples) links these programs and displays checked excerpts. Every complete entry is checked byte-for-byte against source, omitting only its leading license header. Duplicate, changed or missing recipes fail validation; examples do not prove delivery to real transports. +These are source-checkout programs: Composer archives omit the top-level +`examples/` directory. Keep the complete directory, including its bootstrap and +assertion helper, and follow the setup guide above. +For the installed package, use the [bundled application examples](../resources/skills/coisa-error-handler/examples/README.md) +with the application's explicit autoloader. + +Run `php examples/verify.php` to verify every recipe and documented mode in bounded children. The CLI recipe deliberately exits 1. The [static site](https://coisa.github.io/error-handler/#examples) links these programs and displays checked excerpts. Every complete entry is checked byte-for-byte against source, omitting only its leading license header. Duplicate, changed or missing recipes fail validation; examples do not prove delivery to real transports. ## Process bootstrap -Report a warning and an uncaught exception, then exit 1. [Source](../examples/cli.php). +Report a warning and an uncaught exception, then exit 1. [Source](https://github.com/coisa/error-handler/blob/master/examples/cli.php). ```php @@ -36,7 +42,7 @@ throw new RuntimeException('Example uncaught exception; exit status is 1.'); ## Interop service provider -Resolve real FastForward definitions without installing callbacks during lookup. [Source](../examples/fast-forward.php). +Resolve real FastForward definitions without installing callbacks during lookup. [Source](https://github.com/coisa/error-handler/blob/master/examples/fast-forward.php). ```php @@ -105,7 +111,7 @@ try { ## Callable reporters and ordered composition -Deliver the same throwable in order; the existing aggregate remains fail-fast. [Source](../examples/reporters.php). +Deliver the same throwable in order; the existing aggregate remains fail-fast. [Source](https://github.com/coisa/error-handler/blob/master/examples/reporters.php). ```php @@ -147,7 +153,7 @@ Exercise original Throwable and ErrorEvent notification paths with only an application dispatcher. Expect one raw delivery, one wrapped delivery and `Private log records: 1`; exit 0 alone does not prove reporting occurred. See [optional discovery and fallback](fast-forward.md) before relying on the -default reporter. [Source](../examples/event-dispatcher.php). +default reporter. [Source](https://github.com/coisa/error-handler/blob/master/examples/event-dispatcher.php). ```php @@ -234,7 +240,7 @@ printf("Private log records: %d\n", count($logger->records)); ## PSR-15 middleware with the provider -Report once, render a generic response and restore the process handler. [Source](../examples/http-middleware.php). +Report once, render a generic response and restore the process handler. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-middleware.php). ```php @@ -311,7 +317,7 @@ printf("Reported: %d\n", count($reported)); ## Explicit JSON streams -Return application-owned JSON without echo or buffering. [Source](../examples/http-rendering.php). +Return application-owned JSON without echo or buffering. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-rendering.php). ```php @@ -395,7 +401,7 @@ printf("Private reports: %d\n", count($privateDiagnostics)); ## Application PHP-error and shutdown handlers -Supply explicit interface implementations and a shutdown callback. [Source](../examples/custom-handlers.php). +Supply explicit interface implementations and a shutdown callback. [Source](https://github.com/coisa/error-handler/blob/master/examples/custom-handlers.php). ```php @@ -478,7 +484,7 @@ printf("PHP errors: %d | Shutdown notifications: %d\n", count($phpErrors->messag ## Fresh handlers and nested middleware -Use factory services for a new scope; ordinary lookups remain cached. [Source](../examples/scopes.php). +Use factory services for a new scope; ordinary lookups remain cached. [Source](https://github.com/coisa/error-handler/blob/master/examples/scopes.php). ```php @@ -588,7 +594,7 @@ printf("HTTP %d | Reports: %d\n", $response->getStatusCode(), $reported); ## Explicit public output capture -Capture a separate public renderer, preserving the string zero. [Source](../examples/output-capture.php). +Capture a separate public renderer, preserving the string zero. [Source](https://github.com/coisa/error-handler/blob/master/examples/output-capture.php). ```php @@ -618,7 +624,7 @@ printf("Captured body: %s\n", (string) $body); ## Direct PSR-3 logging -Log exact messages with the original exception context. [Source](../examples/logging.php). +Log exact messages with the original exception context. [Source](https://github.com/coisa/error-handler/blob/master/examples/logging.php). ```php @@ -677,7 +683,7 @@ echo 'direct PSR-3 logging, exact messages and original context passed' . PHP_EO ## Filtering -Select per invocation without changing the event or defaults. [Source](../examples/filtering.php). +Select per invocation without changing the event or defaults. [Source](https://github.com/coisa/error-handler/blob/master/examples/filtering.php). ```php @@ -743,7 +749,7 @@ echo 'explicit filtering, severity selection and original-object delivery passed ## Callable PHP-error conversion Pass three callbacks directly to ErrorHandler, retaining four original PHP-error arguments, -application-owned mask policy and explicit active shutdown notification. [Source](../examples/php-errors.php). +application-owned mask policy and explicit active shutdown notification. [Source](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php). ```php @@ -829,7 +835,7 @@ echo 'callable PHP errors: four arguments, explicit mask policy and conversion p ## Best-effort reporting -Attempt every fixed slot in order, then retain the original and all reporter failures. [Source](../examples/best-effort.php). +Attempt every fixed slot in order, then retain the original and all reporter failures. [Source](https://github.com/coisa/error-handler/blob/master/examples/best-effort.php). ```php @@ -917,7 +923,7 @@ echo 'best-effort order, original cause and all reporter failures passed' . PHP_ ## Built-in JSON responses -Public and explicitly enabled debug JSON preserve HTTP error codes. Middleware reports privately once, then uses the pure factory. [Source](../examples/http-json.php). +Public and explicitly enabled debug JSON preserve HTTP error codes. Middleware reports privately once, then uses the pure factory. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-json.php). ```php @@ -1033,7 +1039,7 @@ echo 'HTTP JSON ' . $mode . ': explicit payload, status preservation and one pri ## Explicit development HTML -Trusted configuration selects pure JSON or optional Whoops HTML. The application owns diagnostic policy; no request enables it. [Source](../examples/http-whoops.php). +Trusted configuration selects pure JSON or optional Whoops HTML. The application owns diagnostic policy; no request enables it. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). ```php @@ -1149,7 +1155,7 @@ echo 'HTTP Whoops ' . $mode . ': explicit renderer selection and one private rep ## JSON service-provider profile -Compose base, JSON and application maps explicitly, with a real bool for debug and a fresh middleware scope. [Source](../examples/provider-json.php). +Compose base, JSON and application maps explicitly, with a real bool for debug and a fresh middleware scope. [Source](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). ```php @@ -1279,7 +1285,7 @@ echo 'Interop JSON ' . $mode . ': configured profile, fresh scope and one privat ## Whoops service-provider profile -Compose independent maps in one aggregate, configure the page service and reuse it sequentially while creating distinct request scopes. [Source](../examples/provider-whoops.php). +Compose independent maps in one aggregate, configure the page service and reuse it sequentially while creating distinct request scopes. [Source](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). ```php diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 8ee4c05..837c0c5 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -22,7 +22,7 @@ successful Composer resolution as a complete runtime check. See [test evidence](testing.md#coverage-and-current-evidence). The development checkout includes it in `require`. After installing -the checkout dependencies, run the complete [example](../examples/fast-forward.php): +the checkout dependencies, run the complete [example](https://github.com/coisa/error-handler/blob/master/examples/fast-forward.php): ```sh php examples/fast-forward.php @@ -32,7 +32,7 @@ It reports one warning and restores the prior PHP callbacks. Pass the library provider class and application provider directly to `container()`. This example adds a callable reporter for the library's optional reporter discovery. Its `bootstrap.php` selects the checkout or application autoloader as described in -[example setup](../examples/README.md): +[example setup](https://github.com/coisa/error-handler/blob/master/examples/README.md): ```php Installation and setup

Use Composer 2 to install the library on the actual PHP runtime. The declared PHP constraint is ^8.3; the CI matrix targets PHP 8.3–8.5. FastForward Container ^1.10 and the Interop provider contract are runtime dependencies.

Applications supply loggers, dispatchers, PSR-17 implementations and their emitter as needed. The development checkout supplies Nyholm and Phly for its synthetic recipes; optional filp/whoops:^2.18.4 is needed only where development HTML is selected.

Read example setup for the selected autoloader and verifier dependencies. Resolve with the actual PHP runtime and retain application lockfiles. Consult the recorded test evidence; a permitted constraint is not proof that every combination is clean.

+

The cookbook below runs from a matching source checkout: Composer archives omit the top-level examples/ directory. Keep its bootstrap and assertion helper beside the recipes. Installed applications can instead run the bundled application programs with their explicit Composer autoloader.

diff --git a/docs/migration.md b/docs/migration.md index c7dc515..041c5da 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -68,7 +68,7 @@ no migration of existing adapters is required. The reporter remains required; the second and third arguments still default to null and retain the original PHP error and fatal-shutdown policies. Callable results are ignored, including false from the four-argument PHP callback. Construction remains inert. See the -[complete callback recipe](../examples/php-errors.php). +[complete callback recipe](https://github.com/coisa/error-handler/blob/master/examples/php-errors.php). Before upgrading a real application, run the [documented checks](testing.md) and application-level tests for the actual reporter, middleware stack and PHP SAPI. These isolated fixtures do not establish reliability of third-party loggers, remote services or production infrastructure. diff --git a/docs/releases.md b/docs/releases.md index 70cfa58..5147dc4 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,7 +7,7 @@ Composer consumers do not execute Node/npm scripts. Implementation PRs carry an English pending Changeset describing their consumer-visible change. Use patch for compatible fixes, minor for compatible additions and major for approved breaking changes. The -[Changesets procedure](../.changeset/README.md) covers note creation, dependency +[Changesets procedure](https://github.com/coisa/error-handler/blob/master/.changeset/README.md) covers note creation, dependency automation, validation, version PRs, provenance and GitHub publication. ```sh diff --git a/docs/testing.md b/docs/testing.md index 890297e..cff1c21 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -58,7 +58,7 @@ CI exports the committed Git archive, verifies development-file exclusions and installs it into a fresh consumer with `--no-dev --no-plugins --no-scripts`. Production jobs run PHP 8.3–8.5; lower-bound runtime graphs run on 8.3 and 8.5. The consumer requires only this library, so FastForward/Interop/PSR dependencies -must resolve transitively. See [production checks](../tests/Production/README.md). +must resolve transitively. See [production checks](https://github.com/coisa/error-handler/blob/master/tests/Production/README.md). The archive gate also requires every file of the canonical consumer skill, including nested examples and its license, with exact source bytes. Functional PHPUnit cases reject missing, changed and unrelated resource assets; the bundled diff --git a/examples/README.md b/examples/README.md index aaf9e76..fd59ce9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,37 @@ These synthetic examples exercise the library's reporting, lifecycle, provider and HTTP contracts on PHP `^8.3`. Use examples matching the installed API; follow the [migration guide](../docs/migration.md) when upgrading from 2.x. +The top-level `examples/` directory is source-checkout material and is excluded +from Composer distribution archives. For an installed library, start with the +[complete bundled application programs](../resources/skills/coisa-error-handler/examples/README.md) +that ship under `vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/`. +They accept the application's explicit autoloader and do not need the checkout's +development autoload map. + +To use the cookbook below, select a matching inspected source checkout and keep +its complete `examples/` directory, including +[`bootstrap.php`](https://github.com/coisa/error-handler/blob/master/examples/bootstrap.php) +and [`check.php`](https://github.com/coisa/error-handler/blob/master/examples/check.php). +The bootstrap selects the Composer autoloader and loads the assertion helper; +copying only an individual recipe is insufficient. From an existing application, +run a checkout recipe against that application's graph: + +```sh +ERROR_HANDLER_AUTOLOAD=/absolute/application/vendor/autoload.php php /absolute/source-checkout/examples/reporters.php +``` + +Expect exit 0, empty stderr and exactly: + +```text +first: Synthetic failure +second: Synthetic failure +``` + +Use the same autoloader selection for the other individual recipes. When using +the checkout's own graph, follow its +[dependency setup](https://github.com/coisa/error-handler/blob/master/CONTRIBUTING.md) +and run commands from that checkout root. + The source checkout supplies application implementations through development dependencies. In a disposable application, add `nyholm/psr7:^1.8.2` and `phly/phly-event-dispatcher:^1.6` for HTTP/event recipes. diff --git a/resources/skills/coisa-error-handler/references/reporters.md b/resources/skills/coisa-error-handler/references/reporters.md index 4eb8842..ef97cf6 100644 --- a/resources/skills/coisa-error-handler/references/reporters.md +++ b/resources/skills/coisa-error-handler/references/reporters.md @@ -39,10 +39,11 @@ Using the configured reporter from the ordinary setup: ```php use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\FilteringThrowableHandler; $selected = new FilteringThrowableHandler( - $configuration['reporter'], + new CallableThrowableHandler($configuration['reporter']), /** * Route PHP warnings by severity, without interpreting exception codes. * diff --git a/src/Handler/PhpLastErrorShutdownHandler.php b/src/Handler/PhpLastErrorShutdownHandler.php index dc7e66f..8f6c257 100644 --- a/src/Handler/PhpLastErrorShutdownHandler.php +++ b/src/Handler/PhpLastErrorShutdownHandler.php @@ -22,7 +22,7 @@ * Handles PHP fatal errors during the shutdown phase. * This class SHALL capture the last error using `error_get_last()` and delegate * its processing to the provided PhpErrorHandlerInterface instance if it is - * classified as a catchable fatal error. + * classified as a fatal error. Reporting does not recover the fatal failure. * * @package CoiSA\ErrorHandler\Handler */ @@ -47,7 +47,8 @@ public function __construct(PhpErrorHandlerInterface $phpErrorHandler) * Handles the last PHP error during shutdown. * * This method SHALL retrieve the last error using `error_get_last()` and, - * if it is classified as a catchable fatal error, delegate it to the error handler. + * if it is classified as a fatal error, delegate it to the error handler. + * This notification does not recover the fatal failure. */ public function handleShutdown(): void { @@ -66,7 +67,7 @@ public function handleShutdown(): void } /** - * Determines if an error level represents a catchable fatal error. + * Determines if an error level represents a fatal shutdown event. * * This method SHALL check if the provided error level corresponds * to one of the fatal error types: E_ERROR, E_PARSE, E_CORE_ERROR, @@ -74,7 +75,7 @@ public function handleShutdown(): void * * @param int $level the PHP error level to check * - * @return bool true if the error is a catchable fatal error, false otherwise + * @return bool true if the error is a classified fatal event, false otherwise */ private function isCatchableFatalError(int $level): bool { diff --git a/src/Http/Message/ThrowableStreamFactoryInterface.php b/src/Http/Message/ThrowableStreamFactoryInterface.php index 9a35c8e..6716bd8 100644 --- a/src/Http/Message/ThrowableStreamFactoryInterface.php +++ b/src/Http/Message/ThrowableStreamFactoryInterface.php @@ -21,9 +21,9 @@ /** * Interface ThrowableStreamFactoryInterface. * - * Defines the contract for creating PSR-7 streams from Throwable instances. - * Implementing classes SHALL ensure that throwable details are converted - * into a valid PSR-7 stream representation, suitable for logging or HTTP responses. + * Defines the contract for creating PSR-7 streams for Throwable instances. + * Implementations choose the representation, which may be generic and omit + * diagnostic details. Creating a stream does not imply reporting or emission. * * @package CoiSA\ErrorHandler\Http\Message */ @@ -32,12 +32,12 @@ interface ThrowableStreamFactoryInterface /** * Creates a stream from a Throwable instance. * - * This method SHALL process the provided Throwable and convert its details - * into a PSR-7 compliant StreamInterface instance. + * The representation may omit the throwable's diagnostic details according + * to the application's public-output policy. * * @param \Throwable $throwable the throwable instance to convert into a stream * - * @return StreamInterface the generated PSR-7 stream containing throwable details + * @return StreamInterface the generated PSR-7 stream representation */ public function createStreamFromThrowable(\Throwable $throwable): StreamInterface; } From e20b351f27a5e2dad1aaf50a1f4c67bf59b3164b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 17 Oct 2026 16:14:39 -0300 Subject: [PATCH 22/27] fix: keep caller buffers out of captured responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the existing generic fallback when rendering leaves no capture above the caller's entry depth, and verify caller bytes remain private. Add regression coverage for partial callback ownership, fail-fast reporter identity and native fatal metadata. Align PHPDoc, guides and consumer skill. Signed-off-by: Felipe Sayão Lobato Abreu --- .changeset/modernize-error-handler.md | 6 +- docs/api.md | 2 + docs/http.md | 2 + docs/index.html | 2 +- docs/testing.md | 7 +- .../coisa-error-handler/references/http.md | 2 + src/Http/Message/ThrowableStreamFactory.php | 3 +- tests/Fixtures/lifecycle.php | 4 +- tests/Process/ProcessBehaviorTest.php | 20 +++ tests/Unit/ErrorHandlerTest.php | 122 ++++++++++++++++++ .../Handler/ThrowableHandlerAggregateTest.php | 58 +++++++++ .../Message/ThrowableStreamFactoryTest.php | 34 +++++ 12 files changed, 254 insertions(+), 8 deletions(-) diff --git a/.changeset/modernize-error-handler.md b/.changeset/modernize-error-handler.md index 96fece0..9226ab0 100644 --- a/.changeset/modernize-error-handler.md +++ b/.changeset/modernize-error-handler.md @@ -20,9 +20,11 @@ Modernize the library for PHP 8.3+ while retaining the package default, standard reason phrases and integer HTTP error statuses through 599. - Keep explicit public output capture as an opt-in renderer adapter. Preserve `0`, use a generic empty body, clean reachable buffers after success and failure, - and retain renderer-failure precedence over cleanup failures. + and retain renderer-failure precedence over cleanup failures. If no capture + remains above the caller's entry depth, use the generic fallback without reading caller output. - Retain delivered 2.x handlers and native release tooling. Add distribution checks - and organized PHPUnit Unit/Functional/Process coverage. + and organized PHPUnit Unit/Functional/Process coverage. Cover partial callback + ownership, fail-fast reporter identity and native fatal diagnostic metadata. Preserve the configurable 80% overall coverage floor and real comparisons; retire the prototype Fire/HTTP/Python parents and custom release publisher. - Check all complete cookbook recipes against executable source, retaining diff --git a/docs/api.md b/docs/api.md index ecb05e9..e17878f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -204,6 +204,8 @@ stream; a JSON callback would need to echo JSON. It is not the provider default. Pass a separate public renderer, not the diagnostic reporter middleware already called. Empty captured output becomes `Internal Server Error`; `0` remains valid. +Before reading output, the adapter checks that a buffer remains above the caller's +entry depth. If none remains, it uses the generic body instead of reading a caller buffer. The adapter cleans reachable removable buffers after success and failure and preserves a renderer failure over cleanup failure. Cleanup stops at a non-removable buffer or lack of progress. Renderers must balance their own removable buffers and never diff --git a/docs/http.md b/docs/http.md index 4442976..54d9b42 100644 --- a/docs/http.md +++ b/docs/http.md @@ -28,6 +28,8 @@ public echo-based renderer. Its output becomes the response stream: `0` is preserved, and empty output uses the generic body. A private reporter is not that public renderer and must not be invoked again during rendering. +If rendering returns with no buffer above the caller's entry depth, capture uses +the generic body instead of reading the caller's existing output. Capture cleans reachable removable renderer-owned buffers after success and failure. It stops at protected buffers or no progress and retains the first cleanup failure; the original renderer failure takes precedence. Renderers must diff --git a/docs/index.html b/docs/index.html index 344487e..5f52828 100644 --- a/docs/index.html +++ b/docs/index.html @@ -304,7 +304,7 @@

Use output capture only for a separate public renderer

$streams = new ThrowableStreamFactory(new Psr17Factory(), $publicRenderer); -

This explicit stream adapter captures deliberately public output; it must not invoke the private reporter again. Empty output uses the generic body and "0" is preserved. Cleanup removes reachable removable scopes, stops without progress/protected buffers and preserves a primary renderer failure. Native exit/fatal effects remain unrecoverable. The application owns response emission and terminal policy; see HTTP boundaries.

+

This explicit stream adapter captures deliberately public output; it must not invoke the private reporter again. Empty output uses the generic body and "0" is preserved. If no buffer remains above the caller's entry depth, it uses the generic body instead of reading existing caller output. Cleanup removes reachable removable scopes, stops without progress/protected buffers and preserves a primary renderer failure. Native exit/fatal effects remain unrecoverable. The application owns response emission and terminal policy; see HTTP boundaries.

diff --git a/docs/testing.md b/docs/testing.md index cff1c21..41253e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -39,11 +39,12 @@ application logs. They do not merge child coverage into PHPUnit's parent report. | --- | --- | | Warnings, masking and suppression | Direct native warnings, severity/identity metadata and continuation | | Uncaught Exception, Error, TypeError and ParseError | Bounded child reports original type and exits 1 | -| Compile/allocation fatals, nested handler | Native failure remains fatal; only the active owner reports | -| Re-registration and callback restoration | One retained shutdown callback, stable closure identities and reverse-order ownership | +| Compile/allocation fatals, nested handler | Native failure remains fatal; only the active owner reports; compile cases preserve message/file/line against the native snapshot and fixture source | +| Re-registration and callback restoration | One retained shutdown callback, stable closure identities, reverse-order ownership and rejection of either callback's isolated replacement without altering native masks | | Failed or recursive reporter | Propagation without retry during registration; direct reuse and lifecycle reset tested separately | +| Default fail-fast aggregate | The identical reporter failure stops later slots; a subsequent independent invocation can complete | | Generic HTTP rendering | One diagnostic report, then a separate renderer; status 400–599 and standard reason phrases | -| Output capture | Exact nonempty bytes including `0`, generic empty fallback, cleanup on success/failure, primary failure precedence | +| Output capture | Exact nonempty bytes including `0`, generic fallback for empty/closed capture without reading caller output, cleanup on success/failure, primary failure precedence | | Protected/terminating output handlers | Externally bounded Process cases preserve caller ownership and show native termination limits | | Fatal in an earlier shutdown callback | Fresh and reused handlers both receive no event; PHP aborts the remaining queue | diff --git a/resources/skills/coisa-error-handler/references/http.md b/resources/skills/coisa-error-handler/references/http.md index 04c0f0a..80d33f9 100644 --- a/resources/skills/coisa-error-handler/references/http.md +++ b/resources/skills/coisa-error-handler/references/http.md @@ -50,6 +50,8 @@ becomes the stream: empty output becomes `Internal Server Error`, while `"0"` remains a valid body. Never inject the private reporter middleware already called. The built-in JSON and Whoops response factories return their own bodies directly. +Capture reads only when a buffer remains above the caller's entry depth; otherwise +it uses the generic body without reading the caller's existing output. Capture cleanup removes reachable removable buffers above the caller's entry level. It stops at protected buffers or no progress, retaining the first cleanup failure. A renderer failure takes precedence; otherwise a cleanup failure diff --git a/src/Http/Message/ThrowableStreamFactory.php b/src/Http/Message/ThrowableStreamFactory.php index 3c3725b..80a96c8 100644 --- a/src/Http/Message/ThrowableStreamFactory.php +++ b/src/Http/Message/ThrowableStreamFactory.php @@ -62,6 +62,7 @@ public function __construct( * its output into a PSR-7 compatible stream. * It SHALL preserve the body "0" and use a generic fallback for empty output. * Renderers MUST balance removable buffers and MUST NOT close caller-owned buffers. + * Output SHALL be read only when a buffer remains above the caller's entry depth. * * @param \Throwable $throwable the throwable instance to process * @@ -79,7 +80,7 @@ public function createStreamFromThrowable(\Throwable $throwable): StreamInterfac try { $this->throwableHandler->handleThrowable($throwable); - $buffer = ob_get_contents(); + $buffer = ob_get_level() > $initialLevel ? ob_get_contents() : false; } catch (\Throwable $rendererFailure) { $failure = $rendererFailure; } finally { diff --git a/tests/Fixtures/lifecycle.php b/tests/Fixtures/lifecycle.php index b725185..cf64b36 100644 --- a/tests/Fixtures/lifecycle.php +++ b/tests/Fixtures/lifecycle.php @@ -23,7 +23,7 @@ $case = $argv[1] ?? ''; /** - * Records native exception/shutdown events through the constructor's callable adapter. + * Records native events and their last-error snapshot without altering diagnostic metadata. * * @param Throwable $event the event supplied by the real handler */ @@ -31,6 +31,8 @@ echo json_encode([ 'event' => 'error', 'class' => $event::class, 'severity' => $event instanceof ErrorException ? $event->getSeverity() : null, + 'message' => $event->getMessage(), 'file' => $event->getFile(), 'line' => $event->getLine(), + 'lastError' => error_get_last(), ], JSON_THROW_ON_ERROR) . "\n"; }; $handler = new ErrorHandler($reporter); diff --git a/tests/Process/ProcessBehaviorTest.php b/tests/Process/ProcessBehaviorTest.php index 6b0dfc5..34aebb8 100644 --- a/tests/Process/ProcessBehaviorTest.php +++ b/tests/Process/ProcessBehaviorTest.php @@ -97,6 +97,26 @@ static function (string $type, string $chunk) use (&$bytes, &$limited): void { if (null !== $severity) { self::assertSame($severity, $observed[0]['severity'] ?? null); } + if (\in_array($case, ['compile-fatal', 'nested-fatal'], true)) { + $fatal = $observed[0]; + $lastError = $fatal['lastError'] ?? null; + self::assertIsArray($lastError); + self::assertSame(E_COMPILE_ERROR, $lastError['type']); + self::assertSame($lastError['message'], $fatal['message']); + self::assertSame($lastError['file'], $fatal['file']); + self::assertSame($lastError['line'], $fatal['line']); + + $fixture = \dirname(__DIR__) . '/Fixtures/compile-fatal.inc'; + $lines = file($fixture, FILE_IGNORE_NEW_LINES); + self::assertIsArray($lines); + $declaration = array_search('class FireInvalidSubclass extends FireFinalClass {}', $lines, true); + self::assertIsInt($declaration); + self::assertSame($fixture, $fatal['file']); + self::assertSame($declaration + 1, $fatal['line']); + self::assertIsString($fatal['message']); + self::assertStringContainsString('FireInvalidSubclass', $fatal['message']); + self::assertStringContainsString('FireFinalClass', $fatal['message']); + } if ($native) { self::assertNotSame('', $process->getErrorOutput()); } else { diff --git a/tests/Unit/ErrorHandlerTest.php b/tests/Unit/ErrorHandlerTest.php index ea10a74..ee606b9 100644 --- a/tests/Unit/ErrorHandlerTest.php +++ b/tests/Unit/ErrorHandlerTest.php @@ -23,6 +23,7 @@ use PHPUnit\Framework\TestCase; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\Attributes\WithoutErrorHandler; @@ -114,6 +115,125 @@ public function testUnregisterRequiresReverseRegistrationOrder(): void self::assertSame($before, self::callbacks()); } + /** + * Reject partial ownership loss without changing callback identities or their native masks. + * + * @param bool $replaceError whether the foreign integration replaces only the error callback + */ + #[DataProvider('providePartialCallbackReplacementPreservesOwnershipAndMasksCases')] + #[WithoutErrorHandler] + public function testPartialCallbackReplacementPreservesOwnershipAndMasks(bool $replaceError): void + { + $before = self::callbacks(); + $mask = error_reporting(E_ALL & ~E_USER_WARNING); + $previousEvents = []; + $foreignEvents = []; + $ownedEvents = []; + set_error_handler( + /** + * Observe delivery through the original callback's restricted mask. + * + * @param int $code the delivered PHP severity + * + * @return bool suppress the native diagnostic after recording it + */ + static function (int $code) use (&$previousEvents): bool { + $previousEvents[] = $code; + + return true; + }, + E_USER_NOTICE, + ); + set_exception_handler( + /** @param \Throwable $error an unused input on the original exception callback */ + static function (\Throwable $error): void { + }, + ); + $previous = self::callbacks(); + $reporter = $this->createMock(ThrowableHandlerInterface::class); + $reporter->expects(self::never())->method('handleThrowable'); + $shutdown = $this->createMock(ShutdownHandlerInterface::class); + $shutdown->expects(self::never())->method('handleShutdown'); + $handler = new ErrorHandler( + $reporter, + /** @param int $code the severity delivered through the handler's unrestricted native mask */ + static function (int $code) use (&$ownedEvents): void { + $ownedEvents[] = $code; + }, + $shutdown, + ); + $handler->register(); + + if ($replaceError) { + set_error_handler( + /** + * Record the foreign callback without accepting severities outside its own mask. + * + * @param int $code the delivered PHP severity + * + * @return bool suppress the native diagnostic after recording it + */ + static function (int $code) use (&$foreignEvents): bool { + $foreignEvents[] = $code; + + return true; + }, + E_USER_NOTICE, + ); + } else { + set_exception_handler( + /** @param \Throwable $error an unused input on the foreign exception callback */ + static function (\Throwable $error): void { + }, + ); + } + + try { + try { + $foreign = self::callbacks(); + + try { + $handler->unregister(); + self::fail('Losing either callback must prevent unregistering the active scope.'); + } catch (\LogicException) { + self::assertSame($foreign, self::callbacks()); + } + $handler->handleShutdown(); + self::assertSame($foreign, self::callbacks()); + self::assertSame(E_ALL & ~E_USER_WARNING, error_reporting()); + trigger_error('Observe current notice mask', E_USER_NOTICE); + trigger_error('Observe current warning mask', E_USER_WARNING); + self::assertSame($replaceError ? [E_USER_NOTICE] : [], $foreignEvents); + self::assertSame($replaceError ? [] : [E_USER_NOTICE, E_USER_WARNING], $ownedEvents); + self::assertSame([], $previousEvents); + } finally { + if ($replaceError) { + restore_error_handler(); + } else { + restore_exception_handler(); + } + $handler->unregister(); + } + + self::assertSame($previous, self::callbacks()); + trigger_error('Observe restored notice mask', E_USER_NOTICE); + trigger_error('Observe restored warning mask', E_USER_WARNING); + self::assertSame([E_USER_NOTICE], $previousEvents); + } finally { + restore_error_handler(); + restore_exception_handler(); + error_reporting($mask); + } + + self::assertSame($before, self::callbacks()); + } + + /** @return array independent replacement of each native callback */ + public static function providePartialCallbackReplacementPreservesOwnershipAndMasksCases(): iterable + { + return ['only error callback' => [true], 'only exception callback' => [false]]; + } + /** Verifies a poisoned registration skips shutdown and resets only after a completed scope. */ public function testFailedRegistrationSkipsShutdownAndCanStartANewScope(): void { @@ -390,6 +510,8 @@ static function (\Throwable $event) use (&$events): void { * Snapshots both callback identities without retaining a probe on either native stack. * * @return array{null|callable, null|callable} the current PHP error and exception callbacks + * + * @phpstan-impure */ private static function callbacks(): array { diff --git a/tests/Unit/Handler/ThrowableHandlerAggregateTest.php b/tests/Unit/Handler/ThrowableHandlerAggregateTest.php index 932c98e..08c5624 100644 --- a/tests/Unit/Handler/ThrowableHandlerAggregateTest.php +++ b/tests/Unit/Handler/ThrowableHandlerAggregateTest.php @@ -70,4 +70,62 @@ static function (\Throwable $received) use ($name, &$calls): void { ['attached', $throwable], ], $calls); } + + /** A failed slot stops its pass without replacing the failure or poisoning later invocations. */ + public function testFailureStopsDeliveryAndAnIndependentPassCanSucceed(): void + { + $original = new \RuntimeException('First diagnostic'); + $independent = new \Error('Independent diagnostic'); + $failure = new \Error('Second reporter failed'); + $calls = []; + $attempts = 0; + $aggregate = new ThrowableHandlerAggregate( + new CallableThrowableHandler( + /** @param \Throwable $received the unchanged diagnostic supplied to the first slot */ + static function (\Throwable $received) use (&$calls): void { + $calls[] = ['first', $received]; + }, + ), + new CallableThrowableHandler( + /** + * Fail the first pass after recording the original diagnostic. + * + * @param \Throwable $received the unchanged diagnostic supplied to the failing slot + * + * @throws \Error the identical first-pass reporter failure + */ + static function (\Throwable $received) use (&$calls, &$attempts, $failure): void { + $calls[] = ['second', $received]; + if (1 === ++$attempts) { + throw $failure; + } + }, + ), + new CallableThrowableHandler( + /** @param \Throwable $received the unchanged diagnostic delivered only after earlier slots succeed */ + static function (\Throwable $received) use (&$calls): void { + $calls[] = ['third', $received]; + }, + ), + ); + $this->expectOutputString(''); + + try { + $aggregate->handleThrowable($original); + self::fail('The failing reporter must stop the current pass.'); + } catch (\Error $caught) { + self::assertSame($failure, $caught); + } + self::assertSame([['first', $original], ['second', $original]], $calls); + + $aggregate->handleThrowable($independent); + + self::assertSame([ + ['first', $original], + ['second', $original], + ['first', $independent], + ['second', $independent], + ['third', $independent], + ], $calls); + } } diff --git a/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php b/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php index cca6f16..ef087e9 100644 --- a/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php +++ b/tests/Unit/Http/Message/ThrowableStreamFactoryTest.php @@ -233,6 +233,40 @@ public function testPreservesCallerWhenReporterAlreadyClosedCapture(): void } } + /** + * Uses a generic body when the renderer closes capture without throwing. + */ + public function testDoesNotCaptureCallerOutputWhenRendererClosesCapture(): void + { + /** + * Closes only the factory buffer, then returns without producing a body. + * + * @param \Throwable $throwable the reported event + */ + $reporter = new CallableThrowableHandler(static function (\Throwable $throwable): void { + ob_end_clean(); + }); + $entryLevel = ob_get_level(); + ob_start(); + echo 'private caller output'; + $callerLevel = ob_get_level(); + + try { + $stream = (new ThrowableStreamFactory(new Psr17Factory(), $reporter)) + ->createStreamFromThrowable(new \RuntimeException('Private failure')) + ; + + self::assertSame('Internal Server Error', (string) $stream); + self::assertStringNotContainsString('private caller output', (string) $stream); + self::assertSame($callerLevel, ob_get_level()); + self::assertSame('private caller output', ob_get_contents()); + } finally { + while (ob_get_level() > $entryLevel) { + ob_end_clean(); + } + } + } + /** Verifies successful unbalanced rendering discards all reachable renderer-owned buffers. */ public function testUnbalancedSuccessCleansRendererBuffers(): void { From c178301e42f68e1a4ee573b95ac4771f7913ca25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Tue, 20 Oct 2026 17:47:58 -0300 Subject: [PATCH 23/27] fix(changelog): retain inherited Whoops fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changelog/modernize-error-handler.md | 2 ++ .changelog/whoops-native-source-frames.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changelog/modernize-error-handler.md b/.changelog/modernize-error-handler.md index 5c964ee..9f45226 100644 --- a/.changelog/modernize-error-handler.md +++ b/.changelog/modernize-error-handler.md @@ -48,6 +48,8 @@ Modernize the library for PHP 8.3+ while retaining the package PHP 8.3 support through PHPUnit 12.5 and Symfony Process 7.4 alternatives. Require Nyholm 1.8.2 in development for its PHP 8.5 fixes; retain the compatible PSR ranges and optional Whoops 2.18.7 minimum with its upstream native STDIN source-read fix. + The Whoops compatibility fix preserves its renderer contract; the lifecycle and + HTTP-default changes above apply to this major. - Describe the accompanying API without temporary PR or publication status, and clarify that diagnostic reporter output remains outside the PSR-7 body. - Correct callable-filtering guidance, clarify generic stream and fatal-reporting diff --git a/.changelog/whoops-native-source-frames.md b/.changelog/whoops-native-source-frames.md index a3ec0ec..bbff02e 100644 --- a/.changelog/whoops-native-source-frames.md +++ b/.changelog/whoops-native-source-frames.md @@ -7,6 +7,6 @@ Require optional Whoops `^2.18.7` for development HTML, adopting its upstream fi for native STDIN and `php -r` source inspection. Reject older Whoops versions through Composer without making the optional package mandatory. Preserve original exception frames, one original report, legitimate application warnings and the existing -Whoops renderer response policy. Align installation guidance and the bundled +2.x response and lifecycle policies. Align installation guidance and the bundled consumer skill, with bounded native source regressions alongside the renderer's unit tests. From 8ecd83cdd82e1565eaaeda8677fe5134685c8e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Tue, 20 Oct 2026 21:37:59 -0300 Subject: [PATCH 24/27] feat!: negotiate request-aware error responses with Symfony renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- .changelog/modernize-error-handler.md | 9 +- .../request-aware-response-negotiation.md | 24 ++ .github/workflows/ci.yml | 59 +++ AGENTS.md | 19 +- README.md | 31 +- composer.json | 10 +- docs/api.md | 29 +- docs/cli.md | 55 +++ docs/container.md | 13 +- docs/examples.md | 333 ++++++++++++--- docs/fast-forward.md | 173 ++++---- docs/http.md | 105 ++++- docs/index.html | 77 ++-- docs/migration.md | 68 ++- docs/testing.md | 5 +- examples/README.md | 40 +- examples/custom-handlers.php | 2 +- examples/event-dispatcher.php | 15 +- examples/fast-forward.php | 2 +- examples/http-html.php | 64 +++ examples/http-json.php | 23 +- examples/http-middleware.php | 2 +- examples/http-whoops.php | 11 +- examples/provider-json.php | 39 +- examples/provider-whoops.php | 73 +++- examples/scopes.php | 2 +- examples/symfony-cli.php | 94 +++++ examples/verify.md | 10 +- examples/verify.php | 22 +- resources/skills/coisa-error-handler/SKILL.md | 30 +- .../PublicThrowableResponseFactory.php | 6 +- .../coisa-error-handler/examples/README.md | 73 +++- .../coisa-error-handler/examples/html.php | 62 +++ .../coisa-error-handler/examples/json.php | 19 +- .../examples/provider-json.php | 41 +- .../examples/provider-whoops.php | 88 ++-- .../examples/symfony-cli.php | 108 +++++ .../references/container.md | 43 +- .../coisa-error-handler/references/http.md | 20 +- .../coisa-error-handler/references/json.md | 110 +++-- .../references/reporters.md | 27 ++ .../coisa-error-handler/references/whoops.md | 92 +++-- src/Handler/SymfonyCliThrowableHandler.php | 93 +++++ .../Message/HtmlThrowableResponseFactory.php | 132 ++++++ .../Message/JsonThrowableResponseFactory.php | 83 ++-- .../NegotiatingThrowableResponseFactory.php | 144 +++++++ .../ReportingThrowableResponseFactory.php | 12 +- src/Http/Message/ThrowableResponseFactory.php | 10 +- .../ThrowableResponseFactoryInterface.php | 9 +- .../WhoopsThrowableResponseFactory.php | 10 +- .../Middleware/ErrorHandlerMiddleware.php | 2 +- .../ErrorHandlerServiceProvider.php | 58 ++- ...tiatingThrowableResponseFactoryFactory.php | 81 ++++ .../SymfonyCliThrowableHandlerFactory.php | 48 +++ .../ThrowableHandlerAggregateFactory.php | 16 + .../WhoopsThrowableResponseFactoryFactory.php | 50 +++ .../JsonErrorHandlerServiceProvider.php | 73 ---- .../WhoopsErrorHandlerServiceProvider.php | 87 ---- .../Functional/ConsumerSkillExamplesTest.php | 35 +- .../Middleware/ErrorHandlerCleanupTest.php | 9 +- .../ErrorHandlerCompositionTest.php | 30 +- .../ErrorHandlerServiceProviderTest.php | 101 ++++- tests/Production/container.php | 19 +- .../SymfonyCliThrowableHandlerTest.php | 225 ++++++++++ .../HtmlThrowableResponseFactoryTest.php | 328 +++++++++++++++ .../JsonThrowableResponseFactoryTest.php | 227 ++++++++-- ...egotiatingThrowableResponseFactoryTest.php | 386 ++++++++++++++++++ .../ReportingThrowableResponseFactoryTest.php | 41 +- .../Message/ThrowableResponseFactoryTest.php | 8 +- .../WhoopsThrowableResponseFactoryTest.php | 46 ++- .../Middleware/ErrorHandlerMiddlewareTest.php | 56 ++- .../ErrorHandlerServiceProviderTest.php | 36 +- ...ingThrowableResponseFactoryFactoryTest.php | 239 +++++++++++ .../SymfonyCliThrowableHandlerFactoryTest.php | 110 +++++ .../ThrowableHandlerAggregateFactoryTest.php | 95 +++++ ...opsThrowableResponseFactoryFactoryTest.php | 177 ++++++++ ...tiatedErrorHandlerServiceProviderTest.php} | 185 ++++++--- ... => OptionalWhoopsServiceProviderTest.php} | 60 +-- 78 files changed, 4505 insertions(+), 944 deletions(-) create mode 100644 .changelog/request-aware-response-negotiation.md create mode 100644 docs/cli.md create mode 100644 examples/http-html.php create mode 100644 examples/symfony-cli.php create mode 100644 resources/skills/coisa-error-handler/examples/html.php create mode 100644 resources/skills/coisa-error-handler/examples/symfony-cli.php create mode 100644 src/Handler/SymfonyCliThrowableHandler.php create mode 100644 src/Http/Message/HtmlThrowableResponseFactory.php create mode 100644 src/Http/Message/NegotiatingThrowableResponseFactory.php create mode 100644 src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php create mode 100644 src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php create mode 100644 src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php delete mode 100644 src/ServiceProvider/JsonErrorHandlerServiceProvider.php delete mode 100644 src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php create mode 100644 tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php create mode 100644 tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php create mode 100644 tests/Unit/Http/Message/NegotiatingThrowableResponseFactoryTest.php create mode 100644 tests/Unit/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactoryTest.php create mode 100644 tests/Unit/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactoryTest.php create mode 100644 tests/Unit/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactoryTest.php rename tests/Unit/ServiceProvider/{JsonErrorHandlerServiceProviderTest.php => NegotiatedErrorHandlerServiceProviderTest.php} (63%) rename tests/Unit/ServiceProvider/{WhoopsErrorHandlerServiceProviderTest.php => OptionalWhoopsServiceProviderTest.php} (93%) diff --git a/.changelog/modernize-error-handler.md b/.changelog/modernize-error-handler.md index 9f45226..13d7860 100644 --- a/.changelog/modernize-error-handler.md +++ b/.changelog/modernize-error-handler.md @@ -37,9 +37,10 @@ Modernize the library for PHP 8.3+ while retaining the package - Add a direct native regression for output-handler destruction that replaces its removed buffer, proving bounded cleanup, primary-failure and caller ownership. - Preserve the JSON, optional Whoops and explicit reporting adapters published in - 2.5.0. Compose independent response providers with pure renderer aliases after - the middleware report; retain trusted debug configuration, lazy page - overrides and fresh middleware scopes without a second diagnostic delivery. + 2.5.0. Compose pure renderers after the middleware report; retain trusted debug + configuration, lazy page overrides and fresh middleware scopes without a second + diagnostic delivery. Consolidate response profiles into the request-aware + provider described in the companion negotiation entry. - Accept matching callables in all three ErrorHandler constructor inputs using existing adapters. Preserve interface identity and precedence, required reporter, optional null defaults, inert construction and the existing reporting policies. @@ -55,4 +56,4 @@ Modernize the library for PHP 8.3+ while retaining the package - Correct callable-filtering guidance, clarify generic stream and fatal-reporting contracts, and distinguish checkout recipes from bundled application examples. Keep installed documentation links usable and release instructions current. -- Keep content negotiation under application control. +- Keep available representations and diagnostic authorization under application control. diff --git a/.changelog/request-aware-response-negotiation.md b/.changelog/request-aware-response-negotiation.md new file mode 100644 index 0000000..566b791 --- /dev/null +++ b/.changelog/request-aware-response-negotiation.md @@ -0,0 +1,24 @@ +--- +category: changed +type: major +--- + +Negotiate error responses from the current request through a single +`ErrorHandlerServiceProvider`. Replace the JSON and Whoops profile providers with +lazy Problem Details JSON, plain-text and Symfony HTML representations. Accept a required +`ServerRequestInterface` alongside the original throwable in every response +factory and forward it unchanged after one middleware report. + +Keep diagnostic output disabled by default. Explicit debug configuration enables +JSON diagnostics and Symfony's detailed HTML, while production HTML remains a +generic status page. Keep Whoops available only through explicit application +composition, without changing the default based on installed optional packages. +Respect Accept quality and exclusions, preserve existing Vary fields and keep +selection independent across requests that reuse the same cached service. + +Serialize Problem Details with Symfony Serializer, preserving public HTTP status +codes and keeping trace arguments and objects out of debug responses. Support +compatible Symfony component majors without requiring a matching VarDumper major. +Offer an explicit Symfony CLI reporter through the same provider, writing to +STDERR or the application's Console error output without installing native hooks. +Demonstrate PSR-14 reporting with FastForward Event Dispatcher and its provider. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fd17a5..f1733b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,65 @@ jobs: - name: Run formatting, PHPCS, PHPUnit and maximum src-plus-tests analysis run: composer --no-plugins check + symfony-compatibility: + name: Symfony ${{ matrix.error-handler }} / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - php: '8.3' + error-handler: '~5.4.0' + serializer: '~5.4.0' + http-foundation: '~6.4.0' + - php: '8.3' + error-handler: '~6.4.0' + serializer: '~6.4.0' + http-foundation: '~6.4.0' + - php: '8.4' + error-handler: '~7.0.0' + serializer: '~7.0.0' + http-foundation: '~7.4.0' + - php: '8.5' + error-handler: '~8.0.0' + serializer: '~8.0.0' + http-foundation: '~8.1.0' + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Isolate Composer state + run: | + echo "COMPOSER_HOME=$RUNNER_TEMP/composer-home" >> "$GITHUB_ENV" + echo "COMPOSER_CACHE_DIR=$RUNNER_TEMP/composer-cache" >> "$GITHUB_ENV" + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, xml, xmlwriter + ini-values: error_reporting=E_ALL + coverage: none + tools: composer:v2 + + - name: Resolve the supported Symfony combination + env: + ERROR_HANDLER: ${{ matrix.error-handler }} + SERIALIZER: ${{ matrix.serializer }} + HTTP_FOUNDATION: ${{ matrix.http-foundation }} + run: | + composer update --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress \ + --with "symfony/error-handler:$ERROR_HANDLER" \ + --with "symfony/serializer:$SERIALIZER" \ + --with "symfony/http-foundation:$HTTP_FOUNDATION" + composer check-platform-reqs + composer show --locked + + - name: Verify the adapters and application composition + run: composer --no-plugins check + production: name: Production / PHP ${{ matrix.php }} / ${{ matrix.dependencies }} runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 481127f..21de048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed handlers for nested scopes. Follow [the lifecycle contract](docs/api.md). - FastForward caches services. Keep the middleware handler separate from the process handler and use the provider's factory services for fresh scopes. - Prefer `container(ErrorHandlerServiceProvider::class, $application)` for normal + Prefer `container($application, ErrorHandlerServiceProvider::class)` for normal dependency and optional-reporter composition. For required overrides, merge application definitions after library definitions in one `AggregateServiceProvider`: separate providers prioritize the first and may @@ -42,11 +42,15 @@ PSR-17 implementations, HTTP emission and the moment PHP callbacks are installed scopes; use autowiring for plain constructor injection. A possible move to php-fast-forward does not itself change package identity or namespace. - HTTP middleware requires lifecycle and throwable-reporting contracts on the same - scoped handler. Report once, then invoke a pure response renderer. Keep the generic - stream as the provider default and output capture as an explicit public-renderer - adapter. Keep the independent JSON and Whoops profile maps pure; applications - compose them with the base and their required reporter/PSR-17 bindings. Never - invoke the diagnostic reporter again during rendering. + scoped handler. Report once, then pass the original Throwable and current + ServerRequestInterface to a pure response renderer. The single provider selects + request-aware negotiation: JSON Problem Details or Symfony HTML when accepted, generic fallback + otherwise. Trusted debug enables details without forcing a representation. + Whoops is optional and only selected by an explicit application override; + its installation never changes defaults. Keep output capture + an explicit public-renderer adapter; never report diagnostics again during rendering. +- CLI rendering is explicit through SymfonyCliThrowableHandler or the provider's cli flag; + do not infer it from SAPI or add a second Console exception renderer. - Explicit Log/Filtering/CallablePHP/BestEffort adapters remain opt-in; the default reporter aggregate stays fail-fast and adds no implicit discovery for them. - Reporters own diagnostic output; HTTP fallback bodies must not leak exception @@ -99,7 +103,7 @@ Repository maintenance and verification continue to follow this AGENTS.md. - [docs/api.md](docs/api.md): core lifecycle, reporters and PSR composition. - [docs/fast-forward.md](docs/fast-forward.md): provider definitions, overrides, cached services and fresh middleware scopes. -- [docs/examples.md](docs/examples.md): all seventeen complete recipes, checked +- [docs/examples.md](docs/examples.md): all nineteen complete recipes, checked byte-for-byte against executable source with only the leading license header omitted; preserve setup boundaries and matching website excerpts. - [docs/releases.md](docs/releases.md): independent fragments, reviewed version PRs, @@ -107,6 +111,7 @@ Repository maintenance and verification continue to follow this AGENTS.md. - [docs/testing.md](docs/testing.md): PHPUnit/process tests, commands and evidence limits. - [CONTRIBUTING.md](CONTRIBUTING.md): dependency resolution and quality entrypoints. - [examples/README.md](examples/README.md): executable recipe dependencies and verifier. +- [docs/cli.md](docs/cli.md): explicit Symfony terminal reporting and one Console exception owner. - [docs/http.md](docs/http.md): pure JSON/Whoops rendering, explicit capture, reporting and diagnostic-output limits. - [docs/migration.md](docs/migration.md): breaking changes from published 2.x. diff --git a/README.md b/README.md index 56134c6..1668c8e 100644 --- a/README.md +++ b/README.md @@ -69,22 +69,27 @@ objects keep their identity. See the [complete three-callback recipe](https://gi | Need | Entry point and guide | | --- | --- | | Report a throwable | `ErrorHandler::handleThrowable()` and [reporter composition][link-reporters]. | +| Format terminal diagnostics | [Symfony CLI handler and Console ownership](docs/cli.md). | | Handle warnings or uncaught exceptions | `register()` / `unregister()` and [native callbacks][link-lifecycle]. | | Send diagnostics to a logger or dispatcher | [PSR-3 logging][link-logging] and [PSR-14 events][link-events]. | | Filter or fan out | [Filtering][link-filtering], [aggregates][link-aggregates] and [best effort][link-best-effort]. | | Use a container | [Interop/FastForward provider, overrides and fresh scopes](docs/fast-forward.md). | | Return an HTTP response | [PSR-15 middleware and PSR-17 composition](docs/http.md). | | Return JSON with one private report | [Opt-in JSON rendering](docs/http.md#opt-in-json-responses-and-explicit-reporting) and [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/http-json.php). | -| Use Interop/FastForward providers | [JSON profile](docs/fast-forward.md#json-profile) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). | -| Render development HTML | [Optional Whoops renderer](docs/http.md#opt-in-development-html-with-whoops) and [manual recipe](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). | -| Configure Whoops services | [Whoops profile](docs/fast-forward.md#development-html-profile) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). | +| Use Interop/FastForward providers | [Request-aware provider](docs/fast-forward.md#request-aware-responses) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). | +| Return public or diagnostic HTML | [Symfony HTML](docs/http.md#public-html) and [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/http-html.php). | +| Select optional Whoops diagnostics | [Development HTML configuration](docs/fast-forward.md#development-html-configuration) and [complete provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). | -JSON and Whoops adapters introduced in 2.5.0 use the same representation APIs. -Middleware reports once before the pure renderer; do not add the 2.x reporting -decorator. Each provider declares only its own map, composed by the application. +One Interop provider supplies lifecycle services and request-aware responses. +It selects JSON Problem Details or Symfony HTML when accepted and a generic fallback otherwise. Trusted +debug enables diagnostic HTML/JSON; optional Whoops requires an explicit override. Debug never forces HTML. +Applications supply PSR-17 implementations and a private reporter through +`container($application, ErrorHandlerServiceProvider::class)`. -Whoops is an explicit development choice. Applications own trusted debug -activation and format selection. +Every response factory receives the original Throwable and current +ServerRequestInterface. Middleware reports once before rendering; do not add the +2.x reporting decorator. Direct JSON rendering uses Symfony Serializer; JSON and Whoops renderers remain available for +explicit application pipelines. ## Lifecycle and HTTP boundaries @@ -95,11 +100,11 @@ activation and format selection. Direct `handleThrowable()` calls return normally. Fatal errors remain fatal. - FastForward caches services and discovery installs no callbacks. Middleware has a separate handler; invoke the reusable factory for each independent scope. -- Middleware reports once, then renders. `GenericThrowableStreamFactory` always - supplies `Internal Server Error`, independently of reporter output; codes - 400–599 are preserved and reason phrases come from PSR-17. The middleware does - not capture reporter output: echoed diagnostics can leak outside the PSR-7 body - and corrupt HTTP emission. JSON and development HTML are explicit renderer choices. +- Middleware reports once, then passes the current request to its pure renderer. + The default provider negotiates its representation from `Accept`; missing or + unsupported preferences use the generic `Internal Server Error` body. Codes + 400–599 are preserved and reason phrases come from PSR-17. Reporter echo can + still leak outside the response and corrupt emission; keep diagnostics private. - Output capture is an explicit public-renderer adapter: empty output uses the generic body and `"0"` remains valid. Reporters must keep diagnostics private. diff --git a/composer.json b/composer.json index ac6059d..8d590e1 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,10 @@ "psr/http-server-middleware": "^1.0", "psr/http-factory": "^1.1", "psr/event-dispatcher": "^1.0", - "psr/log": "^1.1 || ^2.0 || ^3.0" + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/error-handler": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4.46 || ^7.4.19 || ^8.1.7", + "symfony/serializer": "^5.4 || ^6.4 || ^7.0 || ^8.0" }, "conflict": { "filp/whoops": "<2.18.7" @@ -46,12 +49,12 @@ }, "require-dev": { "coisa/php-cs-fixer": "^3.0", + "fast-forward/event-dispatcher": "^1.0", "filp/whoops": "^2.18.7", "jangregor/phpstan-prophecy": "^2.3", "llm/skills": "^1.15", "nyholm/psr7": "^1.8.2", "nyholm/psr7-server": "^1.1", - "phly/phly-event-dispatcher": "^1.6", "phpspec/prophecy": "^1.26", "phpspec/prophecy-phpunit": "^2.5", "phpstan/phpstan": "^2.3", @@ -84,6 +87,7 @@ }, "suggest": { "llm/skills": "Optionally synchronize the bundled agent skill in consumer projects.", - "filp/whoops": "Install ^2.18.7 for optional development HTML responses." + "filp/whoops": "Install ^2.18.7 for optional development HTML responses.", + "symfony/console": "Optionally route the explicit CLI reporter through the application's Console output." } } diff --git a/docs/api.md b/docs/api.md index e17878f..1340081 100644 --- a/docs/api.md +++ b/docs/api.md @@ -163,7 +163,8 @@ and line 0; the factory does not invent a source location or previous cause. both contracts. A custom lifecycle implementation must support reporting too. On a downstream Throwable, middleware first calls the scoped handler, then asks -the response factory for a public representation. A reporter failure propagates +the response factory for a public representation, passing the original Throwable +and current ServerRequestInterface. A reporter failure propagates after cleanup; its failed-registration guard prevents a second reporter attempt when a PHP warning caused that failure. Rendering failure also propagates after cleanup. Successful downstream responses pass through unchanged. @@ -188,7 +189,7 @@ lets PSR-17 supply the standard reason phrase. Implement `ThrowableStreamFactoryInterface` for a pure custom body, such as JSON, or `ThrowableResponseFactoryInterface` when also choosing Content-Type/headers. -See [JSON without echo](examples.md#http-rendering). The application owns emission, +See [JSON without echo](examples.md#explicit-json-streams). The application owns emission, headers, transport and public payload policy; diagnostic details are never a safe default. The pure `JsonThrowableResponseFactory` and optional `WhoopsThrowableResponseFactory` retained from 2.5.0 provide built-in alternatives; @@ -196,6 +197,23 @@ see [HTTP composition](http.md). `ReportingThrowableResponseFactory` remains an explicit utility for other pipelines, but must not wrap the renderer after this middleware has already reported. +The response-factory signature is +`createResponseFromThrowable(Throwable $throwable, ServerRequestInterface $request): ResponseInterface`. +Custom factories and direct callers must accept/supply the request even when a +fixed renderer ignores it. The middleware forwards the current object unchanged. +See [negotiation policy](http.md#request-negotiation) and the +[single provider](fast-forward.md#request-aware-responses). + +## Symfony terminal reporting + +`Handler\SymfonyCliThrowableHandler(?OutputInterface $output = null)` formats the +original through Symfony CliErrorRenderer. It writes to stderr by default, or uses +an application's Console output with raw writes and its error channel where available. +Construction is inert; reporting does not choose SAPI or an exit status. Explicitly +select it, or use provider `cli: true`; it is never an HTTP renderer. +Keep one exception owner when integrating Symfony Console. See the +[complete CLI guide](cli.md) and [recipe](examples.md#symfony-terminal-diagnostics). + ## Optional output-capture adapter `ThrowableStreamFactory` remains available explicitly for public echo-based @@ -219,13 +237,16 @@ registers PHP callbacks during discovery/construction. The explicit lifecycle factories live under `ServiceProvider\Factory`; no parallel standalone container or native Laminas ConfigProvider remains. -Use `container(ErrorHandlerServiceProvider::class, $application)` to supply +Use `container($application, ErrorHandlerServiceProvider::class)` to supply application dependencies and optional reporters. To override existing library service IDs, merge library definitions first and the application last in one AggregateServiceProvider. Bind required reporters/renderers explicitly through their interfaces. The default reporter aggregate discovers optional callable/PSR-14 reporters and is silent when -none are configured. The default HTTP stream alias resolves GenericThrowableStreamFactory. +none are configured. The default response alias resolves NegotiatingThrowableResponseFactory. It selects +JSON or Symfony HTML from Accept, uses trusted debug for diagnostic details, +and otherwise uses the generic stream. Optional Whoops requires an explicit override. The concrete response +renderers remain public; all require a ServerRequestInterface argument. Applications supply PSR-14, PSR-17 and logger implementations. See [provider wiring](fast-forward.md) and [migration](migration.md). diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..b4e4c0b --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,55 @@ +# Terminal diagnostics with Symfony + +`SymfonyCliThrowableHandler(?OutputInterface $output = null)` formats the original +Throwable through Symfony's `CliErrorRenderer`. With no output argument it writes +to stderr, without requiring Symfony Console. It does not register callbacks, +choose a SAPI, terminate a process or render an HTTP response. + +For direct terminal reporting: + +```php +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; + +$errors = new ErrorHandler(new SymfonyCliThrowableHandler()); +$errors->handleThrowable($failure); +``` + +Supply a Console `OutputInterface` when the application already owns one. +A `ConsoleOutputInterface` routes the diagnostic to `getErrorOutput()`; +other outputs receive it directly. Writes use `OUTPUT_RAW`, so exception text +is not parsed as Console markup. The output collaborator requires optional +`symfony/console` in that application. Renderer/output failures propagate unchanged. + +## Provider composition + +The normal provider remains silent when no reporter is supplied. Explicitly opt in +with `new ErrorHandlerServiceProvider(cli: true)`; debug is a separate first +parameter controlling HTTP JSON/HTML diagnostics. The default aggregate appends the +CLI reporter after its optional configured reporters. An application override of +`ThrowableHandlerInterface` remains authoritative and replaces that aggregate. + +Bind `OutputInterface::class` through an application service definition when +needed, then use `container($application, new ErrorHandlerServiceProvider(cli: true))`. +Without that service, diagnostics go to stderr. Service construction installs no +callbacks. There is no automatic SAPI detection and no automatic terminal renderer +in an HTTP response. + +## Symfony Console owns one catch + +Choose either Symfony Console's built-in exception rendering or this adapter. +When the application bootstrap owns the catch, call `setCatchExceptions(false)` +and `setAutoExit(false)` on its Console application. Catch once around +`run()`, report the original once and choose the command's exit status there. +Do not report and then let Console render the same failure again. + +The [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/symfony-cli.php) +has direct, provider and Console modes. It uses `BufferedOutput` only to verify +the synthetic diagnostic without sending it to a terminal. The +[bundled consumer program](../resources/skills/coisa-error-handler/examples/symfony-cli.php) +accepts the application's explicit autoloader. Both succeed after verifying one +diagnostic; a real CLI bootstrap chooses its own failure exit status. + +Diagnostics can include messages, paths, previous exceptions and trace data. +Keep this reporter in terminal/private diagnostic flows. HTTP responses use their +pure [request-aware renderers](http.md). diff --git a/docs/container.md b/docs/container.md index 0fda736..2b36da5 100644 --- a/docs/container.md +++ b/docs/container.md @@ -19,7 +19,12 @@ and bind the required reporting interface in the merged map. No factory, alias o automatic membership selects it. The default aggregate remains fail-fast; see [best-effort reporting](api.md#best-effort-reporting). -The independent [JSON profile](fast-forward.md#json-profile) and -[Whoops profile](fast-forward.md#development-html-profile) select pure renderers. -Compose base, selected profile and application maps explicitly; middleware owns -the single private report. No legacy capture/reporting wrapper is restored. +The single [request-aware provider](fast-forward.md#request-aware-responses) +selects a pure renderer from the current request's Accept header. Supply the +application first: `container($application, ErrorHandlerServiceProvider::class)`. +The default HTML implementation is Symfony in both public and debug modes. +Trusted debug enables details but never forces HTML. Optional Whoops requires +an explicit application override. JSON-only +requests do not construct Whoops. Middleware owns the single private report. +Use a merged map only when a required override must not fall through after a +container construction failure. diff --git a/docs/examples.md b/docs/examples.md index 05d195d..27bc85b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,6 +1,6 @@ # Executable cookbook -These complete examples use PHP `^8.3`. The development dependencies include Nyholm PSR-7 and Phly EventDispatcher; applications own their PSR implementations and Composer lockfiles. FastForward Container is a required runtime dependency. See [example setup](https://github.com/coisa/error-handler/blob/master/examples/README.md). +These complete examples use PHP `^8.3`. The development dependencies include Nyholm PSR-7 and FastForward EventDispatcher; applications own their PSR implementations and Composer lockfiles. FastForward Container is a required runtime dependency. See [example setup](https://github.com/coisa/error-handler/blob/master/examples/README.md). These are source-checkout programs: Composer archives omit the top-level `examples/` directory. Keep the complete directory, including its bootstrap and @@ -92,7 +92,7 @@ $application = new class implements ServiceProviderInterface { } }; -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); /** * @var ErrorHandlerInterface $errors @@ -169,10 +169,9 @@ use CoiSA\ErrorHandler\EventDispatcher\Listener\ThrowableCallableListener; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\ArrayServiceProvider; -use Phly\EventDispatcher\EventDispatcher; -use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; -use Psr\EventDispatcher\EventDispatcherInterface; +use FastForward\Config\ArrayConfig; +use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; +use Psr\EventDispatcher\ListenerProviderInterface; use Psr\Log\AbstractLogger; use Psr\Log\LogLevel; @@ -215,13 +214,11 @@ $events = new ErrorEventListenerProvider( ); $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); -$dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events)); - -$application = new ArrayServiceProvider([ - EventDispatcherInterface::class => static fn(): EventDispatcherInterface => $dispatcher, +$configuration = new ArrayConfig([ + ListenerProviderInterface::class => [$throwables, $events], ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($configuration, EventDispatcherServiceProvider::class, ErrorHandlerServiceProvider::class); $errors = $services->get(ErrorHandlerInterface::class); @@ -278,7 +275,7 @@ $application = new ArrayServiceProvider([ StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17, ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); $process = $services->get(ErrorHandlerInterface::class); $middleware = $services->get(ErrorHandlerMiddleware::class); @@ -465,7 +462,7 @@ $application = new ArrayServiceProvider([ ShutdownHandlerInterface::class => new ServiceFactory($shutdown), ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); $errors = $services->get(ErrorHandlerInterface::class); @@ -524,7 +521,7 @@ $application = new ArrayServiceProvider([ StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17, ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); $makeHandler = $services->get(ErrorHandlerFactory::class); $first = $makeHandler($services); @@ -936,6 +933,9 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Serializer; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -968,7 +968,9 @@ $reporter = new CallableThrowableHandler( $errors = new ErrorHandler($reporter); -$json = new JsonThrowableResponseFactory($messages, $messages, $debug); +$serializer = new Serializer([new ProblemNormalizer(true)], [new JsonEncoder()]); + +$json = new JsonThrowableResponseFactory($messages, $messages, $serializer, $debug); $middleware = new ErrorHandlerMiddleware($errors, $json); @@ -1006,24 +1008,28 @@ $response = $middleware->process($messages->createServerRequest('GET', '/example exampleCheck([$original] === $reports, 'Expected exactly one private report of the original event.'); exampleCheck(503 === $response->getStatusCode(), 'The integer HTTP error code was not preserved.'); -exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected a JSON representation.'); +exampleCheck( + 'application/problem+json' === $response->getHeaderLine('Content-Type'), + 'Expected a JSON Problem Details representation.', +); +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + if ($debug) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - exampleCheck('Request failed' === $payload['error'], 'The public error label changed.'); exampleCheck( - $original->getMessage() === $payload['exception']['message'] && [] !== $payload['exception']['trace'], + $original->getMessage() === $payload['detail'] && [] !== $payload['trace'], 'Explicit development metadata is missing.', ); - foreach ($payload['exception']['trace'] as $frame) { + foreach ($payload['trace'] as $frame) { exampleCheck( - !array_key_exists('args', $frame) && !array_key_exists('object', $frame), + [] === ($frame['args'] ?? []) && !array_key_exists('object', $frame), 'Private trace arguments or objects must not be serialized.', ); } exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Debug details must not be cached.'); } else { exampleCheck( - '{"error":"Request failed"}' === (string) $response->getBody(), + $publicBody === (string) $response->getBody(), 'The generic JSON body must not contain the private diagnostic.', ); } @@ -1037,9 +1043,70 @@ exampleCheck($bufferLevel === ob_get_level(), 'JSON composition unexpectedly cha echo 'HTTP JSON ' . $mode . ': explicit payload, status preservation and one private report passed' . PHP_EOL; ``` + +## Public and diagnostic Symfony HTML + +Report explicitly once, then render with the required current request. Public mode hides diagnostic details; trusted debug enables Symfony diagnostics. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-html.php). + + +```php +createServerRequest('GET', 'http://example.test/html'); +$renderer = new HtmlThrowableResponseFactory($messages, $messages, $debug); + +// An explicit pipeline owns reporting; the renderer only builds a response. +$errors->handleThrowable($failure); +$response = $renderer->createResponseFromThrowable($failure, $request); + +if ([$failure] !== $reports || 503 !== $response->getStatusCode()) { + throw new RuntimeException('Expected one original report and the HTTP error status.'); +} +if ( + 'text/html; charset=UTF-8' !== $response->getHeaderLine('Content-Type') + || ($debug && 'no-store' !== $response->getHeaderLine('Cache-Control')) +) { + throw new RuntimeException('Expected HTML and a no-store policy for debug.'); +} +if ($debug !== str_contains((string) $response->getBody(), $failure->getMessage())) { + throw new RuntimeException('Only explicit debug may disclose the diagnostic.'); +} +if ($failure->getMessage() === $response->getReasonPhrase()) { + throw new RuntimeException('Diagnostic text must not become the reason phrase.'); +} + +// Applications own emission; this fixture prints no diagnostic HTML. +echo 'HTTP HTML ' . $mode . ': Symfony rendering and one original report passed' . PHP_EOL; +``` + ## Explicit development HTML -Trusted configuration selects pure JSON or optional Whoops HTML. The application owns diagnostic policy; no request enables it. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). +Trusted configuration selects public Symfony HTML or explicit optional Whoops HTML. The application owns diagnostic policy; no request enables it. [Source](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). ```php @@ -1049,7 +1116,7 @@ declare(strict_types=1); use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; @@ -1078,7 +1145,7 @@ $reporter = new CallableThrowableHandler( }, ); -$renderer = new JsonThrowableResponseFactory($messages, $messages); +$renderer = new HtmlThrowableResponseFactory($messages, $messages); if ($debug) { $renderer = new WhoopsThrowableResponseFactory( @@ -1145,15 +1212,18 @@ if ($debug) { ); } else { exampleCheck(0 === $pageCalls, 'Public mode must not construct a Whoops page.'); - exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected public JSON.'); - exampleCheck('{"error":"Request failed"}' === (string) $response->getBody(), 'Expected the generic public body.'); + exampleCheck('text/html; charset=UTF-8' === $response->getHeaderLine('Content-Type'), 'Expected public HTML.'); + exampleCheck( + !str_contains((string) $response->getBody(), 'synthetic-development-detail'), + 'Public HTML must not disclose the diagnostic.', + ); } // No response is emitted. Hiding selected keys is not complete redaction or production authorization. echo 'HTTP Whoops ' . $mode . ': explicit renderer selection and one private report passed' . PHP_EOL; ``` -## JSON service-provider profile +## JSON negotiation with the single provider Compose base, JSON and application maps explicitly, with a real bool for debug and a fresh middleware scope. [Source](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php). @@ -1167,10 +1237,8 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -1192,7 +1260,7 @@ $mode = $argv[1] ?? 'public'; exampleCheck(in_array($mode, ['public', 'debug'], true), 'Choose the public or debug example mode.'); // This CLI choice stands in for trusted application configuration, never request headers. -$settings = config(['error_handler' => ['json' => ['debug' => 'debug' === $mode]]]); +$settings = config(['error_handler' => ['debug' => 'debug' === $mode]]); $messages = new Psr17Factory(); $original = new RuntimeException('Private synthetic provider failure', 503); $reports = []; @@ -1213,20 +1281,11 @@ $application = new ArrayServiceProvider([ ThrowableHandlerInterface::class => new ServiceFactory($reporter), ]); -// Merge the required reporter and renderer after library defaults in one provider. -if ('debug' === $mode) { - $services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider($settings->get('error_handler.json.debug', false)), - $application, - )); -} else { - $services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), - $application, - )); -} +// The application is searched first; one library provider supplies negotiation and lifecycle. +$services = container( + $application, + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); // Cache the factory, then invoke it for a distinct handler and wrapper for each HTTP scope. $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); @@ -1262,28 +1321,34 @@ $next = new class ($original) implements RequestHandlerInterface { } }; -$response = $middleware->process($messages->createServerRequest('GET', '/provider-json'), $next); +$request = $messages->createServerRequest('GET', '/provider-json') + ->withHeader('Accept', 'application/json') +; + +$response = $middleware->process($request, $next); -exampleCheck([$original] === $reports, 'The profile must report the exact original once.'); +exampleCheck([$original] === $reports, 'Middleware must report the exact original once.'); exampleCheck(503 === $response->getStatusCode(), 'Expected the native HTTP error code.'); exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected JSON.'); +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + if ('debug' === $mode) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); exampleCheck( - $original->getMessage() === $payload['exception']['message'], + $original->getMessage() === $payload['detail'], 'Trusted debug configuration did not reach the renderer.', ); exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); } else { - exampleCheck('{"error":"Request failed"}' === (string) $response->getBody(), 'Public JSON leaked diagnostics.'); + exampleCheck($publicBody === (string) $response->getBody(), 'Public JSON leaked diagnostics.'); } // Applications emit the response; this CLI fixture prints only its verification summary. -echo 'Interop JSON ' . $mode . ': configured profile, fresh scope and one private report passed' . PHP_EOL; +echo 'Interop JSON ' . $mode . ': configured provider, fresh scope and one private report passed' . PHP_EOL; ``` -## Whoops service-provider profile +## Public HTML and explicit Whoops negotiation Compose independent maps in one aggregate, configure the page service and reuse it sequentially while creating distinct request scopes. [Source](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). @@ -1295,14 +1360,19 @@ declare(strict_types=1); use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; +use FastForward\Container\Factory\CallableFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -1321,9 +1391,9 @@ exampleCheck( ); // FastForward is a required runtime dependency of this PHP 8.3+ major. -// Selecting this profile deliberately chooses diagnostic HTML; no request enables it. +// Trusted debug permits HTML; the current request must still accept it. $mode = $argv[1] ?? 'default'; -exampleCheck(in_array($mode, ['default', 'configured'], true), 'Choose default or configured mode.'); +exampleCheck(in_array($mode, ['public', 'default', 'configured'], true), 'Choose public, default or configured mode.'); $messages = new Psr17Factory(); $reports = []; @@ -1343,6 +1413,10 @@ $factories = [ ]; if ('configured' === $mode) { + if (!class_exists(PrettyPageHandler::class)) { + throw new RuntimeException('The configured development page needs filp/whoops:^2.18.7.'); + } + $settings = config([ 'development' => [ 'page_title' => 'Synthetic application diagnostics', @@ -1361,14 +1435,34 @@ if ('configured' === $mode) { $factories[PrettyPageHandler::class] = new ServiceFactory($page); } +if ('public' !== $mode) { + // Whoops is an explicit application choice; package presence never selects it. + $factories[ThrowableResponseFactoryInterface::class] = new CallableFactory( + /** + * Retain request negotiation while selecting a diagnostic HTML integration. + * + * @param ContainerInterface $services the composed application services + * + * @return NegotiatingThrowableResponseFactory the explicit application response policy + */ + static function (ContainerInterface $services): NegotiatingThrowableResponseFactory { + /** @var ThrowableResponseFactoryInterface $fallback the generic error response */ + $fallback = $services->get(ThrowableResponseFactory::class); + + return new NegotiatingThrowableResponseFactory($fallback, [ + 'text/plain' => static fn() => $fallback, + 'application/json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'application/problem+json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'text/html' => static fn() => $services->get(WhoopsThrowableResponseFactory::class), + ]); + }, + ); +} + $application = new ArrayServiceProvider($factories); -// One aggregate selects HTML explicitly and applies the application's page override. -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); +// Application definitions win; development modes explicitly select the Whoops HTML candidate. +$services = container($application, new ErrorHandlerServiceProvider(debug: 'public' !== $mode)); $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); exampleCheck($middlewareFactory instanceof ErrorHandlerMiddlewareFactory, 'Expected the fresh-scope factory.'); @@ -1420,24 +1514,38 @@ $route = static function (Throwable $failure): RequestHandlerInterface { }; $first = $firstScope->process( - $messages->createServerRequest('GET', '/first'), + $messages->createServerRequest('GET', '/first')->withHeader('Accept', 'text/html'), $route($failures[0]), ); $second = $secondScope->process( - $messages->createServerRequest('GET', '/second'), + $messages->createServerRequest('GET', '/second')->withHeader('Accept', 'text/html'), $route($failures[1]), ); exampleCheck($failures === $reports, 'Each original must reach the private reporter once.'); exampleCheck(503 === $first->getStatusCode() && 404 === $second->getStatusCode(), 'Expected original HTTP codes.'); exampleCheck('text/html; charset=UTF-8' === $first->getHeaderLine('Content-Type'), 'Expected HTML.'); -exampleCheck('no-store' === $first->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); +exampleCheck( + 'public' === $mode || 'no-store' === $first->getHeaderLine('Cache-Control'), + 'Diagnostics must not be cached.', +); exampleCheck( !str_contains((string) $second->getBody(), $failures[0]->getMessage()), 'The next page must not retain the first diagnostic.', ); +if ('public' === $mode) { + exampleCheck(0 === $pageCalls, 'Public HTML must not resolve a Whoops page.'); + foreach ($failures as $failure) { + exampleCheck( + !str_contains((string) $first->getBody(), $failure->getMessage()) + && !str_contains((string) $second->getBody(), $failure->getMessage()), + 'Public HTML must not expose diagnostics.', + ); + } +} + if ('configured' === $mode) { exampleCheck(1 === $pageCalls, 'Expected one cached configured page for sequential renders.'); exampleCheck( @@ -1451,7 +1559,102 @@ if ('configured' === $mode) { } // No HTTP emission. Hiding selected keys is not comprehensive redaction. -echo 'Whoops provider ' . $mode . ': lazy pages, fresh scopes and private reporting passed' . PHP_EOL; +echo 'public' === $mode + ? 'HTML provider public: Symfony renderer, fresh scopes and private reporting passed' . PHP_EOL + : 'Whoops provider ' . $mode . ': lazy pages, fresh scopes and private reporting passed' . PHP_EOL; +``` + + +## Symfony terminal diagnostics + +Choose explicit terminal reporting, an opt-in provider or one outer Console exception catch. +The BufferedOutput is a synthetic destination; omit it for stderr in an application. +[Source](https://github.com/coisa/error-handler/blob/master/examples/symfony-cli.php). + + +```php + new ServiceFactory($output), + ]); + $services = container($application, new ErrorHandlerServiceProvider(cli: true)); + $errors = $services->get(ErrorHandler::class); +} + +if ('console' === $mode) { + $application = new Application('Synthetic command'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->addCommands([new class ($failure) extends Command { + /** @param Throwable $failure the unchanged synthetic command failure */ + public function __construct(private readonly Throwable $failure) + { + parent::__construct('fail'); + } + + /** + * Fail without letting Console become a second exception renderer. + * + * @param InputInterface $input the synthetic command input + * @param OutputInterface $output the application-owned command output + * + * @return int this synthetic command never returns normally + * + * @throws Throwable the unchanged command failure + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + throw $this->failure; + } + }]); + + // The bootstrap owns this catch and reports once; Console's exception catch is disabled. + try { + $application->run(new ArrayInput(['command' => 'fail']), $output); + } catch (Throwable $caught) { + exampleCheck($failure === $caught, 'The original command failure must reach the bootstrap.'); + $errors->handleThrowable($caught); + } +} else { + $errors->handleThrowable($failure); +} + +$diagnostic = $output->fetch(); +exampleCheck(1 === substr_count($diagnostic, $failure->getMessage()), 'Expected one terminal diagnostic.'); +exampleCheck(str_contains($diagnostic, RuntimeException::class), 'Expected Symfony exception formatting.'); + +// Reporting does not set an exit status; this verification program succeeds intentionally. +echo 'Symfony CLI ' . $mode . ': one terminal diagnostic and explicit capture ownership passed' . PHP_EOL; ``` ## Failure and lifetime boundaries @@ -1460,4 +1663,4 @@ Middleware requires lifecycle and reporting on one scoped object. It reports onc Unregister in reverse order and use distinct nested instances. Failed-reporter suppression applies within a registered lifecycle; independent direct calls remain usable. Arbitrary exit/die, engine fatals, callback side effects and concurrent native callback scopes are not recovered. See [provider](fast-forward.md), [API](api.md), [migration](migration.md) and [testing](testing.md). -The explicit logging, filtering, callable PHP-error and best-effort adapters are retained from delivered 2.x APIs. The default aggregate remains fail-fast; no implicit adapter discovery is added. The JSON/developer-HTML adapters and independent profiles delivered in 2.5.0 are retained with pure renderers after the middleware's report. ErrorHandler accepts matching callables directly while preserving interface precedence, default policies and explicit lifecycle ownership. +The explicit logging, filtering, callable PHP-error and best-effort adapters are retained from delivered 2.x APIs. The default aggregate remains fail-fast; no implicit adapter discovery is added. Concrete JSON/Whoops adapters remain available. The single provider negotiates JSON or public/debug Symfony HTML from each current request; optional Whoops requires an explicit application override. All response factories require the current request, and middleware reports before rendering. ErrorHandler accepts matching callables directly while preserving interface precedence, default policies and explicit lifecycle ownership. diff --git a/docs/fast-forward.md b/docs/fast-forward.md index 61a8581..94d8b1d 100644 --- a/docs/fast-forward.md +++ b/docs/fast-forward.md @@ -81,7 +81,7 @@ $application = new class implements ServiceProviderInterface { } }; -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); /** * @var ErrorHandlerInterface $errors @@ -112,18 +112,20 @@ There is no provider boot hook. The application decides when to register. | `ErrorHandlerInterface` / `ErrorHandler` | The same cached process handler; initially unregistered | | `ThrowableHandlerInterface` | Default aggregate of available callable and PSR-14 reporters | | `DispatchThrowableHandler` / `DispatchErrorEventThrowableHandler` | Existing PSR-14 adapters; require an application dispatcher | -| `ThrowableStreamFactoryInterface` / `ThrowableResponseFactoryInterface` | Pure generic stream and response adapters; require PSR-17 factories | +| `ThrowableStreamFactoryInterface` | Pure generic fallback stream; requires a PSR-17 stream factory | +| `ThrowableResponseFactoryInterface` | Request-aware negotiation among generic, JSON and Symfony HTML responses | +| `HtmlThrowableResponseFactory` / `JsonThrowableResponseFactory` / `WhoopsThrowableResponseFactory` | Pure concrete renderers; only Whoops requires its optional package | +| `PrettyPageHandler` | Lazy cached page, customizable through an application service | | `ErrorHandlerMiddleware` | Cached middleware with a handler separate from the process handler | | `ServiceProvider\Factory\ErrorHandlerFactory` | Reusable callable that creates a fresh handler on each invocation | | `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` | Reusable callable that creates fresh middleware and a fresh handler | -The provider has nine explicit definitions. Dispatch handlers and concrete -generic stream/response adapters are resolved through FastForward's constructor -autowiring. Their interface aliases remain explicit, and application definitions +The single provider declares lifecycle, response negotiation and renderer services. +Dispatch handlers and simple generic stream/response adapters use FastForward +constructor autowiring. Interface aliases remain explicit, and application definitions can override either an interface or its concrete implementation when their -maps are merged as shown under [required overrides](#required-overrides). The three -library factories that remain encode optional-handler defaults, reporter -composition and fresh middleware scopes; autowiring alone does not replace those +maps are merged as shown under [required overrides](#required-overrides). Explicit library factories encode optional-handler defaults, reporter composition, +fresh scopes and lazy renderer selection; autowiring alone does not replace those contracts. The factory-object services use FastForward's `InvokableFactory`. Bind a required custom renderer through `ThrowableStreamFactoryInterface` or @@ -185,11 +187,11 @@ composition below; verify delivery and failures in the actual application graph. The short bootstrap above adds optional reporters and external dependencies. FastForward 1.10 searches separate providers in the supplied order; -they do not merge their factory maps. In -`container(ErrorHandlerServiceProvider::class, $application)`, a duplicate -`ThrowableHandlerInterface` definition in the application is shadowed by the -library's default. Moving the application first gives it priority, but its PSR-11 -construction failure can still fall through to the silent library aggregate. +they do not merge their factory maps. The normal +`container($application, ErrorHandlerServiceProvider::class)` bootstrap gives +application definitions priority. If an application factory throws a PSR-11 +construction failure, resolution can still fall through to the silent library +aggregate. This distinction matters only when failure must prevent construction. When replacing an existing service ID, merge the definitions explicitly, with the application last: @@ -293,13 +295,14 @@ factory. See [failure boundaries](examples.md#failure-and-lifetime-boundaries). Middleware requires one scoped instance implementing both `ErrorHandlerInterface` and `ThrowableHandlerInterface`. It reports downstream failures before invoking -the response factory. The default `ThrowableStreamFactoryInterface` alias now +the response factory with the original Throwable and current request. The fallback +`ThrowableStreamFactoryInterface` alias selects `Http\Message\GenericThrowableStreamFactory`, which uses PSR-17 directly and produces a controlled body without invoking reporters or output buffering. A lifecycle-only custom implementation must add reporting to use the middleware. Reporters keep their void contract and must write HTTP diagnostics privately. -Select the built-in pure JSON/Whoops profile below or implement a custom +Use the request-aware responses below or implement a custom stream/response factory; see the complete [renderer example](examples.md#explicit-json-streams). The retained `ThrowableStreamFactory` captures a separate public renderer only when explicitly selected. Binding the @@ -307,76 +310,98 @@ old concrete capture class no longer changes the default alias. Bind the interface for required custom rendering, and do not inject the diagnostic reporter into a capture adapter after middleware has already reported it. -## JSON profile +## Explicit terminal reporting -`JsonErrorHandlerServiceProvider(bool $debug = false)` declares only two bindings: -the JSON response factory through FastForward's `InvokableFactory`, and the -response interface alias directly to that pure renderer. It does not include the -base provider or reporter. For a required application reporter and PSR-17 -implementations, compose the maps explicitly: +`new ErrorHandlerServiceProvider(cli: true)` appends `SymfonyCliThrowableHandler` +to the default reporter aggregate after its optional reporters. With the flag false, +terminal formatting is not selected automatically. A configured +`ThrowableHandlerInterface` override replaces the aggregate as usual. +The optional `OutputInterface` application service selects a Console destination; +without one the handler writes to stderr. The CLI flag does not enable HTTP debug +or install callbacks. See [terminal integration and Console ownership](cli.md). + +## Request-aware responses + +`ErrorHandlerServiceProvider(bool $debug = false, bool $cli = false)` is the only library provider. +Its response alias selects `NegotiatingThrowableResponseFactory`. The current +request's Accept header chooses from generic `text/plain`, `application/json` or `application/problem+json` +and Symfony `text/html`. Trusted debug enables diagnostics in the same Symfony +renderer. Negotiated responses include Accept in Vary and preserve existing Vary fields. Installed Whoops never changes this default. +Missing Accept or no supported positive match uses the generic fallback. +Debug never forces HTML; JSON-only requests do not resolve Whoops. +See [quality values and fallbacks](http.md#request-negotiation). + +The application supplies its own PSR-17 implementations and private reporter: + +```php +$services = container($application, ErrorHandlerServiceProvider::class); +``` + +For trusted development configuration, read a real boolean before bootstrap: ```php -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), +$settings = config(['error_handler' => ['debug' => true]]); + +$services = container( $application, -)); + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); ``` -Later aggregate definitions win. A trusted debug choice can be read from -FastForward Config before constructing `new JsonErrorHandlerServiceProvider($debug)`. -Pass a real boolean from a strict caller; the provider does not detect environment -or request state. The [complete recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php) shows public -and debug modes, the application map and a fresh middleware scope. JSON is pure; -the scoped middleware handler supplies the one private report. - -Separate providers search first-success sources. JSON before base selects JSON; -base before JSON retains the generic renderer. This is not sufficient to replace -the base's reporter interface: use the aggregate above for required reporting. -Resolve the reusable middleware factory for each independent scope, rather than -expecting repeated `get(ErrorHandlerMiddleware::class)` calls to be fresh. - -## Development HTML profile - -Install optional `filp/whoops:^2.18.7` in the application selecting this profile. -`WhoopsErrorHandlerServiceProvider` has no constructor and declares only three -bindings: `PrettyPageHandler` through `InvokableFactory`, the pure -`WhoopsThrowableResponseFactory` through FastForward's `CallableFactory`, and its -response interface alias. Its lazy page callback resolves the application's -`PrettyPageHandler` service at render time. Discovery and middleware construction -do not create a page or register native callbacks. - -Use one `AggregateServiceProvider` in base, Whoops, application order, as in the -[complete default/configured recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). An optional -package's absence can otherwise fall through between separate providers after a -PSR-11 error. The aggregate selects the intended binding and retains an install -hint through the container's failure chain; it is not a general guarantee that -all concrete factory failures propagate unchanged. - -Customize the page by preparing a `PrettyPageHandler` outside container lookup, -applying trusted Config values and binding it with FastForward's `ServiceFactory`. -The container caches that service; sequential renders reuse its configuration and -replace diagnostic data. Every render creates a fresh, unregistered Whoops run, -response and stream. Distinct middleware scopes still use distinct core handlers. -There is no concurrent rendering isolation or comprehensive redaction promise. - -FastForward may recover a failing concrete page factory by autowiring a default -page even inside an aggregate. A binding returning the wrong page type and a page -whose `handle()` throws are different runtime failures, not successful fallbacks. -The provider follows the container's resolution contract; it does not install a -second resolver, clone pages or suppress PHP warnings. Configure required page -state before lookup when those configuration errors must be observed immediately. - -The manual renderer's callable parameter remains available for explicit application -composition. Both profiles select pure renderers, never -`ReportingThrowableResponseFactory`: middleware has already reported. -See [HTTP boundaries](http.md) for trusted debug policy and diagnostic exposure. +The Config key belongs to this application example; the library reads no +environment variable or configuration key automatically. Avoid casting the +string `"false"` to bool, which enables PHP truthiness. The +[JSON provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-json.php) +shows public/debug modes and `Accept: application/json` with one private report. +The provider also exposes the concrete JSON renderer for explicit application +composition; selecting a concrete renderer directly bypasses negotiation. JSON uses +`SerializerInterface`, supplied by the provider as a Symfony Serializer with +ProblemNormalizer and JsonEncoder. Both accepted JSON media types use Problem +Details; direct JSON rendering returns `application/problem+json`. Override the +serializer service explicitly only with a collaborator supporting that same contract. + +The negotiator is cached, while the request is supplied for each rendering call. +Its lazy renderer map resolves only the selected response service. Use the +middleware factory for fresh scopes; repeated container lookups remain cached. +Reporter and renderer failures retain the [HTTP boundaries](http.md). + +## Development HTML configuration + +Symfony HTML is the default in public and debug modes. To select Whoops instead, +install optional `filp/whoops:^2.18.7` in the application. The [public/default/configured recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php) +uses the same provider with `Accept: text/html` and an explicit application +override of the negotiating HTML candidate in its development modes. Public mode retains Symfony HTML. The default provider never +auto-selects Whoops based on package availability. + +The provider binds `PrettyPageHandler` through FastForward's `InvokableFactory`. +The HTML renderer's lazy callback resolves that service only during selected HTML +rendering. Discovery, JSON-only requests and middleware construction do not create +a page or register native callbacks. The concrete Whoops renderer remains usable +directly, where the application owns development authorization. + +Customize a page before container lookup, apply trusted Config values and bind it +with `ServiceFactory` in the application map. Application-first composition gives +that page priority. The container caches it; sequential renders replace diagnostic +data while retaining configuration. Every render owns a fresh unregistered Whoops +run, response and stream. This is not concurrent-request isolation. + +FastForward can recover a failing concrete page factory by autowiring a default +page. A merged provider fixes map precedence but does not disable that concrete +fallback. Prepare required page configuration before lookup when configuration +failures must be observed immediately. Wrong-type pages and rendering failures +propagate through their actual runtime boundaries; there is no second resolver, +page cloning or warning suppression. + +The manual renderer's callable remains available for explicit composition. +The provider never selects `ReportingThrowableResponseFactory`: middleware already +reported the original. Debug output can expose source, globals and traces; selected +key hiding is not complete redaction. See [diagnostic HTML](http.md#opt-in-development-html-with-whoops). ## Troubleshooting composition | Symptom | Check | | --- | --- | -| A required reporter is silent | Bind `ThrowableHandlerInterface` and merge base, profile if selected, then application maps in one aggregate. A separate application provider can be shadowed or skipped after a construction failure. | +| A required reporter is silent | Bind `ThrowableHandlerInterface` and merge library then application maps in one aggregate. A separate application provider can be shadowed or skipped after a construction failure. | | A request deactivates the process handler | Give middleware a distinct handler. Resolve and invoke its factory for each independent scope; do not pre-register that handler. | | A page override appears to be ignored | Configure the page before lookup and bind the instance with `ServiceFactory`. A failed concrete factory can fall back to an autowired default page. | | Whoops classes are unavailable | Install optional `filp/whoops:^2.18.7` in the consuming application and confirm that the selected autoloader includes it. | diff --git a/docs/http.md b/docs/http.md index 0ec9336..aed1529 100644 --- a/docs/http.md +++ b/docs/http.md @@ -2,20 +2,61 @@ Middleware requires one scoped object implementing `ErrorHandlerInterface` and `ThrowableHandlerInterface`. It registers, calls downstream, reports a caught -Throwable once, invokes a separate response renderer and unregisters in `finally`. +Throwable once, passes that same Throwable and the current ServerRequestInterface +to a separate response renderer, and unregisters in `finally`. A reporter failure propagates without rendering. Successful downstream responses retain their exact identity; PHP warnings do not replace them automatically. Applications supply PSR-17 factories and own HTTP emission and format selection. +All response factories implement +`createResponseFromThrowable(Throwable $throwable, ServerRequestInterface $request): ResponseInterface`. +The request parameter is required, including for renderers that ignore it. +Direct application calls must supply the current request too. + JSON and Whoops adapters were introduced in 2.5.0. Middleware owns reporting; pass the pure renderer directly. Do not wrap it in `ReportingThrowableResponseFactory` after this middleware has already reported. See [migration](migration.md#separate-http-reporting-from-rendering). +## Request negotiation + +`ErrorHandlerServiceProvider(bool $debug = false, bool $cli = false)` supplies one default +`NegotiatingThrowableResponseFactory` for all requests. A cached middleware can +therefore serve an HTML browser request and a JSON API request using each current +request's `Accept` header; no request is cached in a service. + +| Available representation | Selection | +| --- | --- | +| Generic body | `text/plain`, or fallback when Accept is absent or no supported positive match exists | +| JSON Problem Details | `application/json` or `application/problem+json`, with generic details unless trusted debug is enabled | +| Public HTML | `text/html`, without exception details | +| Diagnostic HTML | The same Symfony renderer with trusted debug; optional Whoops requires an explicit override | + +Accepted quality values determine selection. Zero-quality representations are +excluded. Equal wildcard choices prefer generic, then JSON, then HTML. Debug +enables diagnostic HTML/JSON; it never overrides the client's JSON preference. +A JSON-only request never resolves Whoops. Whoops is never selected by default, +whether installed or absent. Applications can explicitly replace the HTML +candidate with Whoops while preserving negotiation. + +The fallback is an error response for the original failure, not a new 406. +Negotiated responses add Accept to Vary while preserving existing fields. +Reporting still occurs once before negotiation. Renderers receive the exact +current request and original Throwable; selecting a representation reports nothing. + +For explicit composition, construct +`NegotiatingThrowableResponseFactory($fallback, $renderers)`. The map keys are +media types and values are lazy `callable(): ThrowableResponseFactoryInterface` +factories. The selected factory alone is invoked. Configure known response +services from trusted application code; client input selects only among that map. +Concrete Symfony HTML, JSON, Whoops and generic factories can still be used directly. + ## Generic default and explicit output capture -The base provider selects `GenericThrowableStreamFactory`, which always returns -`Internal Server Error` without inspecting reporter output. Diagnostic reporters +The single provider selects `NegotiatingThrowableResponseFactory`; see +[request negotiation](#request-negotiation). Its fallback uses +`GenericThrowableStreamFactory`, which always returns `Internal Server Error` +without inspecting reporter output. Diagnostic reporters must use private sinks: echoed diagnostics can still escape the response object and corrupt application emission. `ThrowableResponseFactory` preserves integer codes 400–599 and otherwise uses 500, including SQLSTATE strings. It leaves reason @@ -42,9 +83,14 @@ limitations. See the [capture recipe](https://github.com/coisa/error-handler/blo ## Opt-in JSON responses and explicit reporting -`JsonThrowableResponseFactory($responses, $streams, bool $debug = false)` is a -pure PSR-17 renderer. Public mode returns exactly `{"error":"Request failed"}` -with `Content-Type: application/json`. Integer exception codes 400–599 are +`JsonThrowableResponseFactory($responses, $streams, $serializer, bool $debug = false)` +is a pure PSR-17 renderer using Symfony's `SerializerInterface`. The provider supplies +`Serializer` with `ProblemNormalizer(true)` and `JsonEncoder`; direct composition +supplies the same collaborators explicitly. Its public Problem Details body contains +`type: about:blank`, the PSR-17 reason as `title`, the HTTP `status` and +`detail: Request failed`. A direct call returns `application/problem+json`. +Negotiation also supports `application/json` and retains the requested supported +media type; both have the same Problem Details shape. Integer exception codes 400–599 are preserved; other integers and string codes use 500. Reason phrases come from PSR-17. No report, output capture or emission occurs inside this factory. @@ -54,10 +100,11 @@ Middleware delivers the identical original once before rendering. A reporter failure prevents rendering; renderer and PSR-17 failures propagate after callback restoration without a second report. -The third parameter is an explicit application-controlled boolean. When true, -JSON adds exception class, message, file, line and trace metadata, omitting frame -arguments and objects. It sets `Cache-Control: no-store` and substitutes invalid -UTF-8. Messages, paths and other metadata can still contain secrets. No environment +The fourth parameter is an explicit application-controlled boolean. When true, +`detail` contains the exception message and JSON adds `class` and a Symfony trace. +Frame argument values and objects are excluded; an empty `args` array is harmless. +It sets `Cache-Control: no-store` and substitutes invalid UTF-8. Serialization +failures propagate; JSON rendering never falls back to HTML. Messages, paths and other metadata can still contain secrets. No environment name or request header enables diagnostics. Use trusted configuration and strict typing; casting the string `"false"` yields true in PHP. @@ -65,17 +112,32 @@ typing; casting the string `"false"` yields true in PHP. explicit utility for pipelines that have not already reported. It reports once and then delegates. Reporter failures prevent rendering; collaborator failures propagate unchanged. Selecting it after this middleware would report -twice. The base provider and both response profiles never select it automatically. +twice. The provider never selects it automatically. + +## Public HTML + +`HtmlThrowableResponseFactory($responses, $streams, bool $debug = false)` uses +Symfony's `HtmlErrorRenderer` to create a production error page. With debug false, +the page omits the exception message, source and trace. It returns `text/html; charset=UTF-8`, +uses the same 400–599 status policy and does not report or emit. +The default provider always offers Symfony HTML. Trusted debug enables Symfony's +diagnostic page with a document shell and the renderer's stylesheet; +public mode omits diagnostic details. Whoops presence does not +change either default. +The [complete public/debug HTML recipe](https://github.com/coisa/error-handler/blob/master/examples/http-html.php) +shows explicit reporting followed by a direct request-aware render. The +[manual middleware recipe](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php) +shows public HTML or explicitly selected diagnostic Whoops. ## Opt-in development HTML with Whoops -Install `filp/whoops:^2.18.7` in the application wherever diagnostic HTML is -selected. This minimum includes upstream PHP 8.5 deprecation and native STDIN / +Install `filp/whoops:^2.18.7` in the application wherever the optional Whoops +renderer is explicitly selected. This minimum includes upstream PHP 8.5 deprecation and native STDIN / `php -r` source-inspection fixes. Composer rejects older Whoops versions when selected without making that optional package mandatory. `WhoopsThrowableResponseFactory($responses, $streams, $pageFactory)` takes a callable returning `Whoops\Handler\PrettyPageHandler`, invoked lazily for -each render. The manual signature is unchanged from 2.5.0. Pass this pure renderer +each render. Its constructor signature is unchanged from 2.5.0. Pass this pure renderer directly to middleware; see [manual selection](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php). Each render owns a fresh, unregistered `Whoops\Run`, response and body. Whoops @@ -86,15 +148,20 @@ service for sequential renders: current diagnostic data is replaced while page configuration remains. That page retains its latest diagnostic and does not provide concurrent-request isolation. -The [Whoops provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php) composes independent -base, Whoops and application maps in one aggregate. The profile has no constructor: -override the `PrettyPageHandler` service through an application `ServiceFactory`. +The [Whoops provider recipe](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php) +uses `container($application, new ErrorHandlerServiceProvider(debug: true))`. +The same provider supplies JSON, generic responses and Symfony HTML; the +recipe explicitly overrides its negotiating HTML candidate with optional Whoops. +Override the `PrettyPageHandler` service through an application `ServiceFactory`. The page is lazy and cached; middleware scopes and runs are fresh. See -[provider configuration and failure limits](fast-forward.md#development-html-profile). +[provider configuration and failure limits](fast-forward.md#development-html-configuration). Choose diagnostic HTML only through trusted development configuration. Default Whoops pages expose globals, source and traces; selected key hiding is not complete -redaction. No Accept negotiation or automatic debug activation is supplied. +redaction. The concrete Whoops renderer does not negotiate; the +application can explicitly select it as the negotiating factory's HTML candidate. +The default provider uses Symfony HTML in both public and debug modes. +No request value activates debug. ## Failure and lifetime boundaries diff --git a/docs/index.html b/docs/index.html index 4f4b1e3..779e9ab 100644 --- a/docs/index.html +++ b/docs/index.html @@ -102,7 +102,7 @@ Conditional reporting Best-effort reporting Container integration - JSON and HTML profiles + Request-aware responses HTTP composition Executable recipes Guides and verification @@ -121,7 +121,7 @@

CoiSA Error Handler

Installation and setup

composer require coisa/error-handler

Use Composer 2 to install the library on the actual PHP runtime. The declared PHP constraint is ^8.3; the CI matrix targets PHP 8.3–8.5. FastForward Container ^1.10 and the Interop provider contract are runtime dependencies.

-

Applications supply loggers, dispatchers, PSR-17 implementations and their emitter as needed. The development checkout supplies Nyholm and Phly for its synthetic recipes; optional filp/whoops:^2.18.7 is needed only where development HTML is selected.

+

Applications supply loggers, dispatchers, PSR-17 implementations and their emitter as needed. The development checkout supplies Nyholm and FastForward EventDispatcher for its synthetic recipes; optional filp/whoops:^2.18.7 is needed only where development HTML is selected.

Read example setup for the selected autoloader and verifier dependencies. Resolve with the actual PHP runtime and retain application lockfiles. Consult the recorded test evidence; a permitted constraint is not proof that every combination is clean.

The cookbook below runs from a matching source checkout: Composer archives omit the top-level examples/ directory. Keep its bootstrap and assertion helper beside the recipes. Installed applications can instead run the bundled application programs with their explicit Composer autoloader.

@@ -236,66 +236,82 @@

Use the required Interop provider

ErrorHandlerServiceProvider implements the Interop provider contract and reuses FastForward factories/autowiring. This bootstrap adds application dependencies and optional reporters:

Base provider and optional reporter bootstrap from examples/fast-forward.php · imports and application setup are in the complete source
-
$services = container(ErrorHandlerServiceProvider::class, $application);
+
$services = container($application, ErrorHandlerServiceProvider::class);

FastForward caches services. The base reporting alias selects a fail-fast aggregate, silent when no optional reporter is available. Explicit Log, Filtering, Callable PHP-error and BestEffort adapters are not discovered merely by registering their concrete classes.

-

For a required reporter, renderer or other replacement, merge library maps with application definitions last in one AggregateServiceProvider, as below. Separate providers use first-success resolution and can fall back on PSR construction errors; their short bootstrap is not a required-override guarantee.

+

For a required reporter, renderer or other replacement, merge library maps with application definitions last in one AggregateServiceProvider, only when such a failure must prevent construction. Separate providers use first-success resolution and can fall back on PSR construction errors; their short bootstrap is not a required-override guarantee.

Legacy ErrorHandlerContainer, ConfigProvider, Laminas discovery and old container factories are removed. Follow the provider guide and migration map. Core handlers remain independently constructible.

-

Select a pure response profile

-

Each representation provider returns only its own map; the application composes it with the base provider and required private reporter/PSR-17 bindings. JSON declares two entries: its configured pure renderer and response-interface alias.

+

Negotiate responses with one provider

+

ErrorHandlerServiceProvider(bool $debug = false) supplies lifecycle services and a pure request-aware renderer. Each current request selects from plain text, JSON or Symfony HTML through its Accept header. Missing or unsupported preferences use the generic body. Equal wildcard preferences favor plain text, then JSON, then HTML; zero-quality choices are excluded.

-
Required reporter and public JSON selection from examples/provider-json.php · imports and application setup are in the complete source
-
    $services = container(new AggregateServiceProvider(
-        new ErrorHandlerServiceProvider(),
-        new JsonErrorHandlerServiceProvider(),
-        $application,
-    ));
+
Trusted configuration and application-first composition from examples/provider-json.php · imports and application setup are in the complete source
+
$services = container(
+    $application,
+    new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)),
+);
-

The default JSON profile is public. Its optional constructor bool is selected from trusted configuration before container construction. Pass a real boolean; casting the string "false" enables PHP truthiness. The major middleware supplies the private report before pure rendering.

+

The Config key belongs to this application example. Pass a real boolean; casting the string "false" enables PHP truthiness. Debug permits diagnostic JSON and Symfony HTML, but does not force HTML. Request headers never activate debug. Negotiated responses merge Accept into Vary.

+

Symfony supplies both public and diagnostic HTML by default. Public mode omits the original message, source and trace. Debug exposes diagnostics and adds Cache-Control: no-store. JSON-only requests do not construct an HTML renderer or a Whoops page.

-
Explicit development HTML selection from examples/provider-whoops.php · imports and application setup are in the complete source
-
$services = container(new AggregateServiceProvider(
-    new ErrorHandlerServiceProvider(),
-    new WhoopsErrorHandlerServiceProvider(),
-    $application,
-));
+
Optional application-selected Whoops integration from examples/provider-whoops.php · the full source supplies an explicit negotiating-map override
+
$services = container($application, new ErrorHandlerServiceProvider(debug: 'public' !== $mode));
-

The Whoops profile has no constructor and only three entries: a lazy PrettyPageHandler via InvokableFactory, a pure renderer via lazy CallableFactory, and its response alias. Install optional filp/whoops:^2.18.7 where HTML is selected. Configure a page before container lookup and bind it through ServiceFactory when required.

-

A configured page is cached and can be reused sequentially: configuration stays, current diagnostic data replaces the previous data, and the page retains its latest diagnostic. Each render still uses a fresh unregistered Run, response and body. Concrete page-factory failures can trigger FastForward autowiring fallback even inside an aggregate; binding order is not universal failure propagation or concurrent isolation.

+

Installing optional filp/whoops:^2.18.7 alone never changes the default. This recipe's development modes explicitly replace the HTML candidate with Whoops while retaining JSON and generic candidates. Public mode keeps Symfony. The provider exposes PrettyPageHandler as a lazy service; configure a page first and bind it through ServiceFactory when desired.

+

A configured Whoops page is cached sequentially: configuration stays and current diagnostic data replaces prior data. Each render uses a fresh unregistered Run, response and body. Concrete page-factory failures can trigger FastForward autowiring fallback; map order does not provide universal failure propagation or concurrent isolation.

Fresh HTTP scope from examples/provider-json.php · imports and application setup are in the complete source
$middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class);
 
 $middleware = $middlewareFactory($services);
-

Invoke this factory for distinct handlers and middleware in independent nested scopes. Repeated middleware get() calls remain cached; the process handler stays separate. Lookup and construction install no callbacks. Read provider maps, required overrides and failure limits.

+

Invoke this factory for distinct handlers and middleware in independent nested scopes. Repeated middleware get() calls remain cached; the process handler stays separate. Lookup and construction install no callbacks. Read provider definitions, required overrides and failure limits.

+
+ +
+

Explicit Symfony terminal diagnostics

+

SymfonyCliThrowableHandler() uses Symfony's CliErrorRenderer and writes to stderr. Inject a Console OutputInterface to reuse an application destination; ConsoleOutput uses its error channel and writes raw text. Construction installs no callbacks and reporting chooses no exit status or SAPI. The provider enables it only with cli: true.

+
+
Explicit terminal destination from examples/symfony-cli.php
+
$terminal = new SymfonyCliThrowableHandler($output);
+$errors = new ErrorHandler($terminal);
+
+

Use either Console's exception renderer or an outer Error Handler catch. The complete recipe disables Console's exception catch and reports once. See terminal reporting, private diagnostics and exit-status ownership.

Report once, then render explicitly

-

Middleware requires one scoped object implementing both lifecycle and throwable reporting. It registers, calls downstream, reports a caught throwable, invokes a pure response renderer and unregisters in finally. Reporter failure prevents rendering; renderer failures propagate after callback restoration. Successful downstream responses pass through unchanged.

-

The base provider's GenericThrowableStreamFactory returns Internal Server Error independently of reporter output. Integer error codes 400–599 are preserved, other integers/string codes use 500, and PSR-17 supplies reason phrases. Keep diagnostics private: an echoed reporter can still corrupt emission outside the returned response.

+

Middleware requires one scoped object implementing both lifecycle and throwable reporting. It registers, calls downstream, reports a caught throwable, passes the same throwable and current ServerRequestInterface to a pure response renderer and unregisters in finally. Reporter failure prevents rendering; renderer failures propagate after callback restoration. Successful downstream responses pass through unchanged.

+

The default provider negotiates from the current request. Its fallback GenericThrowableStreamFactory returns Internal Server Error independently of reporter output. Integer error codes 400–599 are preserved, other integers/string codes use 500, and PSR-17 supplies reason phrases. Keep diagnostics private: an echoed reporter can still corrupt emission outside the returned response.

If an explicit public renderer closes capture and returns, the capture factory uses the generic body without reading or closing caller-owned buffers or invoking their output callbacks. Missing capture at native depth zero also uses that body. The factory cannot undo the renderer's own writes, flushes or buffer changes.

Pure JSON composition from examples/http-json.php · imports and application setup are in the complete source
$errors = new ErrorHandler($reporter);
 
-$json = new JsonThrowableResponseFactory($messages, $messages, $debug);
+$serializer = new Serializer([new ProblemNormalizer(true)], [new JsonEncoder()]);
+
+$json = new JsonThrowableResponseFactory($messages, $messages, $serializer, $debug);
 
 $middleware = new ErrorHandlerMiddleware($errors, $json);
-

The retained pure JSON factory returns application/json with {"error":"Request failed"}. Explicit debug adds class/message/file/line and trace metadata without arguments/objects, substitutes invalid UTF-8 and sets Cache-Control: no-store. Messages and paths can still expose secrets. No client header or environment name enables debug automatically.

+

The pure JSON factory uses Symfony Serializer and returns application/problem+json with Problem Details: type, PSR-17 title, HTTP status and generic detail. Negotiation also supports application/json with the same shape. Explicit debug adds original-message detail, class and trace without argument values or objects, substitutes invalid UTF-8 and sets Cache-Control: no-store. Messages and paths can still expose secrets. No client header or environment name enables debug automatically.

Pure renderer after trusted selection from examples/http-whoops.php · imports and application setup are in the complete source
$errors = new ErrorHandler($reporter);
 
 $middleware = new ErrorHandlerMiddleware($errors, $renderer);
-

The trusted application choice made earlier in this recipe selects JSON or optional WhoopsThrowableResponseFactory. Its manual third argument remains callable(): PrettyPageHandler. It returns text/html; charset=UTF-8 and no-store without emission, native headers, registration or controlled quit. Whoops source/globals/traces are development disclosures; selected key hiding is not comprehensive redaction.

-

ReportingThrowableResponseFactory remains an explicit utility for other pipelines that still need reporting. Neither the base provider nor these profiles select it after the middleware has already reported. Request/Accept negotiation is application policy; these providers select explicit renderers.

+

The trusted application choice made earlier in this recipe selects public Symfony HTML or optional WhoopsThrowableResponseFactory. Its manual third argument remains callable(): PrettyPageHandler. It returns text/html; charset=UTF-8 and no-store without emission, native headers, registration or controlled quit. Whoops source/globals/traces are development disclosures; selected key hiding is not comprehensive redaction.

+

ReportingThrowableResponseFactory remains an explicit utility for pipelines that have not already reported. The provider never selects it after middleware reporting. Every response factory requires createResponseFromThrowable(Throwable, ServerRequestInterface), including fixed renderers. Middleware forwards the current request unchanged; direct callers must provide it.

+

Public and diagnostic Symfony HTML

+
+
Direct HTML rendering after one explicit private report from examples/http-html.php
+
$errors->handleThrowable($failure);
+$response = $renderer->createResponseFromThrowable($failure, $request);
+
+

HtmlThrowableResponseFactory($responses, $streams, bool $debug = false) uses Symfony's public rendering API. The request does not authorize diagnostics. Public mode uses a generic page; explicit debug provides a styled diagnostic document. It neither reports nor emits the response.

Use output capture only for a separate public renderer

Explicit public output capture from examples/output-capture.php · imports and application setup are in the complete source
@@ -325,9 +341,10 @@

Run the complete recipes

  • http-middleware.phpOne private report, generic response and restored process callbacks.
  • http-rendering.phpAn application-owned pure JSON stream, independent of diagnostics.
  • output-capture.phpA separate deliberately public echo-based renderer.
  • +
  • http-html.phpPublic/debug Symfony HTML with the required current request and one explicit report.
  • http-json.phpPublic/debug JSON and one private middleware report.
  • -
  • http-whoops.phpTrusted development HTML selection and a generic public JSON route.
  • -
  • provider-json.phpIndependent JSON map, required application bindings and fresh scopes.
  • +
  • http-whoops.phpPublic Symfony HTML and explicitly selected development Whoops.
  • +
  • provider-json.phpSingle provider, current-request JSON negotiation and fresh scopes.
  • provider-whoops.phpLazy default/configured pages, sequential reuse and fresh HTTP scopes.
  • Run php examples/verify.php from the configured source checkout. FastForward is a runtime dependency; there is no optional FastForward verifier flag. The development graph supplies Whoops for its HTML modes. The bounded runner checks the selected modes' exact exit/stdout/stderr; read its operating guide before native failure experiments.

    diff --git a/docs/migration.md b/docs/migration.md index 041c5da..8b3e37f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -4,11 +4,10 @@ This guide describes the API and behavior changes when upgrading from 2.x to 3.x Use the [Interop service provider](fast-forward.md) for container integration without changing the existing package or namespace. The short bootstrap is -`container(ErrorHandlerServiceProvider::class, $application)` for external -dependencies and optional reporters. To override existing library service IDs, -merge definitions with `AggregateServiceProvider`, library first and application -last, so a required reporter is not shadowed or replaced after construction -fails. Registration remains explicit; +`container($application, ErrorHandlerServiceProvider::class)` for external +dependencies and normal application overrides. When a required interface's +construction failure must prevent fallback to the library default, merge +definitions with `AggregateServiceProvider`, library first and application last. Registration remains explicit; use factory services for fresh/nested scopes because container lookups are cached. The Interop provider is the single container integration entrypoint. The Laminas `ConfigProvider` adapter and standalone `ErrorHandlerContainer` @@ -18,11 +17,12 @@ have been removed in favor of FastForward Container with this provider. 2. Expect exit status **1** after a globally uncaught `Throwable` is reported successfully. Version 2 could exit with status 0. A reporter that explicitly terminates the process still controls its own exit code. Direct `handleThrowable()` calls do not exit. 3. Pair `register()` and `unregister()` in reverse order. A repeated `register()` on the same instance is idempotent, not reference counted. Out-of-order unregistration now throws `LogicException` instead of silently removing someone else's callback. Use separate instances for nested middleware and do not pre-register the middleware's own instance. 4. A reporter must not call its own `ErrorHandler` recursively. After a reporter throws, that instance stops retrying it until it is unregistered and registered again. The failure propagates to the caller or PHP; it is not silently swallowed. Only the active registration receives shutdown notifications, preventing duplicates across nested handlers. -5. Review custom HTTP rendering. The provider defaults to a pure generic renderer, independent of reporter output. In the explicit output-capture adapter, empty output now produces `Internal Server Error` instead of a stringified exception and stack trace; the string `0` is preserved. Exception messages no longer become HTTP reason phrases. Codes 400–599 map to their matching status; other integer codes and string SQLSTATE codes map to 500. +5. Review custom HTTP rendering. The provider defaults to request-aware pure rendering: JSON or Symfony HTML + when accepted, and a generic fallback otherwise, independent of reporter output. In the explicit output-capture adapter, empty output now produces `Internal Server Error` instead of a stringified exception and stack trace; the string `0` is preserved. Exception messages no longer become HTTP reason phrases. Codes 400–599 map to their matching status; other integer codes and string SQLSTATE codes map to 500. 6. Compatible 2.x maintenance already removed Docker/Compose and consolidated bounded process fixtures under PHPUnit. Keep those Composer/PHPUnit checks; CI supplies the PHP 8.3–8.5 development matrix. No application runtime image is supplied. 7. Replace `new Container\ErrorHandlerContainer($parent)` with - `FastForward\Container\container(ErrorHandlerServiceProvider::class, $parent)` + `FastForward\Container\container($parent, ErrorHandlerServiceProvider::class)` when the parent only supplies external PSR dependencies. For application overrides, use the merged provider composition above. FastForward is now a runtime dependency, including its transitive packages; consumers resolve them @@ -45,9 +45,9 @@ have been removed in favor of FastForward Container with this provider. | `Container\Factory\ThrowableHandlerAggregateFactory` | `ServiceProvider\Factory\ThrowableHandlerAggregateFactory` | Update factory imports and service identifiers to the new namespaces; no aliases -for the old names are provided. All three remaining factories live under -`ServiceProvider\Factory` because they encode lifecycle and reporter-composition -behavior. Use the Interop provider with constructor autowiring; a framework-native +for the old names are provided. The explicit library factories live under +`ServiceProvider\Factory` because they encode lifecycle, reporter composition and lazy +response selection. Use the Interop provider with constructor autowiring; a framework-native configuration adapter is no longer shipped. `laminas/laminas-servicemanager` is no longer a development dependency; unrelated transitive Laminas packages may still be required by FastForward. @@ -87,8 +87,9 @@ The built-in ErrorHandler already satisfies this. Custom lifecycle-only implemen must also implement diagnostic delivery. Middleware reports a downstream Throwable once, then invokes the response factory; reporter failures propagate without rendering. -The provider's stream alias now selects GenericThrowableStreamFactory. Reporter -output is not implicitly captured as the response body. HTTP reporters must use +The provider's response alias selects NegotiatingThrowableResponseFactory and +its generic fallback uses GenericThrowableStreamFactory. Reporter output is not +implicitly captured as the response body. HTTP reporters must use private sinks. Implement ThrowableStreamFactoryInterface to return JSON or another public stream directly; the cookbook includes a complete JSON example without echo. ThrowableHandlerInterface remains void, preserving CLI, event and aggregate roles. @@ -102,10 +103,39 @@ The JSON and optional Whoops response adapters published in 2.5.0 remain availab Pass their pure renderers directly to middleware. Remove the explicit `ReportingThrowableResponseFactory` wrapper used by 2.x middleware, which did not report independently. The wrapper remains available for other explicit pipelines. -`JsonErrorHandlerServiceProvider` and `WhoopsErrorHandlerServiceProvider` now alias -`ThrowableResponseFactoryInterface` to their pure concrete renderers. Each profile -returns only its own map; compose base, selected profile and application in one -`AggregateServiceProvider` for required reporters/overrides. JSON retains explicit -`bool $debug = false`; the Whoops provider has no constructor and uses the -application-overridable `PrettyPageHandler` service. A configured page is reused -sequentially; middleware scopes, Whoops runs and response bodies remain distinct. +The separate `JsonErrorHandlerServiceProvider` and `WhoopsErrorHandlerServiceProvider` +are removed. Use the single `ErrorHandlerServiceProvider(bool $debug = false)` +with application-first composition: +`container($application, ErrorHandlerServiceProvider::class)`. To enable trusted +development diagnostics, pass `new ErrorHandlerServiceProvider(debug: true)`. +The provider negotiates Accept for every current request; debug never forces HTML. +Symfony renders public/debug HTML by default. Optional Whoops needs an explicit +application override, and JSON-only requests never construct it. Missing/unsupported Accept uses the +generic fallback. JSON and Whoops concrete renderers remain public. Configure +Whoops through the application-overridable `PrettyPageHandler` service. + +Every `ThrowableResponseFactoryInterface` implementation now requires +`createResponseFromThrowable(Throwable $throwable, ServerRequestInterface $request)`. +Update custom renderers and every direct call to pass the current request. +Middleware forwards it automatically. Stream-factory signatures are unchanged. +A configured page is reused sequentially; middleware scopes, Whoops runs and +response bodies remain distinct. Required overrides may still use one merged +`AggregateServiceProvider` to prevent fallback to a library interface binding. + +## JSON Problem Details and explicit terminal formatting + +Construct direct JSON renderers with the required third `SerializerInterface` +argument and the debug bool fourth. The provider supplies a Symfony Serializer +with ProblemNormalizer and JsonEncoder. JSON bodies use Problem Details with +`type`, `title`, `status` and generic public `detail`, replacing the 2.x `error` +field and nested debug `exception` object. Debug places the original message in +`detail` and adds top-level class/trace metadata without argument values or objects. +Update clients and assertions accordingly. Direct JSON returns +`application/problem+json`; request negotiation also supports `application/json` +with the same payload. See [HTTP details](http.md#opt-in-json-responses-and-explicit-reporting). + +The optional Symfony terminal adapter is explicitly selected; normal providers +do not enable it by SAPI detection. With `cli: true`, the default aggregate adds +it after optional reporters. Keep one exception-rendering owner for Console +applications and choose the exit status in the application bootstrap. See +[terminal diagnostics](cli.md). diff --git a/docs/testing.md b/docs/testing.md index d042320..46f67ec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -88,11 +88,12 @@ FastForward may normalize construction failures and retry autowiring: failed required construction is asserted without promising one attempt or original cause. `php examples/verify.php` checks every complete recipe and documented mode, -including the adapters and JSON/Whoops profiles delivered in 2.x. The existing Symfony Process verifier uses +including retained 2.x adapters and the single provider's request negotiation, +Symfony HTML, JSON and explicit optional Whoops composition. The existing Symfony Process verifier uses five-second/64 MiB children and aborts above 16 KiB of observed output. It clears inherited environment variables except the selected autoloader/fixture marker. `php tests/Documentation/site.php` checks local source links, anchors and all -seventeen complete cookbook recipes against executable source, omitting only the +nineteen complete cookbook recipes against executable source, omitting only the leading license header. Duplicate, changed or missing recipes fail; a new executable recipe needs its own complete entry. Bootstrap, assertion helper and verifier are setup tools, not recipes. The site retains its source-parity checks. diff --git a/examples/README.md b/examples/README.md index 22192a2..4208858 100644 --- a/examples/README.md +++ b/examples/README.md @@ -37,7 +37,7 @@ and run commands from that checkout root. The source checkout supplies application implementations through development dependencies. In a disposable application, add -`nyholm/psr7:^1.8.2` and `phly/phly-event-dispatcher:^1.6` for HTTP/event recipes. +`nyholm/psr7:^1.8.2` and `fast-forward/event-dispatcher:^1.0` for HTTP/event recipes. Add optional `filp/whoops:^2.18.7` wherever the development HTML recipes are used. FastForward Container and PSR interfaces resolve from the library's runtime graph. No framework-specific container is required. Keep application locks/caches outside @@ -55,7 +55,7 @@ ERROR_HANDLER_AUTOLOAD=/absolute/application/vendor/autoload.php php examples/ve Without that variable, run `php examples/verify.php` from the configured checkout. It executes every recipe and documented mode with exact stdout/stderr and exit expectations, including the deliberate CLI exit 1. See [verifier operation](verify.md). -The [cookbook](../docs/examples.md) contains all seventeen complete sources, including +The [cookbook](../docs/examples.md) contains all nineteen complete sources, including imports and every executable branch, with only the leading license header omitted. Its exact source parity and recipe inventory are checked alongside the [site](../docs/index.html#examples). The four explicit logging/filtering/PHP-error/ @@ -65,26 +65,48 @@ policy and active-scope shutdown ownership. The recipes do not start a server, emit HTTP to clients or deliver real logs. Applications own transports, private diagnostics and emission. +## Symfony terminal diagnostics + +With optional `symfony/console` in the application graph: + +```sh +php examples/symfony-cli.php direct +php examples/symfony-cli.php provider +php examples/symfony-cli.php console +``` + +Each mode prints `Symfony CLI : one terminal diagnostic and explicit capture ownership passed`. +The recipe observes a BufferedOutput; ordinary no-argument construction writes to +stderr without Console. Console mode disables Console's catch and reports in one +bootstrap catch. Provider mode explicitly uses `cli: true`; HTTP debug is independent. +See [terminal reporting](../docs/cli.md) for private-data and exit-status boundaries. + ## JSON and development HTML ```sh php examples/http-json.php public php examples/http-json.php debug +php examples/http-html.php public +php examples/http-html.php debug php examples/http-whoops.php public php examples/http-whoops.php development php examples/provider-json.php public php examples/provider-json.php debug +php examples/provider-whoops.php public php examples/provider-whoops.php default php examples/provider-whoops.php configured ``` -The two manual recipes pass a pure renderer directly to middleware. The provider -recipes compose independent base, selected profile and application maps in one -aggregate for a required private reporter. No renderer reports the original again. -JSON accepts an explicit debug bool; Whoops page customization is an application -service override. A cached configured page is reused sequentially, with fresh -Whoops runs, HTTP bodies and middleware scopes. No recipe emits a response or -enables diagnostics from client input. See [HTTP boundaries](../docs/http.md). +Manual recipes use pure renderers after one private report. The HTML recipe +shows the required request argument in a direct rendering call; middleware +forwards it automatically in the other recipes. Symfony renders public/debug +HTML by default. The provider recipes use one ErrorHandlerServiceProvider with +the application first and an explicit Accept header. JSON accepts a trusted debug +bool. The Whoops recipe's development modes explicitly override only the +negotiating HTML candidate; public mode retains Symfony. Installation alone never +selects Whoops. Its configured page is cached sequentially, while runs, bodies and +middleware scopes remain fresh. No recipe emits a response or enables debug from +client input. See [HTTP boundaries](../docs/http.md). Explicit public renderers must balance their own output buffers. If a renderer closes capture and returns, the stream factory uses the generic body while diff --git a/examples/custom-handlers.php b/examples/custom-handlers.php index 5c1d91f..291fd11 100644 --- a/examples/custom-handlers.php +++ b/examples/custom-handlers.php @@ -70,7 +70,7 @@ static function (Throwable $error): void { ShutdownHandlerInterface::class => new ServiceFactory($shutdown), ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); $errors = $services->get(ErrorHandlerInterface::class); diff --git a/examples/event-dispatcher.php b/examples/event-dispatcher.php index 95ea92b..df8495f 100644 --- a/examples/event-dispatcher.php +++ b/examples/event-dispatcher.php @@ -22,10 +22,9 @@ use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ErrorEventListenerProvider; use CoiSA\ErrorHandler\EventDispatcher\ListenerProvider\ThrowableListenerProvider; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\ArrayServiceProvider; -use Phly\EventDispatcher\EventDispatcher; -use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; -use Psr\EventDispatcher\EventDispatcherInterface; +use FastForward\Config\ArrayConfig; +use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; +use Psr\EventDispatcher\ListenerProviderInterface; use Psr\Log\AbstractLogger; use Psr\Log\LogLevel; @@ -68,13 +67,11 @@ public function log($level, $message, array $context = []): void ); $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL)); -$dispatcher = new EventDispatcher(new ListenerProviderAggregate($throwables, $events)); - -$application = new ArrayServiceProvider([ - EventDispatcherInterface::class => static fn(): EventDispatcherInterface => $dispatcher, +$configuration = new ArrayConfig([ + ListenerProviderInterface::class => [$throwables, $events], ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($configuration, EventDispatcherServiceProvider::class, ErrorHandlerServiceProvider::class); $errors = $services->get(ErrorHandlerInterface::class); diff --git a/examples/fast-forward.php b/examples/fast-forward.php index ffaaa0a..d096bd4 100644 --- a/examples/fast-forward.php +++ b/examples/fast-forward.php @@ -56,7 +56,7 @@ public function getExtensions(): array } }; -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); /** * @var ErrorHandlerInterface $errors diff --git a/examples/http-html.php b/examples/http-html.php new file mode 100644 index 0000000..d6a6991 --- /dev/null +++ b/examples/http-html.php @@ -0,0 +1,64 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use Nyholm\Psr7\Factory\Psr17Factory; + +require __DIR__ . '/bootstrap.php'; + +$mode = $argv[1] ?? 'public'; +if (!in_array($mode, ['public', 'debug'], true)) { + throw new InvalidArgumentException('Choose public or debug mode.'); +} + +// This boolean belongs to trusted application configuration, never the request. +$debug = 'debug' === $mode; +$reports = []; +$errors = new ErrorHandler( + /** @param Throwable $error the unchanged original diagnostic delivered privately */ + static function (Throwable $error) use (&$reports): void { + $reports[] = $error; // Replace with the application's private diagnostic transport. + }, +); + +$messages = new Psr17Factory(); +$failure = new RuntimeException('Private synthetic HTML diagnostic', 503); +$request = $messages->createServerRequest('GET', 'http://example.test/html'); +$renderer = new HtmlThrowableResponseFactory($messages, $messages, $debug); + +// An explicit pipeline owns reporting; the renderer only builds a response. +$errors->handleThrowable($failure); +$response = $renderer->createResponseFromThrowable($failure, $request); + +if ([$failure] !== $reports || 503 !== $response->getStatusCode()) { + throw new RuntimeException('Expected one original report and the HTTP error status.'); +} +if ( + 'text/html; charset=UTF-8' !== $response->getHeaderLine('Content-Type') + || ($debug && 'no-store' !== $response->getHeaderLine('Cache-Control')) +) { + throw new RuntimeException('Expected HTML and a no-store policy for debug.'); +} +if ($debug !== str_contains((string) $response->getBody(), $failure->getMessage())) { + throw new RuntimeException('Only explicit debug may disclose the diagnostic.'); +} +if ($failure->getMessage() === $response->getReasonPhrase()) { + throw new RuntimeException('Diagnostic text must not become the reason phrase.'); +} + +// Applications own emission; this fixture prints no diagnostic HTML. +echo 'HTTP HTML ' . $mode . ': Symfony rendering and one original report passed' . PHP_EOL; diff --git a/examples/http-json.php b/examples/http-json.php index 1c8c0f1..6c5fffa 100644 --- a/examples/http-json.php +++ b/examples/http-json.php @@ -19,6 +19,9 @@ use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Serializer; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -51,7 +54,9 @@ static function (Throwable $error) use (&$reports): void { $errors = new ErrorHandler($reporter); -$json = new JsonThrowableResponseFactory($messages, $messages, $debug); +$serializer = new Serializer([new ProblemNormalizer(true)], [new JsonEncoder()]); + +$json = new JsonThrowableResponseFactory($messages, $messages, $serializer, $debug); $middleware = new ErrorHandlerMiddleware($errors, $json); @@ -89,24 +94,28 @@ public function handle(ServerRequestInterface $request): ResponseInterface exampleCheck([$original] === $reports, 'Expected exactly one private report of the original event.'); exampleCheck(503 === $response->getStatusCode(), 'The integer HTTP error code was not preserved.'); -exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected a JSON representation.'); +exampleCheck( + 'application/problem+json' === $response->getHeaderLine('Content-Type'), + 'Expected a JSON Problem Details representation.', +); +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + if ($debug) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - exampleCheck('Request failed' === $payload['error'], 'The public error label changed.'); exampleCheck( - $original->getMessage() === $payload['exception']['message'] && [] !== $payload['exception']['trace'], + $original->getMessage() === $payload['detail'] && [] !== $payload['trace'], 'Explicit development metadata is missing.', ); - foreach ($payload['exception']['trace'] as $frame) { + foreach ($payload['trace'] as $frame) { exampleCheck( - !array_key_exists('args', $frame) && !array_key_exists('object', $frame), + [] === ($frame['args'] ?? []) && !array_key_exists('object', $frame), 'Private trace arguments or objects must not be serialized.', ); } exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Debug details must not be cached.'); } else { exampleCheck( - '{"error":"Request failed"}' === (string) $response->getBody(), + $publicBody === (string) $response->getBody(), 'The generic JSON body must not contain the private diagnostic.', ); } diff --git a/examples/http-middleware.php b/examples/http-middleware.php index 41c93da..ead2cd3 100644 --- a/examples/http-middleware.php +++ b/examples/http-middleware.php @@ -44,7 +44,7 @@ StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17, ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); $process = $services->get(ErrorHandlerInterface::class); $middleware = $services->get(ErrorHandlerMiddleware::class); diff --git a/examples/http-whoops.php b/examples/http-whoops.php index b768d90..8a60aa5 100644 --- a/examples/http-whoops.php +++ b/examples/http-whoops.php @@ -16,7 +16,7 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; -use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; @@ -45,7 +45,7 @@ static function (Throwable $error) use (&$reports): void { }, ); -$renderer = new JsonThrowableResponseFactory($messages, $messages); +$renderer = new HtmlThrowableResponseFactory($messages, $messages); if ($debug) { $renderer = new WhoopsThrowableResponseFactory( @@ -112,8 +112,11 @@ public function handle(ServerRequestInterface $request): ResponseInterface ); } else { exampleCheck(0 === $pageCalls, 'Public mode must not construct a Whoops page.'); - exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected public JSON.'); - exampleCheck('{"error":"Request failed"}' === (string) $response->getBody(), 'Expected the generic public body.'); + exampleCheck('text/html; charset=UTF-8' === $response->getHeaderLine('Content-Type'), 'Expected public HTML.'); + exampleCheck( + !str_contains((string) $response->getBody(), 'synthetic-development-detail'), + 'Public HTML must not disclose the diagnostic.', + ); } // No response is emitted. Hiding selected keys is not complete redaction or production authorization. diff --git a/examples/provider-json.php b/examples/provider-json.php index e4b3efa..6ca3c4a 100644 --- a/examples/provider-json.php +++ b/examples/provider-json.php @@ -18,10 +18,8 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -43,7 +41,7 @@ exampleCheck(in_array($mode, ['public', 'debug'], true), 'Choose the public or debug example mode.'); // This CLI choice stands in for trusted application configuration, never request headers. -$settings = config(['error_handler' => ['json' => ['debug' => 'debug' === $mode]]]); +$settings = config(['error_handler' => ['debug' => 'debug' === $mode]]); $messages = new Psr17Factory(); $original = new RuntimeException('Private synthetic provider failure', 503); $reports = []; @@ -64,20 +62,11 @@ static function (Throwable $event) use (&$reports): void { ThrowableHandlerInterface::class => new ServiceFactory($reporter), ]); -// Merge the required reporter and renderer after library defaults in one provider. -if ('debug' === $mode) { - $services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider($settings->get('error_handler.json.debug', false)), - $application, - )); -} else { - $services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), - $application, - )); -} +// The application is searched first; one library provider supplies negotiation and lifecycle. +$services = container( + $application, + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); // Cache the factory, then invoke it for a distinct handler and wrapper for each HTTP scope. $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); @@ -113,22 +102,28 @@ public function handle(ServerRequestInterface $request): ResponseInterface } }; -$response = $middleware->process($messages->createServerRequest('GET', '/provider-json'), $next); +$request = $messages->createServerRequest('GET', '/provider-json') + ->withHeader('Accept', 'application/json') +; + +$response = $middleware->process($request, $next); -exampleCheck([$original] === $reports, 'The profile must report the exact original once.'); +exampleCheck([$original] === $reports, 'Middleware must report the exact original once.'); exampleCheck(503 === $response->getStatusCode(), 'Expected the native HTTP error code.'); exampleCheck('application/json' === $response->getHeaderLine('Content-Type'), 'Expected JSON.'); +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + if ('debug' === $mode) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); exampleCheck( - $original->getMessage() === $payload['exception']['message'], + $original->getMessage() === $payload['detail'], 'Trusted debug configuration did not reach the renderer.', ); exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); } else { - exampleCheck('{"error":"Request failed"}' === (string) $response->getBody(), 'Public JSON leaked diagnostics.'); + exampleCheck($publicBody === (string) $response->getBody(), 'Public JSON leaked diagnostics.'); } // Applications emit the response; this CLI fixture prints only its verification summary. -echo 'Interop JSON ' . $mode . ': configured profile, fresh scope and one private report passed' . PHP_EOL; +echo 'Interop JSON ' . $mode . ': configured provider, fresh scope and one private report passed' . PHP_EOL; diff --git a/examples/provider-whoops.php b/examples/provider-whoops.php index dc6781a..faed7d7 100644 --- a/examples/provider-whoops.php +++ b/examples/provider-whoops.php @@ -16,14 +16,19 @@ use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; +use FastForward\Container\Factory\CallableFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -42,9 +47,9 @@ ); // FastForward is a required runtime dependency of this PHP 8.3+ major. -// Selecting this profile deliberately chooses diagnostic HTML; no request enables it. +// Trusted debug permits HTML; the current request must still accept it. $mode = $argv[1] ?? 'default'; -exampleCheck(in_array($mode, ['default', 'configured'], true), 'Choose default or configured mode.'); +exampleCheck(in_array($mode, ['public', 'default', 'configured'], true), 'Choose public, default or configured mode.'); $messages = new Psr17Factory(); $reports = []; @@ -64,6 +69,10 @@ static function (Throwable $event) use (&$reports): void { ]; if ('configured' === $mode) { + if (!class_exists(PrettyPageHandler::class)) { + throw new RuntimeException('The configured development page needs filp/whoops:^2.18.7.'); + } + $settings = config([ 'development' => [ 'page_title' => 'Synthetic application diagnostics', @@ -82,14 +91,34 @@ static function (Throwable $event) use (&$reports): void { $factories[PrettyPageHandler::class] = new ServiceFactory($page); } +if ('public' !== $mode) { + // Whoops is an explicit application choice; package presence never selects it. + $factories[ThrowableResponseFactoryInterface::class] = new CallableFactory( + /** + * Retain request negotiation while selecting a diagnostic HTML integration. + * + * @param ContainerInterface $services the composed application services + * + * @return NegotiatingThrowableResponseFactory the explicit application response policy + */ + static function (ContainerInterface $services): NegotiatingThrowableResponseFactory { + /** @var ThrowableResponseFactoryInterface $fallback the generic error response */ + $fallback = $services->get(ThrowableResponseFactory::class); + + return new NegotiatingThrowableResponseFactory($fallback, [ + 'text/plain' => static fn() => $fallback, + 'application/json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'application/problem+json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'text/html' => static fn() => $services->get(WhoopsThrowableResponseFactory::class), + ]); + }, + ); +} + $application = new ArrayServiceProvider($factories); -// One aggregate selects HTML explicitly and applies the application's page override. -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); +// Application definitions win; development modes explicitly select the Whoops HTML candidate. +$services = container($application, new ErrorHandlerServiceProvider(debug: 'public' !== $mode)); $middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); exampleCheck($middlewareFactory instanceof ErrorHandlerMiddlewareFactory, 'Expected the fresh-scope factory.'); @@ -141,24 +170,38 @@ public function handle(ServerRequestInterface $request): ResponseInterface }; $first = $firstScope->process( - $messages->createServerRequest('GET', '/first'), + $messages->createServerRequest('GET', '/first')->withHeader('Accept', 'text/html'), $route($failures[0]), ); $second = $secondScope->process( - $messages->createServerRequest('GET', '/second'), + $messages->createServerRequest('GET', '/second')->withHeader('Accept', 'text/html'), $route($failures[1]), ); exampleCheck($failures === $reports, 'Each original must reach the private reporter once.'); exampleCheck(503 === $first->getStatusCode() && 404 === $second->getStatusCode(), 'Expected original HTTP codes.'); exampleCheck('text/html; charset=UTF-8' === $first->getHeaderLine('Content-Type'), 'Expected HTML.'); -exampleCheck('no-store' === $first->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.'); +exampleCheck( + 'public' === $mode || 'no-store' === $first->getHeaderLine('Cache-Control'), + 'Diagnostics must not be cached.', +); exampleCheck( !str_contains((string) $second->getBody(), $failures[0]->getMessage()), 'The next page must not retain the first diagnostic.', ); +if ('public' === $mode) { + exampleCheck(0 === $pageCalls, 'Public HTML must not resolve a Whoops page.'); + foreach ($failures as $failure) { + exampleCheck( + !str_contains((string) $first->getBody(), $failure->getMessage()) + && !str_contains((string) $second->getBody(), $failure->getMessage()), + 'Public HTML must not expose diagnostics.', + ); + } +} + if ('configured' === $mode) { exampleCheck(1 === $pageCalls, 'Expected one cached configured page for sequential renders.'); exampleCheck( @@ -172,4 +215,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface } // No HTTP emission. Hiding selected keys is not comprehensive redaction. -echo 'Whoops provider ' . $mode . ': lazy pages, fresh scopes and private reporting passed' . PHP_EOL; +echo 'public' === $mode + ? 'HTML provider public: Symfony renderer, fresh scopes and private reporting passed' . PHP_EOL + : 'Whoops provider ' . $mode . ': lazy pages, fresh scopes and private reporting passed' . PHP_EOL; diff --git a/examples/scopes.php b/examples/scopes.php index e3a54b5..aa6093b 100644 --- a/examples/scopes.php +++ b/examples/scopes.php @@ -46,7 +46,7 @@ StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17, ]); -$services = container(ErrorHandlerServiceProvider::class, $application); +$services = container($application, ErrorHandlerServiceProvider::class); $makeHandler = $services->get(ErrorHandlerFactory::class); $first = $makeHandler($services); diff --git a/examples/symfony-cli.php b/examples/symfony-cli.php new file mode 100644 index 0000000..e8e97f6 --- /dev/null +++ b/examples/symfony-cli.php @@ -0,0 +1,94 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use FastForward\Container\Factory\ServiceFactory; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; + +use function FastForward\Container\container; + +require __DIR__ . '/bootstrap.php'; + +$mode = $argv[1] ?? 'direct'; +exampleCheck(in_array($mode, ['direct', 'provider', 'console'], true), 'Choose direct, provider or console.'); + +// BufferedOutput makes this synthetic recipe observable without printing private diagnostics. +// Omit the constructor argument in an application to write directly to stderr. +$output = new BufferedOutput(); +$terminal = new SymfonyCliThrowableHandler($output); +$errors = new ErrorHandler($terminal); +$failure = new RuntimeException('Synthetic terminal diagnostic'); + +if ('provider' === $mode) { + $application = new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ]); + $services = container($application, new ErrorHandlerServiceProvider(cli: true)); + $errors = $services->get(ErrorHandler::class); +} + +if ('console' === $mode) { + $application = new Application('Synthetic command'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->addCommands([new class ($failure) extends Command { + /** @param Throwable $failure the unchanged synthetic command failure */ + public function __construct(private readonly Throwable $failure) + { + parent::__construct('fail'); + } + + /** + * Fail without letting Console become a second exception renderer. + * + * @param InputInterface $input the synthetic command input + * @param OutputInterface $output the application-owned command output + * + * @return int this synthetic command never returns normally + * + * @throws Throwable the unchanged command failure + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + throw $this->failure; + } + }]); + + // The bootstrap owns this catch and reports once; Console's exception catch is disabled. + try { + $application->run(new ArrayInput(['command' => 'fail']), $output); + } catch (Throwable $caught) { + exampleCheck($failure === $caught, 'The original command failure must reach the bootstrap.'); + $errors->handleThrowable($caught); + } +} else { + $errors->handleThrowable($failure); +} + +$diagnostic = $output->fetch(); +exampleCheck(1 === substr_count($diagnostic, $failure->getMessage()), 'Expected one terminal diagnostic.'); +exampleCheck(str_contains($diagnostic, RuntimeException::class), 'Expected Symfony exception formatting.'); + +// Reporting does not set an exit status; this verification program succeeds intentionally. +echo 'Symfony CLI ' . $mode . ': one terminal diagnostic and explicit capture ownership passed' . PHP_EOL; diff --git a/examples/verify.md b/examples/verify.md index a2fe292..ae220fa 100644 --- a/examples/verify.md +++ b/examples/verify.md @@ -16,9 +16,11 @@ the behavioral assertions and externally bounded native-termination fixtures. It does not use credentials, network servers, production logs or child coverage aggregation, and never publishes documentation or a release. -The verifier covers every recipe and documented mode. JSON public/debug, -manual HTML public/development and Whoops provider default/configured are included +The verifier covers every recipe and documented mode. JSON public/debug, Symfony HTML public/debug, +Symfony CLI direct/provider/Console ownership, manual HTML public/development and HTML provider public/Whoops-default/configured are included without an extra FastForward flag: the container is a runtime dependency of this -major. The documented development graph supplies optional Whoops for those tests. -Both middleware profiles report the original once before pure rendering. A +major. The event recipe uses FastForward's published provider/configuration API. The +documented development graph supplies Console and optional Whoops for those tests. +Middleware recipes report the original once before pure rendering; the direct +HTML pipeline reports explicitly once and supplies its current request. A configured page is a cached service, while runs and request scopes are fresh. diff --git a/examples/verify.php b/examples/verify.php index 597ae52..2ee467d 100644 --- a/examples/verify.php +++ b/examples/verify.php @@ -52,7 +52,14 @@ ]; $cases[] = [ 'provider-json.php', [$mode], 0, - 'Interop JSON ' . $mode . ": configured profile, fresh scope and one private report passed\n", '', + 'Interop JSON ' . $mode . ": configured provider, fresh scope and one private report passed\n", '', + ]; +} + +foreach (['public', 'debug'] as $mode) { + $cases[] = [ + 'http-html.php', [$mode], 0, + 'HTTP HTML ' . $mode . ": Symfony rendering and one original report passed\n", '', ]; } @@ -63,10 +70,19 @@ ]; } -foreach (['default', 'configured'] as $mode) { +foreach (['public', 'default', 'configured'] as $mode) { $cases[] = [ 'provider-whoops.php', [$mode], 0, - 'Whoops provider ' . $mode . ": lazy pages, fresh scopes and private reporting passed\n", '', + 'public' === $mode + ? "HTML provider public: Symfony renderer, fresh scopes and private reporting passed\n" + : 'Whoops provider ' . $mode . ": lazy pages, fresh scopes and private reporting passed\n", '', + ]; +} + +foreach (['direct', 'provider', 'console'] as $mode) { + $cases[] = [ + 'symfony-cli.php', [$mode], 0, + 'Symfony CLI ' . $mode . ": one terminal diagnostic and explicit capture ownership passed\n", '', ]; } diff --git a/resources/skills/coisa-error-handler/SKILL.md b/resources/skills/coisa-error-handler/SKILL.md index a64c3aa..c545c0f 100644 --- a/resources/skills/coisa-error-handler/SKILL.md +++ b/resources/skills/coisa-error-handler/SKILL.md @@ -1,8 +1,8 @@ --- name: coisa-error-handler -description: Integrate coisa/error-handler with configured private reporters, process callbacks, FastForward Interop providers and pure generic, JSON or Whoops PSR-15 responses. Use when an application consumes this library; inspect its installed version before selecting an API. +description: Integrate coisa/error-handler with configured private reporters, process callbacks, FastForward Interop providers and request-aware generic, JSON Problem Details or Symfony HTML PSR-15 responses and explicit Whoops integration. Use when an application consumes this library; inspect its installed version before selecting an API. license: MIT; see LICENSE -compatibility: CoiSA Error Handler 3.x API; PHP ^8.3, Composer 2 and required fast-forward/container ^1.10. Applications supply PSR-14/17 implementations and HTTP emission; development HTML additionally requires optional filp/whoops ^2.18.7 at runtime. No agent SDK or global installation is required. +compatibility: CoiSA Error Handler 3.x API; PHP ^8.3, Composer 2 and required fast-forward/container ^1.10. Applications supply PSR-14/17 implementations and HTTP emission; explicit Whoops HTML additionally requires optional filp/whoops ^2.18.7 at runtime. No agent SDK or global installation is required. metadata: author: Felipe Sayão Lobato Abreu api-baseline: "3.x" @@ -105,11 +105,12 @@ and does not write logs or contact a service. | Application need | Supported route and observation | | --- | --- | | Explicit reporting | `ErrorHandler::handleThrowable()` as above; original delivered once, execution continues. | +| Terminal diagnostics | Use explicit `SymfonyCliThrowableHandler` or provider `cli: true`; follow [terminal reporting](references/reporters.md#symfony-terminal-diagnostics) and keep one Console exception owner. | | PHP warnings or uncaught exceptions | Register at the application bootstrap; follow [process scopes](references/process.md) and verify restoration. | | FastForward integration | Supply reporters through the [Interop provider recipe](references/container.md); construction must install no callbacks. | | HTTP | Supply PSR-17 implementations and select the [explicit public response recipe](references/http.md); verify private delivery and fixed body separately. | -| Built-in JSON | Use the [pure JSON renderer or independent JSON provider](references/json.md); enable diagnostic JSON only through trusted application configuration. | -| Development HTML | Use the [Whoops renderer or independent Whoops provider](references/whoops.md) only in an explicitly selected development pipeline; the renderer does not negotiate requests. | +| Built-in JSON | Use the [pure JSON renderer or request-aware provider](references/json.md); enable diagnostic JSON only through trusted application configuration. | +| Public/development HTML | Use Symfony HTML through the default provider or [HTML application program](examples/html.php); trusted debug enables details. [Whoops](references/whoops.md) is an explicit optional override. | | Logging, events, filtering or best-effort reporting | Select [reporter composition](references/reporters.md) only for that integration; supply the actual collaborators. | FastForward caches services. The provider keeps process and middleware handlers @@ -119,12 +120,16 @@ Applications own interfaces' implementations, private diagnostic transports, callback installation and HTTP emission. Registering a class does not imply aggregate membership. -For a required reporter and a selected HTTP profile, compose one -`AggregateServiceProvider` in base, JSON or Whoops, application order. Each -library provider declares its own services; the application combines them. -Application definitions win last inside that aggregate. See -[provider precedence and fallback](references/container.md) before relying on an -override: map precedence does not disable concrete autowiring fallback. +Use `container($application, ErrorHandlerServiceProvider::class)` for ordinary +composition. This single provider negotiates responses from each current request. +`new ErrorHandlerServiceProvider(debug: true)` explicitly enables diagnostics; +neither Accept nor environment names enable debug automatically. Applications may +read a real boolean through FastForward Config before construction. + +Use one `AggregateServiceProvider`, library then application, only when a required +interface override must not fall through to a library binding after construction +fails. See [provider precedence and fallback](references/container.md); merged +maps do not disable concrete autowiring fallback. ## Verify and report @@ -136,7 +141,10 @@ run uncaught/fatal/termination experiments only in bounded child processes. For HTTP, assert status, exact public body and one original private report. Middleware reports before invoking a pure renderer. The provider defaults to a -generic body; JSON and Whoops profiles select pure response factories. Keep +request-aware factory: accepted JSON or Symfony HTML, otherwise generic fallback. +Trusted debug enables details without changing the chosen representation. Debug does not force HTML; JSON-only +requests never resolve Whoops. Every response factory takes the original Throwable +and current ServerRequestInterface, including explicit renderers. Keep output capture explicit and never inject the private reporter into that renderer again. `ReportingThrowableResponseFactory` is an explicit utility for a pipeline that has not already reported; do not wrap this middleware's renderer with it. diff --git a/resources/skills/coisa-error-handler/examples/PublicThrowableResponseFactory.php b/resources/skills/coisa-error-handler/examples/PublicThrowableResponseFactory.php index 15c2174..84a7ed5 100644 --- a/resources/skills/coisa-error-handler/examples/PublicThrowableResponseFactory.php +++ b/resources/skills/coisa-error-handler/examples/PublicThrowableResponseFactory.php @@ -19,6 +19,7 @@ use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; /** @@ -49,13 +50,14 @@ public function __construct( /** * Return a generic body without copying exception details or reporting again. * - * @param \Throwable $throwable the original failure, already reported by middleware + * @param \Throwable $throwable the original failure, already reported by middleware + * @param ServerRequestInterface $request the current request supplied by middleware * * @return ResponseInterface a status-500 response with an application-owned body * * @throws \Throwable when a supplied PSR-17 factory fails */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface + public function createResponseFromThrowable(\Throwable $throwable, ServerRequestInterface $request): ResponseInterface { return $this->responses->createResponse(500) ->withHeader('Content-Type', 'application/json') diff --git a/resources/skills/coisa-error-handler/examples/README.md b/resources/skills/coisa-error-handler/examples/README.md index 2b25f2b..7787fcc 100644 --- a/resources/skills/coisa-error-handler/examples/README.md +++ b/resources/skills/coisa-error-handler/examples/README.md @@ -107,11 +107,11 @@ or `JSON debug: original reported once and explicit response passed`. The explicit CLI mode represents trusted application configuration, never an HTTP input. Middleware reports the original once and invokes the pure JSON renderer directly. The program checks status 503, JSON content type, the exact -public body `{"error":"Request failed"}` or selected diagnostic metadata, +public Problem Details (`type`, `title`, `status`, generic `detail`) or selected diagnostic metadata, metadata-only trace frames and unchanged caller buffers. Debug adds `no-store`; it can expose messages and paths. See [JSON policy](../references/json.md). -## Interop JSON profile +## Interop JSON negotiation Use FastForward from the major's runtime graph and the application's PSR-17 implementation. The program reads a real boolean with FastForward Config before @@ -123,16 +123,33 @@ php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/pro ``` Expect exit 0 and -`Interop JSON public: independent providers and fresh scope passed` or -`Interop JSON debug: independent providers and fresh scope passed`. -The application supplies PSR-17 and reporter services, then composes one -`AggregateServiceProvider` in base, JSON, application order. The JSON map selects -the pure renderer; it does not copy base definitions or add a reporting decorator. +`Interop JSON public: single provider and fresh scope passed` or +`Interop JSON debug: single provider and fresh scope passed`. +The application supplies PSR-17 and reporter services first, followed by one +`ErrorHandlerServiceProvider(debug: $debug)`. Each current request explicitly +accepts JSON. This selects the pure JSON renderer without constructing Whoops +or adding a reporting decorator. The program invokes the reusable middleware factory for a fresh scope, checks the actual selected renderer, and verifies one report and the requested public or debug body. FastForward Config supplies application values, not automatic provider hydration. See [provider precedence](../references/container.md). +## Library HTML responses + +Symfony HTML needs no optional Whoops package. With the application's PSR-17 +implementation installed, run: + +```sh +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/html.php ./vendor/autoload.php public +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/html.php ./vendor/autoload.php debug +``` + +Expect exit 0 and `HTTP HTML public: Symfony rendering and one original report passed` +or the same summary with `debug`. The program reports the original once, then +calls `HtmlThrowableResponseFactory::createResponseFromThrowable($failure, $request)`. +The required current request is explicit. Public HTML omits exception details; +trusted debug enables Symfony diagnostics. Both return HTML and status503 without emission; debug adds no-store. The provider uses this same renderer by default. + ## Development HTML The application explicitly chooses a development HTML pipeline. If this optional @@ -152,23 +169,28 @@ unchanged caller buffers. The callback creates/configures the page; middleware receives the pure renderer directly. No `Accept` header or public environment flag enables this route. Selected field hiding is not complete redaction. -## Interop Whoops profile +## Interop HTML negotiation Keep Whoops in the selected application's actual runtime graph, including a -`--no-dev` installation. With the same PSR-17 implementation, run both modes: +`--no-dev` installation. With the same PSR-17 implementation, run public and explicitly selected Whoops modes: ```sh +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/provider-whoops.php ./vendor/autoload.php public php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/provider-whoops.php ./vendor/autoload.php default php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/provider-whoops.php ./vendor/autoload.php configured ``` -Expect exit 0 and -`Interop Whoops default: explicit aggregate, shared page and original reports passed` -or `Interop Whoops configured: explicit aggregate, shared page and original reports passed`. -Both modes compose one aggregate in base, Whoops, application order and invoke -the middleware factory for distinct scopes. The configured mode reads a trusted -page title through FastForward Config, prepares the page before `get()` and binds -it with `ServiceFactory`. The provider itself has no constructor arguments. +Expect exit 0 and `Interop HTML public: Symfony renderer and original reports passed`, +`Interop Whoops default: single provider, shared page and original reports passed` +or `Interop Whoops configured: single provider, shared page and original reports passed`. +Public mode uses the single provider's Symfony HTML with debug false and needs +no Whoops package. Development default/configured modes explicitly override the +negotiating factory's HTML candidate with Whoops; JSON and generic candidates +remain available. Installing Whoops alone never changes the library default. +All modes supply the application first and invoke the middleware factory for +distinct scopes. The configured mode reads a trusted page title through +FastForward Config, prepares the page before `get()` and binds it with +`ServiceFactory`. The provider's only option is a trusted debug boolean. Checks cover original-object delivery, statuses, selected renderer, retained page configuration and replacement of sequential diagnostic data. @@ -188,3 +210,22 @@ activation are outside these programs. JSON and Whoops programs use the major's report-before-render contract; adding `ReportingThrowableResponseFactory` to their middleware would report the original again. The explicit utility remains available to application pipelines that have not already reported. + +## Symfony terminal diagnostics + +The optional `symfony/console` application package supplies the observable output +and Console application used by this recipe. The handler's default stderr mode +itself does not require Console. + +```sh +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/symfony-cli.php ./vendor/autoload.php direct +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/symfony-cli.php ./vendor/autoload.php provider +php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/symfony-cli.php ./vendor/autoload.php console +``` + +Each exits 0 with `Symfony CLI : one terminal diagnostic and explicit capture ownership passed`. +It verifies one diagnostic in BufferedOutput; real applications omit the handler's +output argument for stderr or inject their Console output. Provider mode sets +`cli: true`. Console mode disables its default exception catch and reports once +at the outer application boundary. A real application chooses the failure exit +status. See [reporter boundaries](../references/reporters.md#symfony-terminal-diagnostics). diff --git a/resources/skills/coisa-error-handler/examples/html.php b/resources/skills/coisa-error-handler/examples/html.php new file mode 100644 index 0000000..f482431 --- /dev/null +++ b/resources/skills/coisa-error-handler/examples/html.php @@ -0,0 +1,62 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use Nyholm\Psr7\Factory\Psr17Factory; + +require __DIR__ . '/bootstrap.php'; + +$mode = $argv[2] ?? 'public'; +if (!in_array($mode, ['public', 'debug'], true)) { + throw new InvalidArgumentException('Choose public or debug mode.'); +} + +// This boolean belongs to trusted application configuration, never the request. +$debug = 'debug' === $mode; +$reports = []; +$errors = new ErrorHandler( + /** @param Throwable $error the unchanged original diagnostic delivered privately */ + static function (Throwable $error) use (&$reports): void { + $reports[] = $error; // Replace with the application's private diagnostic transport. + }, +); + +$messages = new Psr17Factory(); +$failure = new RuntimeException('Private synthetic HTML diagnostic', 503); +$request = $messages->createServerRequest('GET', 'http://example.test/html'); +$renderer = new HtmlThrowableResponseFactory($messages, $messages, $debug); + +// An explicit pipeline owns reporting; the renderer only builds a response. +$errors->handleThrowable($failure); +$response = $renderer->createResponseFromThrowable($failure, $request); + +if ([$failure] !== $reports || 503 !== $response->getStatusCode()) { + throw new RuntimeException('Expected one original report and the HTTP error status.'); +} +if ('text/html; charset=UTF-8' !== $response->getHeaderLine('Content-Type') + || ($debug && 'no-store' !== $response->getHeaderLine('Cache-Control'))) { + throw new RuntimeException('Expected HTML and a no-store policy for debug.'); +} +if ($debug !== str_contains((string) $response->getBody(), $failure->getMessage())) { + throw new RuntimeException('Only explicit debug may disclose the diagnostic.'); +} +if ($failure->getMessage() === $response->getReasonPhrase()) { + throw new RuntimeException('Diagnostic text must not become the reason phrase.'); +} + +// Applications own emission; this fixture prints no diagnostic HTML. +echo 'HTTP HTML ' . $mode . ': Symfony rendering and one original report passed' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/examples/json.php b/resources/skills/coisa-error-handler/examples/json.php index 75fdb28..5467bb1 100644 --- a/resources/skills/coisa-error-handler/examples/json.php +++ b/resources/skills/coisa-error-handler/examples/json.php @@ -20,6 +20,9 @@ use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Serializer; require __DIR__ . '/bootstrap.php'; @@ -49,7 +52,9 @@ $failure = $createFailure(); -$json = new JsonThrowableResponseFactory($http, $http, $configuration['debug']); +$serializer = new Serializer([new ProblemNormalizer(true)], [new JsonEncoder()]); + +$json = new JsonThrowableResponseFactory($http, $http, $serializer, $configuration['debug']); $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $json); @@ -63,7 +68,7 @@ throw new RuntimeException('Expected one private report without changing the output-buffer stack.'); } -if (503 !== $response->getStatusCode() || 'application/json' !== $response->getHeaderLine('Content-Type')) { +if (503 !== $response->getStatusCode() || 'application/problem+json' !== $response->getHeaderLine('Content-Type')) { throw new RuntimeException('Expected the original HTTP status and JSON content type.'); } @@ -71,15 +76,17 @@ throw new RuntimeException('The diagnostic must not become the response reason.'); } +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + if ($configuration['debug']) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - if ($failure->getMessage() !== $payload['exception']['message'] || [] === $payload['exception']['trace']) { + if ($failure->getMessage() !== $payload['detail'] || [] === $payload['trace']) { throw new RuntimeException('Expected explicitly configured development metadata.'); } - foreach ($payload['exception']['trace'] as $frame) { - if (array_key_exists('args', $frame) || array_key_exists('object', $frame)) { + foreach ($payload['trace'] as $frame) { + if ([] !== ($frame['args'] ?? []) || array_key_exists('object', $frame)) { throw new RuntimeException('Trace arguments and objects must remain private.'); } } @@ -87,7 +94,7 @@ if ('no-store' !== $response->getHeaderLine('Cache-Control')) { throw new RuntimeException('Development responses must disable caching.'); } -} elseif ('{"error":"Request failed"}' !== (string) $response->getBody()) { +} elseif ($publicBody !== (string) $response->getBody()) { throw new RuntimeException('Expected generic JSON without the private diagnostic.'); } diff --git a/resources/skills/coisa-error-handler/examples/provider-json.php b/resources/skills/coisa-error-handler/examples/provider-json.php index a8c60a3..2d0d675 100644 --- a/resources/skills/coisa-error-handler/examples/provider-json.php +++ b/resources/skills/coisa-error-handler/examples/provider-json.php @@ -17,14 +17,12 @@ use CoiSA\ErrorHandler\Example\RecordingThrowableHandler; use CoiSA\ErrorHandler\Example\ThrowingRequestHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseFactoryInterface; @@ -43,7 +41,7 @@ throw new RuntimeException('This source requires PHP 8.3+ and its FastForward runtime dependency.'); } -foreach ([ErrorHandlerServiceProvider::class, JsonErrorHandlerServiceProvider::class] as $class) { +foreach ([ErrorHandlerServiceProvider::class] as $class) { if (!class_exists($class)) { throw new RuntimeException('The selected library installation lacks ' . $class); } @@ -56,7 +54,7 @@ } // Resolve a real boolean from trusted configuration before constructing the provider. -$settings = config(['error_handler' => ['json' => ['debug' => 'debug' === $mode]]]); +$settings = config(['error_handler' => ['debug' => 'debug' === $mode]]); $reporter = new RecordingThrowableHandler(); $http = new Psr17Factory(); $failure = new RuntimeException('Private provider diagnostic', 503); @@ -67,22 +65,13 @@ ThrowableHandlerInterface::class => new ServiceFactory($reporter), ]); -if ('debug' === $mode) { - $services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider($settings->get('error_handler.json.debug', false)), - $application, - )); -} else { - $services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), - $application, - )); -} +$services = container( + $application, + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); -if (!$services->get(ThrowableResponseFactoryInterface::class) instanceof JsonThrowableResponseFactory) { - throw new RuntimeException('Expected the pure JSON profile instead of the generic default.'); +if (!$services->get(ThrowableResponseFactoryInterface::class) instanceof NegotiatingThrowableResponseFactory) { + throw new RuntimeException('Expected the request-aware default response factory.'); } /** @var ErrorHandlerMiddlewareFactory $middlewareFactory the reusable fresh-scope service */ @@ -95,7 +84,7 @@ } $response = $middleware->process( - $http->createServerRequest('GET', 'http://example.test/provider-json'), + $http->createServerRequest('GET', 'http://example.test/provider-json')->withHeader('Accept', 'application/json'), new ThrowingRequestHandler($failure), ); @@ -104,21 +93,23 @@ } if ('application/json' !== $response->getHeaderLine('Content-Type')) { - throw new RuntimeException('Expected a JSON response from the selected profile.'); + throw new RuntimeException('Expected JSON selected by the current request.'); } +$publicBody = '{"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Request failed"}'; + if ('debug' === $mode) { $payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - if ($failure->getMessage() !== $payload['exception']['message']) { + if ($failure->getMessage() !== $payload['detail']) { throw new RuntimeException('Trusted debug configuration must reach the JSON renderer.'); } if ('no-store' !== $response->getHeaderLine('Cache-Control')) { throw new RuntimeException('Development responses must disable caching.'); } -} elseif ('{"error":"Request failed"}' !== (string) $response->getBody()) { +} elseif ($publicBody !== (string) $response->getBody()) { throw new RuntimeException('Expected generic JSON without the private diagnostic.'); } -echo 'Interop JSON ' . $mode . ': independent providers and fresh scope passed' . PHP_EOL; +echo 'Interop JSON ' . $mode . ': single provider and fresh scope passed' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/examples/provider-whoops.php b/resources/skills/coisa-error-handler/examples/provider-whoops.php index 181ddaa..6700b9e 100644 --- a/resources/skills/coisa-error-handler/examples/provider-whoops.php +++ b/resources/skills/coisa-error-handler/examples/provider-whoops.php @@ -17,16 +17,19 @@ use CoiSA\ErrorHandler\Example\RecordingThrowableHandler; use CoiSA\ErrorHandler\Example\ThrowingRequestHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; use FastForward\Container\Factory\AliasFactory; +use FastForward\Container\Factory\CallableFactory; use FastForward\Container\Factory\ServiceFactory; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Whoops\Handler\PrettyPageHandler; @@ -44,23 +47,19 @@ throw new RuntimeException('This source requires PHP 8.3+ and its FastForward runtime dependency.'); } -foreach ([ErrorHandlerServiceProvider::class, WhoopsErrorHandlerServiceProvider::class] as $class) { +foreach ([ErrorHandlerServiceProvider::class] as $class) { if (!class_exists($class)) { throw new RuntimeException('The selected library installation lacks ' . $class); } } -if (!class_exists(PrettyPageHandler::class)) { - throw new RuntimeException('This optional application route needs filp/whoops:^2.18.7.'); -} - $mode = $argv[2] ?? 'default'; -if (!in_array($mode, ['default', 'configured'], true)) { - throw new InvalidArgumentException('Choose default or configured after the consumer autoloader.'); +if (!in_array($mode, ['public', 'default', 'configured'], true)) { + throw new InvalidArgumentException('Choose public, default or configured after the consumer autoloader.'); } -// The application has already selected development HTML; no request enables it. +// Trusted debug permits HTML; the current request must still accept it. $reporter = new RecordingThrowableHandler(); $http = new Psr17Factory(); $page = null; @@ -71,6 +70,10 @@ ]; if ('configured' === $mode) { + if (!class_exists(PrettyPageHandler::class)) { + throw new RuntimeException('The configured development page needs filp/whoops:^2.18.7.'); + } + $settings = config(['development' => ['page_title' => 'Synthetic configured diagnostics']]); // Prepare configuration before get(): an aggregate does not disable autowire fallback. @@ -83,16 +86,36 @@ $factories[PrettyPageHandler::class] = new ServiceFactory($page); } +if ('public' !== $mode) { + // Whoops is an explicit application choice; package presence never selects it. + $factories[ThrowableResponseFactoryInterface::class] = new CallableFactory( + /** + * Retain request negotiation while selecting a diagnostic HTML integration. + * + * @param ContainerInterface $services the composed application services + * + * @return NegotiatingThrowableResponseFactory the explicit application response policy + */ + static function (ContainerInterface $services): NegotiatingThrowableResponseFactory { + /** @var ThrowableResponseFactoryInterface $fallback the generic error response */ + $fallback = $services->get(ThrowableResponseFactory::class); + + return new NegotiatingThrowableResponseFactory($fallback, [ + 'text/plain' => static fn() => $fallback, + 'application/json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'application/problem+json' => static fn() => $services->get(JsonThrowableResponseFactory::class), + 'text/html' => static fn() => $services->get(WhoopsThrowableResponseFactory::class), + ]); + }, + ); +} + $application = new ArrayServiceProvider($factories); -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); +$services = container($application, new ErrorHandlerServiceProvider(debug: 'public' !== $mode)); -if (!$services->get(ThrowableResponseFactoryInterface::class) instanceof WhoopsThrowableResponseFactory) { - throw new RuntimeException('Expected the explicitly selected pure HTML profile.'); +if (!$services->get(ThrowableResponseFactoryInterface::class) instanceof NegotiatingThrowableResponseFactory) { + throw new RuntimeException('Expected the request-aware response factory.'); } /** @var ErrorHandlerMiddlewareFactory $middlewareFactory the reusable fresh-scope service */ @@ -111,12 +134,12 @@ ]; $first = $firstScope->process( - $http->createServerRequest('GET', 'http://example.test/first'), + $http->createServerRequest('GET', 'http://example.test/first')->withHeader('Accept', 'text/html'), new ThrowingRequestHandler($failures[0]), ); $second = $secondScope->process( - $http->createServerRequest('GET', 'http://example.test/second'), + $http->createServerRequest('GET', 'http://example.test/second')->withHeader('Accept', 'text/html'), new ThrowingRequestHandler($failures[1]), ); @@ -124,19 +147,28 @@ throw new RuntimeException('Expected each original once with its HTTP status.'); } -if ('text/html; charset=UTF-8' !== $second->getHeaderLine('Content-Type') - || 'no-store' !== $second->getHeaderLine('Cache-Control')) { - throw new RuntimeException('Expected non-cacheable development HTML.'); +if ( + 'text/html; charset=UTF-8' !== $second->getHeaderLine('Content-Type') + || ('public' !== $mode && 'no-store' !== $second->getHeaderLine('Cache-Control')) +) { + throw new RuntimeException('Expected HTML and a no-store policy for diagnostics.'); } -if (str_contains((string) $second->getBody(), $failures[0]->getMessage()) - || !str_contains((string) $second->getBody(), $failures[1]->getMessage())) { +if ( + str_contains((string) $second->getBody(), $failures[0]->getMessage()) + || ('public' !== $mode && !str_contains((string) $second->getBody(), $failures[1]->getMessage())) + || ('public' === $mode && str_contains((string) $second->getBody(), $failures[1]->getMessage())) +) { throw new RuntimeException('Sequential rendering must replace the previous diagnostic.'); } -if (null !== $page && ($page !== $services->get(PrettyPageHandler::class) - || !str_contains((string) $second->getBody(), 'Synthetic configured diagnostics'))) { +if ( + null !== $page && ($page !== $services->get(PrettyPageHandler::class) + || !str_contains((string) $second->getBody(), 'Synthetic configured diagnostics')) +) { throw new RuntimeException('Expected the shared application page and its retained configuration.'); } -echo 'Interop Whoops ' . $mode . ': explicit aggregate, shared page and original reports passed' . PHP_EOL; +echo 'public' === $mode + ? 'Interop HTML public: Symfony renderer and original reports passed' . PHP_EOL + : 'Interop Whoops ' . $mode . ': single provider, shared page and original reports passed' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/examples/symfony-cli.php b/resources/skills/coisa-error-handler/examples/symfony-cli.php new file mode 100644 index 0000000..a12c87a --- /dev/null +++ b/resources/skills/coisa-error-handler/examples/symfony-cli.php @@ -0,0 +1,108 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; +use FastForward\Container\Factory\ServiceFactory; +use FastForward\Container\ServiceProvider\ArrayServiceProvider; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; + +use function FastForward\Container\container; + +require __DIR__ . '/bootstrap.php'; + +/** + * Check a synthetic application invariant without changing library reporting. + * + * @param bool $condition the checked recipe invariant + * @param string $message the failure diagnostic for this synthetic application + * + * @throws RuntimeException when the expected behavior is not observed + */ +$check = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$mode = $argv[2] ?? 'direct'; +$check(in_array($mode, ['direct', 'provider', 'console'], true), 'Choose direct, provider or console.'); + +// BufferedOutput makes this synthetic recipe observable without printing private diagnostics. +// Omit the constructor argument in an application to write directly to stderr. +$output = new BufferedOutput(); +$terminal = new SymfonyCliThrowableHandler($output); +$errors = new ErrorHandler($terminal); +$failure = new RuntimeException('Synthetic terminal diagnostic'); + +if ('provider' === $mode) { + $application = new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ]); + $services = container($application, new ErrorHandlerServiceProvider(cli: true)); + $errors = $services->get(ErrorHandler::class); +} + +if ('console' === $mode) { + $application = new Application('Synthetic command'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->addCommands([new class ($failure) extends Command { + /** @param Throwable $failure the unchanged synthetic command failure */ + public function __construct(private readonly Throwable $failure) + { + parent::__construct('fail'); + } + + /** + * Fail without letting Console become a second exception renderer. + * + * @param InputInterface $input the synthetic command input + * @param OutputInterface $output the application-owned command output + * + * @return int this synthetic command never returns normally + * + * @throws Throwable the unchanged command failure + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + throw $this->failure; + } + }]); + + // The bootstrap owns this catch and reports once; Console's exception catch is disabled. + try { + $application->run(new ArrayInput(['command' => 'fail']), $output); + } catch (Throwable $caught) { + $check($failure === $caught, 'The original command failure must reach the bootstrap.'); + $errors->handleThrowable($caught); + } +} else { + $errors->handleThrowable($failure); +} + +$diagnostic = $output->fetch(); +$check(1 === substr_count($diagnostic, $failure->getMessage()), 'Expected one terminal diagnostic.'); +$check(str_contains($diagnostic, RuntimeException::class), 'Expected Symfony exception formatting.'); + +// Reporting does not set an exit status; this verification program succeeds intentionally. +echo 'Symfony CLI ' . $mode . ': one terminal diagnostic and explicit capture ownership passed' . PHP_EOL; diff --git a/resources/skills/coisa-error-handler/references/container.md b/resources/skills/coisa-error-handler/references/container.md index 2bafdfc..4c6d442 100644 --- a/resources/skills/coisa-error-handler/references/container.md +++ b/resources/skills/coisa-error-handler/references/container.md @@ -13,7 +13,7 @@ resolution alone does not establish a warning-free application. Keep diagnostics under `E_ALL` visible and separate dependency evidence from the library's behavior. For optional reporter discovery and external PSR implementations, ordinary -composition uses `container(ErrorHandlerServiceProvider::class, $application)` +composition uses `container($application, ErrorHandlerServiceProvider::class)` from `FastForward\Container`. The application is an Interop provider. The library discovers an available `CallableThrowableHandler` and PSR-14 adapters when their dispatcher is available. An empty default aggregate is silent. A logger, @@ -37,26 +37,33 @@ back after a PSR container error; they do not establish a required override. An aggregate fixes binding precedence but does not make concrete service construction fail closed against FastForward's autowiring fallback. -## Select a response profile +## Request-aware responses -The base provider selects `ThrowableResponseFactory` with the pure -`GenericThrowableStreamFactory`. Its optional reporter aggregate can be empty -and silent. A configured private reporter remains an application responsibility. -The independent profiles replace only the selected response services: +The single `ErrorHandlerServiceProvider(bool $debug = false, bool $cli = false)` selects +`NegotiatingThrowableResponseFactory` by default. It uses the current request's +Accept header, with generic, JSON and Symfony HTML candidates. Trusted debug enables diagnostics +in JSON and Symfony HTML. Optional Whoops requires an explicit application override. +JSON-only requests never resolve Whoops. Debug permits diagnostic HTML, but does not force it; +without Accept or a supported positive match, the generic response is used. +A configured private reporter remains the application's responsibility. -- `JsonErrorHandlerServiceProvider` declares its pure JSON renderer and response - alias. Its optional boolean constructor input enables diagnostic JSON. -- `WhoopsErrorHandlerServiceProvider` declares `PrettyPageHandler`, its pure - Whoops renderer and response alias. It has no constructor; customize the page - through the `PrettyPageHandler::class` application service. +Use the complete [JSON](json.md#compose-the-single-provider) or +[Whoops](whoops.md#configure-the-single-provider) program. The ordinary bootstrap +is `container($application, ErrorHandlerServiceProvider::class)`. +Enable diagnostics with a real boolean in +`new ErrorHandlerServiceProvider(debug: $debug)`, read from trusted application +configuration. No specific configuration key is read by the library. -For a required reporter and a selected profile, use one aggregate in base, -selected profile, application order. The later profile replaces the base response -alias, and the application supplies its required reporter and PSR-17 bindings. -Library providers do not instantiate or merge another provider. Follow the -complete [JSON](json.md#compose-independent-providers) or -[Whoops](whoops.md#compose-the-independent-whoops-profile) route. Select one -profile explicitly; these providers do not inspect `Accept` or environment values. +Explicit `cli: true` appends Symfony terminal formatting to the default reporter +aggregate; it does not select HTTP diagnostics. An application OutputInterface +service supplies an optional Console destination; otherwise it uses stderr. +Keep one Console exception owner; see [terminal diagnostics](reporters.md#symfony-terminal-diagnostics). + +The provider also declares concrete public HTML, JSON and Whoops renderers and a lazy cached +`PrettyPageHandler` service. Supply that page through the application map to +customize it. The removed separate JSON/Whoops providers are not needed. +Use a single merged provider only for the required-interface failure contract +described above. Construction and discovery install no PHP callbacks. Repeated `get(ErrorHandler::class)` returns a cached handler. Resolve diff --git a/resources/skills/coisa-error-handler/references/http.md b/resources/skills/coisa-error-handler/references/http.md index 3e46d42..6bce9f1 100644 --- a/resources/skills/coisa-error-handler/references/http.md +++ b/resources/skills/coisa-error-handler/references/http.md @@ -7,7 +7,7 @@ request handler. Follow its [setup and result](../examples/README.md#explicit-ht Middleware requires one scoped object implementing both `ErrorHandlerInterface` and `ThrowableHandlerInterface`. It registers that scope, -executes downstream, reports a caught throwable once, invokes the pure +executes downstream, reports a caught throwable once, passes the same Throwable and current ServerRequestInterface to the pure `ThrowableResponseFactoryInterface` and restores callbacks in `finally`. A reporter failure propagates before rendering. A renderer/PSR-17 failure also propagates without retrying the original report. The application supplies the @@ -20,14 +20,25 @@ library API. Keep distinct handlers for nested process/request scopes. Invoke the provider's middleware factory service for each fresh scope; repeated `get()` calls return the cached middleware. +Every response factory implements +`createResponseFromThrowable(Throwable $throwable, ServerRequestInterface $request): ResponseInterface`. +The second argument is required even for a fixed renderer. Middleware supplies +it automatically; direct application calls must pass their current request. + ## Select a built-in renderer +Use `HtmlThrowableResponseFactory($responses, $streams, $debug)` for Symfony +public/debug HTML; its default false debug omits the diagnostic. The complete +[HTML program](../examples/html.php) and +[setup](../examples/README.md#library-html-responses) show both modes. Use [JSON](json.md) for a generic JSON body or explicitly configured diagnostic JSON. Use [Whoops](whoops.md) only after trusted application configuration selects development HTML and provides its optional runtime dependency. Both factories preserve integer throwable codes 400–599, otherwise use 500, and leave the reason -to the application's PSR-17 implementation. Their independent profiles select -the pure renderer; they do not import lifecycle or reporter maps. +to the application's PSR-17 implementation. The single provider negotiates JSON +or Symfony HTML from Accept and keeps the generic response as fallback. Debug +enables diagnostic JSON and Symfony HTML, but never forces HTML. Optional Whoops +is an explicit application override, independent of whether it is installed. `ReportingThrowableResponseFactory($reporter, $renderer)` remains available for an application pipeline that has not already reported. It calls the reporter, @@ -38,7 +49,8 @@ owns reporting before combining these components. ## Generic default and explicit output capture -The provider's `GenericThrowableStreamFactory` returns `Internal Server Error` +The provider negotiates from the current request; its fallback uses +`GenericThrowableStreamFactory`, which returns `Internal Server Error` without invoking a reporter. `ThrowableResponseFactory` preserves integer codes 400–599 and uses 500 otherwise, including string codes. It leaves the reason phrase to the application's PSR-17 implementation; exception messages are never diff --git a/resources/skills/coisa-error-handler/references/json.md b/resources/skills/coisa-error-handler/references/json.md index 1e5fba5..e4adafd 100644 --- a/resources/skills/coisa-error-handler/references/json.md +++ b/resources/skills/coisa-error-handler/references/json.md @@ -1,7 +1,7 @@ # Pure JSON and Interop composition Read when an application wants the library's JSON renderer or explicitly chooses -the independent JSON provider. The library requires PHP `^8.3`; follow the +the request-aware provider. The library requires PHP `^8.3`; follow the [migration guide](https://github.com/coisa/error-handler/blob/master/docs/migration.md) when upgrading from the 2.x middleware composition. @@ -16,11 +16,10 @@ php -r 'require "vendor/autoload.php"; if (!class_exists("CoiSA\\ErrorHandler\\H ``` For providers, also verify `ServiceProvider\ErrorHandlerServiceProvider`, -`ServiceProvider\JsonErrorHandlerServiceProvider` and `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` under the `CoiSA\ErrorHandler` namespace. FastForward `^1.10` is a required runtime dependency of this source. Class discovery alone does not distinguish the 2.5 renderer APIs -from the lifecycle and middleware contracts described here. Follow the matching +from the request-aware response and middleware contracts described here. Follow the matching source and skill; do not upgrade a consumer automatically to fit this route. ## Direct JSON responses @@ -34,7 +33,12 @@ use CoiSA\ErrorHandler\ErrorHandler; use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; -$json = new JsonThrowableResponseFactory($responses, $streams, $configuration['debug']); +$serializer = new Symfony\Component\Serializer\Serializer( + [new Symfony\Component\Serializer\Normalizer\ProblemNormalizer(true)], + [new Symfony\Component\Serializer\Encoder\JsonEncoder()], +); + +$json = new JsonThrowableResponseFactory($responses, $streams, $serializer, $configuration['debug']); $middleware = new ErrorHandlerMiddleware(new ErrorHandler($reporter), $json); ``` @@ -46,90 +50,76 @@ program, not automatic library configuration keys. The JSON factory is pure: it does not report, capture output or emit HTTP. Integer throwable codes 400–599 select the status; everything else selects 500. -The public body is exactly `{"error":"Request failed"}` with JSON content type. +The public Problem Details object has `type: about:blank`, `title` from the PSR-17 +reason, the selected `status` and `detail: Request failed`. Direct rendering returns +`application/problem+json`; negotiation also accepts `application/json` and keeps +that media type with the same object shape. The response factory chooses its PSR-17 reason instead of using exception text. Middleware reports the identical original once, then invokes this pure renderer. Reporter failure prevents rendering; renderer/PSR-17 failures propagate without retry. A reporter that prints still owns that output; use a private sink without public output. -`bool $debug = false` is the JSON factory's third constructor parameter. -Trusted application configuration can enable class, message, file, line and -metadata-only trace. Arguments, objects and previous causes are excluded; +`SerializerInterface $serializer` is required as the third constructor parameter; +the fourth is `bool $debug = false`. Trusted application configuration can enable +original-message `detail`, `class` and Symfony trace metadata. Argument values and +objects are excluded; empty `args` arrays may remain; `Cache-Control: no-store` is added and invalid UTF-8 is substituted. Messages and paths still contain diagnostic data. Choose debug before construction, never from public request headers or a naive `(bool) 'false'` environment cast. -The default public representation is a fixed JSON string. Debug uses the JSON -encoder; this source requires PHP 8.3+, where JSON is a core extension. +Both modes use the configured serializer. Serialization failures propagate without +an HTML fallback; invalid UTF-8 is substituted. This source requires PHP 8.3+, +where JSON is a core extension. `ReportingThrowableResponseFactory` remains an explicit reporting utility for other pipelines. Do not wrap this middleware's JSON renderer with it: middleware already owns the original report. See [HTTP boundaries](http.md). -## Compose independent providers +## Compose the single provider Use the complete [provider program](../examples/provider-json.php) after its -[application setup](../examples/README.md#interop-json-profile). The application -binds `ResponseFactoryInterface`, `StreamFactoryInterface` and its private -`ThrowableHandlerInterface` in an `ArrayServiceProvider`. Library providers only -declare their own maps; the application composes them explicitly: +[application setup](../examples/README.md#interop-json-negotiation). Bind the +application's PSR-17 factories and private reporter in an `ArrayServiceProvider`, +then supply it first: ```php use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; -use CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider; -use FastForward\Container\ServiceProvider\AggregateServiceProvider; +use function FastForward\Config\config; use function FastForward\Container\container; -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new JsonErrorHandlerServiceProvider(), - $application, -)); -``` - -The base provider supplies lifecycle services and a generic default response. -The JSON provider supplies only its pure renderer and response alias; it does not -instantiate another provider or import reporter/lifecycle maps. The application -defines the required reporter after the library maps. Without an application -reporter, the base's optional reporter aggregate can be empty and silent. - -For development configuration, use -`new JsonErrorHandlerServiceProvider($configuration['debug'])` in the same place. -The program reads a boolean through FastForward Config before building the -container. Config supplies values; it does not hydrate the provider or inject -constructor scalars. A strict application entrypoint avoids weak caller coercion. - -For an application override of a service already declared by the library, use -one `AggregateServiceProvider` as above; the last definition wins within that -aggregate. Separate variadic providers prioritize the first successful source, -so the ordinary helper does not establish required overrides. FastForward can -continue after PSR-11 resolution errors or autowire after a concrete factory -failure. Definition precedence is not a guarantee that -configuration errors fail closed. Test the actual selected renderer and failure -behavior with the application's container. - -Resolve the reusable `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` -and invoke it with the composed container for each independent HTTP scope: +$settings = config(['error_handler' => ['debug' => false]]); -```php -$middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class); - -$middleware = $middlewareFactory($services); +$services = container( + $application, + new ErrorHandlerServiceProvider(debug: $settings->get('error_handler.debug', false)), +); ``` -Import that factory from `CoiSA\ErrorHandler\ServiceProvider\Factory` in the -entrypoint, as the program does. Each invocation constructs a distinct core and -middleware. Direct middleware lookup and the process `ErrorHandlerInterface` -service remain cached. Resolving services installs no callbacks; middleware -owns its bounded request registration/restoration. Fresh objects do not remove -PHP's queued shutdown callbacks or establish a bounded long-running worker policy; -this core queues at most one shutdown callback per handler instance. +The example Config key belongs to the application, not the library. The default +class-string bootstrap is `container($application, ErrorHandlerServiceProvider::class)`. +The provider supplies lifecycle services and lazy request negotiation; no separate +JSON/Whoops providers are used. Send `Accept: application/json` or `Accept: application/problem+json` on the current +request. JSON-only requests never resolve Whoops, including when debug is true. +Debug controls details, not client representation preferences. + +Application-first composition gives normal overrides priority. For a required +interface override that must fail rather than fall through to library defaults, +use one AggregateServiceProvider, library then application. See +[container failure semantics](container.md); a concrete factory can still fall +back to autowiring. + +Resolve and invoke `ServiceProvider\\Factory\\ErrorHandlerMiddlewareFactory` for +each fresh HTTP scope. Direct middleware/process lookups remain cached. +Construction installs no callbacks; middleware registers and restores only its +scope. Native callbacks remain process-wide and fresh instances do not provide +fiber/concurrent isolation. ## Verify the application boundary Run both public/debug programs with the consumer's explicit autoloader. Assert status, content type, exact generic body or explicit debug metadata, one original private report, callback restoration and failure propagation. Test provider -order and required overrides; do not infer a correct profile from class discovery -alone. The programs use synthetic fixtures and do not emit or contact a service. +order and required overrides; do not infer a correct renderer from class discovery +alone. Every response factory requires the current ServerRequestInterface as its +second method argument; middleware forwards it automatically. The programs use synthetic fixtures and do not emit or contact a service. diff --git a/resources/skills/coisa-error-handler/references/reporters.md b/resources/skills/coisa-error-handler/references/reporters.md index ef97cf6..4b61316 100644 --- a/resources/skills/coisa-error-handler/references/reporters.md +++ b/resources/skills/coisa-error-handler/references/reporters.md @@ -73,6 +73,26 @@ the previous cause is the original. An empty/all-success pass returns normally. Construct these explicitly; neither supplies retries, a recursion guard or durable delivery. +## Symfony terminal diagnostics + +`SymfonyCliThrowableHandler()` formats through Symfony CliErrorRenderer and writes +to stderr. It does not need Console, install callbacks or choose an exit status. +An explicit `OutputInterface` receives raw text; ConsoleOutput uses its error +channel. Install optional symfony/console only when the application uses that API. +Renderer/output failures propagate; diagnostics can contain private data. + +The provider's `cli: true` appends terminal reporting to its default aggregate; +`debug` independently controls HTTP details. An explicit ThrowableHandlerInterface +override replaces that aggregate. Bind OutputInterface in the application for +an existing destination. No SAPI detection or CLI response negotiation occurs. + +When using Symfony Console, choose one exception owner. An outer application catch +uses `setCatchExceptions(false)` and `setAutoExit(false)`, reports once and decides +its exit status. Do not also use Console's built-in exception rendering. Run the +[complete terminal program](../examples/symfony-cli.php) in direct, provider or +console mode after the explicit autoloader argument. It uses BufferedOutput and +prints a verification summary instead of private diagnostics. + ## PSR-3 With the application's actual `Psr\Log\LoggerInterface $logger`: @@ -112,6 +132,13 @@ callable listeners. Configuring both raw and wrapped adapters causes separate deliveries; choose that deliberately. Dispatcher/listener failures propagate and are not retried. +The [checkout event recipe](https://github.com/coisa/error-handler/blob/master/examples/event-dispatcher.php) +uses `fast-forward/event-dispatcher:^1.0`: configure its EventDispatcherServiceProvider +with FastForward ArrayConfig under `ListenerProviderInterface::class`, listing the +Error Handler listener-provider objects. Compose that configuration and provider +before ErrorHandlerServiceProvider. The dispatcher remains an application choice; +Error Handler only consumes the PSR-14 interface. + Use the consumer's existing PHPUnit doubles or synthetic recording collaborators. Verify original-object identity, order/nonmatch behavior, empty aggregates, partial reporting failures and exact logger context. No example proves delivery diff --git a/resources/skills/coisa-error-handler/references/whoops.md b/resources/skills/coisa-error-handler/references/whoops.md index 17b99a2..23c14b9 100644 --- a/resources/skills/coisa-error-handler/references/whoops.md +++ b/resources/skills/coisa-error-handler/references/whoops.md @@ -1,8 +1,10 @@ # Explicit development HTML with Whoops Read when an application deliberately selects diagnostic HTML in an existing -HTML pipeline. Keep a generic response policy for public requests. This route -does not negotiate `Accept`, infer an environment or enable debug from a header. +HTML pipeline. The concrete renderer does not negotiate Accept. The default +provider uses Symfony HTML; Whoops requires an explicit application override. +That application map may retain Accept negotiation, as shown below. No header +or environment name activates debug or selects an unconfigured renderer. ## Inspect the selected installation @@ -68,55 +70,57 @@ retry. Supply this pure renderer directly. The retained `ReportingThrowableResponseFactory` is an explicit utility for other pipelines; adding it here would report the original twice. -## Compose the independent Whoops profile +## Public HTML -First verify that the selected installation contains -`ServiceProvider\WhoopsErrorHandlerServiceProvider`, the base -`ServiceProvider\ErrorHandlerServiceProvider` and -`ServiceProvider\Factory\ErrorHandlerMiddlewareFactory`. The application must -run PHP 8.3+; FastForward `^1.10` is required by this source at runtime. +`HtmlThrowableResponseFactory($responses, $streams, bool $debug = false)` renders a +production error page with Symfony HtmlErrorRenderer by default. Trusted debug +selects Symfony diagnostics; installing Whoops never changes this default. Public +mode exposes no original exception message, source or trace. The single provider always offers this HTML +route, including without optional Whoops. Its request argument is required; +middleware supplies the current request. See the +[provider public mode](../examples/README.md#interop-html-negotiation). -The [complete provider program](../examples/provider-whoops.php) supplies the -PSR-17 interfaces and private reporter through its application provider. Compose -one aggregate in base, Whoops, application order, including for default pages: +## Configure the single provider + +The [complete provider program](../examples/provider-whoops.php) supplies PSR-17 +interfaces and a private reporter through the application provider. Development +modes also explicitly replace the negotiating factory's HTML candidate with +Whoops while retaining JSON and generic candidates: ```php -$services = container(new AggregateServiceProvider( - new ErrorHandlerServiceProvider(), - new WhoopsErrorHandlerServiceProvider(), - $application, -)); +$services = container($application, new ErrorHandlerServiceProvider(debug: true)); ``` -Import these names and FastForward's `container` helper as the program does. -Each library provider declares only its own services. The Whoops provider has no -constructor arguments and binds `PrettyPageHandler::class` with `InvokableFactory`. -The renderer's callback resolves that service only during rendering. Resolve and -invoke `ErrorHandlerMiddlewareFactory` for a distinct core/middleware per scope; -direct services remain cached and lookup installs no native callbacks. - -To customize the page, configure a `PrettyPageHandler` through its public API -before composing the container and expose it with `ServiceFactory` in the -application map. The configured program uses FastForward Config for a trusted -page title. Application definitions win last inside the aggregate. Sequential -renders reuse the cached configured page: Whoops replaces the exception, -inspector and template data while retaining configuration. The page keeps the -latest diagnostic until its next render; fresh Run/response/body do not imply -that the shared page releases all diagnostic state. A manual callback may instead -create pages. Concurrency and arbitrary subclasses need application tests. - -An aggregate fixes binding precedence, not fail-closed construction: FastForward -may autowire a default page after a concrete page factory throws. Preparing a -page before composition does not change that fallback policy. An incompatible -returned page raises `TypeError`; an actual `handle()` failure propagates. -Separate providers can also continue after PSR-11 resolution errors and select -another response definition. Use one aggregate to select the intended profile -and application bindings, then test the actual missing-package and failed-factory -behavior. Do not infer a particular renderer or exception chain from map order. +This is the same `ServiceProvider\ErrorHandlerServiceProvider` used for ordinary +reporting and JSON. No separate HTML provider is required. Send +`Accept: text/html` on the current request. Debug alone never forces HTML. +The default negotiator always uses Symfony HTML. These development modes override +only the HTML candidate with Whoops and never resolve it for a JSON-only request. Direct selection of the concrete renderer still +requires the optional package at construction. + +The provider binds `PrettyPageHandler::class` with InvokableFactory. The optional Whoops renderer's lazy +callback resolves that service only during selected HTML rendering. +Resolve and invoke ErrorHandlerMiddlewareFactory for fresh scopes; direct +lookups remain cached and construction installs no callbacks. + +Customize the page through its public API before composition and expose it with +ServiceFactory in the application map. Application-first composition selects +that configured service. The program uses trusted Config values for the page +title. Sequential renders retain configuration while replacing current exception, +inspector and template data; the page keeps its latest diagnostic until the next +render. Fresh Whoops Run/response/body do not provide concurrent page isolation. +A manual callback may instead create a page per render. + +FastForward may autowire a default page after a failing concrete page factory. +Preparing a page before lookup exposes configuration errors immediately. A merged +provider fixes binding precedence but does not disable concrete fallback. +Wrong-type pages and actual rendering failures retain their runtime failure +contracts. Test application selection and failure behavior rather than infer it +from service discovery. ## Verify diagnostic and delivery limits -Run the direct program and both provider modes against the consumer autoloader. +Run the direct program and all provider modes against the consumer autoloader. Check original-object identity and one private report, status and content type, unchanged caller buffers, fresh response bodies and replacement of sequential diagnostic data. Check missing dependencies and actual container fallback too. @@ -127,4 +131,6 @@ source, causes and other globals may expose secrets. Never select this renderer for public users merely because they accept HTML. A deployment using it must retain Whoops in its actual runtime graph even with `--no-dev`. -Request negotiation remains separate from this renderer. +Every response factory requires the current ServerRequestInterface as its second +method argument. Middleware forwards it automatically; a direct call must supply +it. Negotiation is a separate pure renderer selected by the single provider. diff --git a/src/Handler/SymfonyCliThrowableHandler.php b/src/Handler/SymfonyCliThrowableHandler.php new file mode 100644 index 0000000..90297e4 --- /dev/null +++ b/src/Handler/SymfonyCliThrowableHandler.php @@ -0,0 +1,93 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Handler; + +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\ErrorHandler\ErrorRenderer\CliErrorRenderer; + +/** + * Formats explicitly selected terminal diagnostics without installing native callbacks. + * + * The optional Console output belongs to the application; otherwise diagnostics go + * to stderr. This adapter does not choose a SAPI, terminate the process or render HTTP. + */ +final class SymfonyCliThrowableHandler implements ThrowableHandlerInterface +{ + /** + * Selects an application-owned Console destination without requiring Console for stderr output. + * + * @param null|OutputInterface $output optional Console output; ConsoleOutput uses its error channel + */ + public function __construct(private readonly ?OutputInterface $output = null) + { + } + + /** + * Writes the original throwable's Symfony diagnostic once to the selected terminal destination. + * + * Console markup is not interpreted. No registration, reporting retry, exit status + * policy or HTTP negotiation occurs here. Diagnostic text may contain private data. + * + * @param \Throwable $throwable the original diagnostic, passed unchanged to Symfony + * + * @throws \Throwable the unchanged renderer or application-output failure + * @throws \RuntimeException when the native stderr stream cannot accept the complete diagnostic + */ + public function handleThrowable(\Throwable $throwable): void + { + $text = (new CliErrorRenderer())->render($throwable)->getAsString(); + if (null === $this->output) { + $this->writeToStderr($text); + + return; + } + + $output = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + $output->write($text, false, OutputInterface::OUTPUT_RAW); + } + + /** + * Writes all bytes to an owned stderr handle without closing an application's stream constant. + * + * @param string $text the already formatted diagnostic + * + * @throws \RuntimeException when opening or writing the native destination fails + * @throws \Throwable the unchanged native warning-handler failure + */ + private function writeToStderr(string $text): void + { + $stream = fopen('php://stderr', 'w'); + if (false === $stream) { + throw new \RuntimeException('Unable to open stderr for the CLI diagnostic.'); + } + + try { + $offset = 0; + $length = \strlen($text); + while ($offset < $length) { + $written = fwrite($stream, substr($text, $offset)); + if (false === $written || 0 === $written) { + throw new \RuntimeException('Unable to write the complete CLI diagnostic to stderr.'); + } + $offset += $written; + } + } finally { + fclose($stream); + } + } +} diff --git a/src/Http/Message/HtmlThrowableResponseFactory.php b/src/Http/Message/HtmlThrowableResponseFactory.php new file mode 100644 index 0000000..be9b0f8 --- /dev/null +++ b/src/Http/Message/HtmlThrowableResponseFactory.php @@ -0,0 +1,132 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Http\Message; + +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; +use Symfony\Component\ErrorHandler\Exception\FlattenException; + +/** + * Creates public or explicitly enabled diagnostic HTML through Symfony's rendering APIs. + * + * Rendering does not report, emit responses or register process-wide callbacks. + */ +final class HtmlThrowableResponseFactory implements ThrowableResponseFactoryInterface +{ + /** @var ResponseFactoryInterface the application-owned HTTP response factory */ + private ResponseFactoryInterface $responseFactory; + + /** @var StreamFactoryInterface the factory providing a fresh body for every response */ + private StreamFactoryInterface $streamFactory; + + /** @var bool whether trusted application configuration enables diagnostic HTML */ + private bool $debug; + + /** + * Configures HTML rendering without deriving debug authorization from the request. + * + * @param ResponseFactoryInterface $responseFactory the application HTTP response factory + * @param StreamFactoryInterface $streamFactory the application HTTP stream factory + * @param bool $debug explicit development diagnostics; public HTML is the default + */ + public function __construct( + ResponseFactoryInterface $responseFactory, + StreamFactoryInterface $streamFactory, + bool $debug = false, + ) { + $this->responseFactory = $responseFactory; + $this->streamFactory = $streamFactory; + $this->debug = $debug; + } + + /** + * Returns a complete HTML document with the library's HTTP error-status policy. + * + * The request never enables diagnostics. Debug HTML may expose exception details; + * public HTML contains only the status and Symfony's generic error page. + * + * @param \Throwable $throwable the unchanged original diagnostic supplying status and optional details + * @param ServerRequestInterface $request the current request; direct selection chooses HTML explicitly + * + * @return ResponseInterface the application response containing a fresh UTF-8 HTML stream + * + * @throws \Throwable the unchanged HTTP-factory or rendering failure + */ + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { + $code = $throwable->getCode(); + $status = (\is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; + $response = $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'text/html; charset=UTF-8') + ; + $body = $this->createHtml($throwable, $status, $response->getReasonPhrase()); + $stream = $this->streamFactory->createStream($body); + + if ($this->debug) { + $response = $response->withHeader('Cache-Control', 'no-store'); + } + + return $response->withBody($stream); + } + + /** + * Adapts Symfony's public body API while retaining the selected PSR-17 status. + * + * Symfony may infer a different status for its HTTP exception contracts, so its + * public setters align the displayed status and text with the returned response. + * Diagnostic body fragments receive a small document shell and the public stylesheet. + * + * @param \Throwable $throwable the original event whose metadata Symfony flattens for rendering + * @param int $status the already selected library HTTP status + * @param string $reason the application response implementation's reason phrase + * + * @return string the complete public or diagnostic HTML document + * + * @throws \Throwable the unchanged Symfony rendering failure + */ + private function createHtml(\Throwable $throwable, int $status, string $reason): string + { + $renderer = new HtmlErrorRenderer($this->debug, 'UTF-8'); + $exception = FlattenException::createFromThrowable($throwable, $status) + ->setStatusCode($status) + ->setStatusText($reason) + ; + $body = $renderer->getBody($exception); + + if (!$this->debug) { + return $body; + } + + $title = htmlspecialchars($status . ' ' . $reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return \sprintf( + "\n\n\n" + . "\n\n" + . "\n" + . "%s\n\n\n" + . "\n%s\n\n\n", + $title, + $renderer->getStylesheet(), + $body, + ); + } +} diff --git a/src/Http/Message/JsonThrowableResponseFactory.php b/src/Http/Message/JsonThrowableResponseFactory.php index 48fa5ce..a916ad5 100644 --- a/src/Http/Message/JsonThrowableResponseFactory.php +++ b/src/Http/Message/JsonThrowableResponseFactory.php @@ -18,10 +18,13 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\Serializer\SerializerInterface; /** - * Builds JSON responses; applications own diagnostic reporting and response emission. + * Builds JSON Problem Details; applications own reporting and response emission. * * Integer HTTP error codes 400–599 are preserved; other codes use 500. * Debug details require an explicit application choice; generic output is the default. @@ -35,29 +38,29 @@ final class JsonThrowableResponseFactory implements ThrowableResponseFactoryInte /** @var StreamFactoryInterface the application-owned body factory */ private StreamFactoryInterface $streamFactory; + /** @var SerializerInterface the application serializer configured with a problem normalizer and JSON encoder */ + private SerializerInterface $serializer; + /** @var bool whether trusted application configuration explicitly enables diagnostics */ private bool $debug; /** - * Configures pure JSON rendering without changing the library's default renderer. + * Configures pure Problem Details rendering with an explicit serializer. * * @param ResponseFactoryInterface $responseFactory the factory providing HTTP status reason phrases * @param StreamFactoryInterface $streamFactory the factory providing a new body per response + * @param SerializerInterface $serializer the serializer using ProblemNormalizer and JsonEncoder * @param bool $debug whether to expose exception metadata for development - * - * @throws \LogicException when debug rendering is selected without an available JSON encoder */ public function __construct( ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory, + SerializerInterface $serializer, bool $debug = false, ) { - if ($debug && !\function_exists('json_encode')) { - throw new \LogicException('Debug JSON responses require the JSON extension and json_encode().'); - } - $this->responseFactory = $responseFactory; $this->streamFactory = $streamFactory; + $this->serializer = $serializer; $this->debug = $debug; } @@ -67,19 +70,33 @@ public function __construct( * The throwable message is never used as the response's reason phrase. * This method does not report, capture output, install callbacks or emit a response. * - * @param \Throwable $throwable the original failure providing status and optional debug metadata + * @param \Throwable $throwable the original failure providing status and optional debug metadata + * @param ServerRequestInterface $request the request; diagnostics use trusted configuration only * - * @return ResponseInterface the application response with an application/json body + * @return ResponseInterface the application response with a JSON Problem Details body * - * @throws \Throwable the identical failure raised by an HTTP collaborator - * @throws \JsonException if controlled debug metadata cannot be encoded + * @throws \Throwable the identical failure raised by an HTTP or serializer collaborator */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $code = $throwable->getCode(); $status = (\is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; - $response = $this->responseFactory->createResponse($status)->withHeader('Content-Type', 'application/json'); - $stream = $this->streamFactory->createStream($this->createBody($throwable)); + $response = $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/problem+json') + ; + $stream = $this->streamFactory->createStream($this->serializer->serialize( + $this->createProblem($throwable, $status), + 'json', + [ + 'debug' => $this->debug, + 'type' => 'about:blank', + 'title' => $response->getReasonPhrase(), + 'status' => $status, + 'json_encode_options' => JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR, + ], + )); if ($this->debug) { $response = $response->withHeader('Cache-Control', 'no-store'); @@ -89,30 +106,26 @@ public function createResponseFromThrowable(\Throwable $throwable): ResponseInte } /** - * Encodes only selected native metadata, replacing invalid UTF-8 without serializing objects. + * Selects native metadata without traversing exception objects or trace arguments. * * @param \Throwable $throwable the original failure whose metadata is explicitly selected + * @param int $status the library's selected HTTP error status * - * @return string the complete JSON document without captured reporter output - * - * @throws \JsonException if controlled debug metadata cannot be encoded + * @return FlattenException a detached problem value without a previous exception chain */ - private function createBody(\Throwable $throwable): string + private function createProblem(\Throwable $throwable, int $status): FlattenException { - if (!$this->debug) { - return '{"error":"Request failed"}'; - } - - return json_encode([ - 'error' => 'Request failed', - 'exception' => [ - 'class' => $throwable::class, - 'message' => $throwable->getMessage(), - 'file' => $throwable->getFile(), - 'line' => $throwable->getLine(), - 'trace' => $this->createTraceMetadata($throwable), - ], - ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + return (new FlattenException()) + ->setStatusCode($status) + ->setStatusText('Request failed') + ->setMessage($this->debug ? $throwable->getMessage() : 'Request failed') + ->setClass($this->debug ? $throwable::class : '') + ->setTrace( + $this->debug ? $this->createTraceMetadata($throwable) : [], + $this->debug ? $throwable->getFile() : '', + $this->debug ? $throwable->getLine() : 0, + ) + ; } /** diff --git a/src/Http/Message/NegotiatingThrowableResponseFactory.php b/src/Http/Message/NegotiatingThrowableResponseFactory.php new file mode 100644 index 0000000..086abda --- /dev/null +++ b/src/Http/Message/NegotiatingThrowableResponseFactory.php @@ -0,0 +1,144 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Http\Message; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\HttpFoundation\AcceptHeader; +use Symfony\Component\HttpFoundation\AcceptHeaderItem; + +/** + * Selects an application-authorized renderer for the current request's Accept header. + * + * The application supplies the available representations in server-preference order. + * Only the winning renderer callback runs. Diagnostic authorization belongs to + * that configuration; a client header cannot enable an unavailable representation. + * This factory does not report, register callbacks, retain requests or emit output. + */ +final class NegotiatingThrowableResponseFactory implements ThrowableResponseFactoryInterface +{ + /** @var ThrowableResponseFactoryInterface the generic response for absent or unsupported preferences */ + private readonly ThrowableResponseFactoryInterface $fallback; + + /** @var array lazy representations in tie-breaking order */ + private readonly array $renderers; + + /** + * Configures trusted candidates without resolving any lazy renderer. + * + * @param ThrowableResponseFactoryInterface $fallback generic fallback + * @param array $renderers lazy types in priority order + */ + public function __construct(ThrowableResponseFactoryInterface $fallback, array $renderers) + { + $this->fallback = $fallback; + $this->renderers = $renderers; + } + + /** + * Negotiates positive client quality using Symfony's media-range matching. + * + * Specific ranges determine each candidate's effective quality, including zero + * exclusions behind wildcards. Equal positive qualities retain configured order. + * Missing or unsupported preferences select the generic fallback. Selected map + * keys define the response media type; matching renderer parameters are retained + * when the key has none. Every response includes Accept in the Vary policy. + * + * @param \Throwable $throwable the original diagnostic, passed unchanged to the selected renderer + * @param ServerRequestInterface $request the current request, never retained between invocations + * + * @return ResponseInterface the selected response without reporting or emission + * + * @throws \Throwable the identical lazy factory or renderer failure; no fallback retry is performed + */ + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { + [$renderer, $mediaType] = $this->selectRenderer($request); + $response = $renderer->createResponseFromThrowable($throwable, $request); + + if (null !== $mediaType) { + $selected = AcceptHeaderItem::fromString($mediaType); + if ( + !$response->hasHeader('Content-Type') + || [] !== $selected->getAttributes() + || 0 !== strcasecmp( + $selected->getValue(), + AcceptHeaderItem::fromString($response->getHeaderLine('Content-Type'))->getValue(), + ) + ) { + $response = $response->withHeader('Content-Type', $mediaType); + } + } elseif (!$response->hasHeader('Content-Type')) { + $response = $response->withHeader('Content-Type', 'text/plain'); + } + + return $this->varyByAccept($response); + } + + /** + * Obtains only the best positive candidate or the already configured fallback. + * + * @param ServerRequestInterface $request the request supplying representation preferences + * + * @return array{ThrowableResponseFactoryInterface, ?string} the renderer and selected map key, or null for fallback + * + * @throws \Throwable the identical selected callback failure + */ + private function selectRenderer(ServerRequestInterface $request): array + { + $accept = AcceptHeader::fromString($request->getHeaderLine('Accept')); + $quality = 0.0; + $selected = null; + $selectedMediaType = null; + + foreach ($this->renderers as $mediaType => $renderer) { + $item = $accept->get($mediaType); + if (null === $item || $item->getQuality() <= $quality) { + continue; + } + + $quality = $item->getQuality(); + $selected = $renderer; + $selectedMediaType = $mediaType; + } + + return null === $selected ? [$this->fallback, null] : [$selected(), $selectedMediaType]; + } + + /** + * Adds the selecting field without replacing existing Vary fields or duplicating Accept. + * + * @param ResponseInterface $response the selected renderer's immutable response + * + * @return ResponseInterface the response with a merged Vary policy + */ + private function varyByAccept(ResponseInterface $response): ResponseInterface + { + foreach ($response->getHeader('Vary') as $line) { + foreach (explode(',', $line) as $field) { + $field = strtolower(trim($field)); + if ('accept' === $field || '*' === $field) { + return $response; + } + } + } + + return $response->withAddedHeader('Vary', 'Accept'); + } +} diff --git a/src/Http/Message/ReportingThrowableResponseFactory.php b/src/Http/Message/ReportingThrowableResponseFactory.php index 1ca94cc..457b86a 100644 --- a/src/Http/Message/ReportingThrowableResponseFactory.php +++ b/src/Http/Message/ReportingThrowableResponseFactory.php @@ -18,6 +18,7 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Delivers a throwable once before delegating to an explicitly selected pure renderer. @@ -51,16 +52,19 @@ public function __construct( * * A reporter failure prevents rendering. Neither failure path retries or reports again. * - * @param \Throwable $throwable the original failure passed unchanged to both collaborators + * @param \Throwable $throwable the original failure passed unchanged to both collaborators + * @param ServerRequestInterface $request the incoming request forwarded unchanged to the pure renderer * * @return ResponseInterface the delegate's unmodified response * * @throws \Throwable the identical reporter or renderer failure */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $this->throwableHandler->handleThrowable($throwable); - return $this->responseFactory->createResponseFromThrowable($throwable); + return $this->responseFactory->createResponseFromThrowable($throwable, $request); } } diff --git a/src/Http/Message/ThrowableResponseFactory.php b/src/Http/Message/ThrowableResponseFactory.php index 7aff013..92e805f 100644 --- a/src/Http/Message/ThrowableResponseFactory.php +++ b/src/Http/Message/ThrowableResponseFactory.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Class ThrowableResponseFactory. @@ -60,12 +61,15 @@ public function __construct( * This method SHALL map the throwable's code to an HTTP status, use the factory's * standard reason phrase, and include the stream factory's public response body. * - * @param \Throwable $throwable the throwable to convert into an HTTP response + * @param \Throwable $throwable the throwable to convert into an HTTP response + * @param ServerRequestInterface $request the incoming request; generic rendering is request-independent * * @return ResponseInterface the generated PSR-7 response */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $code = $throwable->getCode(); $statusCode = $this->getStatusCode($code); diff --git a/src/Http/Message/ThrowableResponseFactoryInterface.php b/src/Http/Message/ThrowableResponseFactoryInterface.php index df745ab..85849e0 100644 --- a/src/Http/Message/ThrowableResponseFactoryInterface.php +++ b/src/Http/Message/ThrowableResponseFactoryInterface.php @@ -17,6 +17,7 @@ namespace CoiSA\ErrorHandler\Http\Message; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * Interface ThrowableResponseFactoryInterface. @@ -36,9 +37,13 @@ interface ThrowableResponseFactoryInterface * that complies with the PSR-7 standard. The response MUST include * an appropriate status code, headers, and error message. * - * @param \Throwable $throwable the Throwable instance to convert into a response + * @param \Throwable $throwable the Throwable instance to convert into a response + * @param ServerRequestInterface $request the incoming request supplying representation context * * @return ResponseInterface the generated HTTP response */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface; + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface; } diff --git a/src/Http/Message/WhoopsThrowableResponseFactory.php b/src/Http/Message/WhoopsThrowableResponseFactory.php index 6bfd3b4..4e88e7d 100644 --- a/src/Http/Message/WhoopsThrowableResponseFactory.php +++ b/src/Http/Message/WhoopsThrowableResponseFactory.php @@ -18,6 +18,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Whoops\Handler\PrettyPageHandler; use Whoops\Run; @@ -68,15 +69,18 @@ public function __construct( * Integer codes from 400 through 599 are preserved; other codes use 500. * This renderer does not report, emit, register callbacks or negotiate Accept. * - * @param \Throwable $throwable the unchanged original failure to render + * @param \Throwable $throwable the unchanged original failure to render + * @param ServerRequestInterface $request the request; direct selection chooses development HTML * * @return ResponseInterface the development response with a fresh HTML body and no-store policy * * @throws \Throwable the unchanged response, stream, page-factory or rendering failure * @throws \TypeError when the page factory returns an incompatible value */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { $code = $throwable->getCode(); $status = (\is_int($code) && $code >= 400 && $code <= 599) ? $code : 500; $response = $this->responseFactory->createResponse($status) diff --git a/src/Http/Middleware/ErrorHandlerMiddleware.php b/src/Http/Middleware/ErrorHandlerMiddleware.php index 93867cf..95a336f 100644 --- a/src/Http/Middleware/ErrorHandlerMiddleware.php +++ b/src/Http/Middleware/ErrorHandlerMiddleware.php @@ -88,7 +88,7 @@ public function process( $response = $handler->handle($request); } catch (\Throwable $throwable) { $this->errorHandler->handleThrowable($throwable); - $response = $this->throwableResponseFactory->createResponseFromThrowable($throwable); + $response = $this->throwableResponseFactory->createResponseFromThrowable($throwable, $request); } finally { $this->errorHandler->unregister(); } diff --git a/src/ServiceProvider/ErrorHandlerServiceProvider.php b/src/ServiceProvider/ErrorHandlerServiceProvider.php index b2656ed..d483094 100644 --- a/src/ServiceProvider/ErrorHandlerServiceProvider.php +++ b/src/ServiceProvider/ErrorHandlerServiceProvider.php @@ -20,7 +20,11 @@ use CoiSA\ErrorHandler\ErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; @@ -28,20 +32,43 @@ use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ServiceProvider\Factory\ThrowableHandlerAggregateFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\NegotiatingThrowableResponseFactoryFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\WhoopsThrowableResponseFactoryFactory; +use CoiSA\ErrorHandler\ServiceProvider\Factory\SymfonyCliThrowableHandlerFactory; use FastForward\Container\Factory\AliasFactory; use FastForward\Container\Factory\InvokableFactory; use Interop\Container\ServiceProviderInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Whoops\Handler\PrettyPageHandler; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Encoder\JsonEncoder; /** * Declares lifecycle services and interface aliases for FastForward autowiring. * * Resolving a service MUST NOT install PHP's process-wide callbacks. - * Concrete dispatch and HTTP adapters SHALL be resolved through autowiring. + * HTTP representation is selected for each request by a shared, stateless negotiator. + * Whoops remains an explicitly selected optional adapter, independent of the default HTML renderer. * * @package CoiSA\ErrorHandler\ServiceProvider */ final class ErrorHandlerServiceProvider implements ServiceProviderInterface { + /** + * Selects trusted diagnostics without enabling them from request headers. + * + * @param bool $debug whether JSON and Symfony HTML may include development diagnostics + * @param bool $cli explicitly enables terminal diagnostics without inferring the server API + */ + public function __construct( + private readonly bool $debug = false, + private readonly bool $cli = false, + ) { + } + /** * Supplies aliases and factories that define reporter composition and handler lifetimes. * @@ -54,8 +81,31 @@ public function getFactories(): array ErrorHandler::class => new ErrorHandlerFactory(), ErrorHandlerFactory::class => new InvokableFactory(ErrorHandlerFactory::class), ThrowableHandlerInterface::class => AliasFactory::get(ThrowableHandlerAggregate::class), - ThrowableHandlerAggregate::class => new ThrowableHandlerAggregateFactory(), - ThrowableResponseFactoryInterface::class => AliasFactory::get(ThrowableResponseFactory::class), + ThrowableHandlerAggregate::class => new ThrowableHandlerAggregateFactory($this->cli), + SymfonyCliThrowableHandler::class => new SymfonyCliThrowableHandlerFactory(), + ThrowableResponseFactoryInterface::class => AliasFactory::get(NegotiatingThrowableResponseFactory::class), + NegotiatingThrowableResponseFactory::class => new NegotiatingThrowableResponseFactoryFactory(), + JsonThrowableResponseFactory::class => new InvokableFactory( + JsonThrowableResponseFactory::class, + ResponseFactoryInterface::class, + StreamFactoryInterface::class, + SerializerInterface::class, + $this->debug, + ), + SerializerInterface::class => AliasFactory::get(Serializer::class), + Serializer::class => new InvokableFactory( + Serializer::class, + [new ProblemNormalizer(true)], + [new JsonEncoder()], + ), + HtmlThrowableResponseFactory::class => new InvokableFactory( + HtmlThrowableResponseFactory::class, + ResponseFactoryInterface::class, + StreamFactoryInterface::class, + $this->debug, + ), + PrettyPageHandler::class => new InvokableFactory(PrettyPageHandler::class), + WhoopsThrowableResponseFactory::class => new WhoopsThrowableResponseFactoryFactory(), ThrowableStreamFactoryInterface::class => AliasFactory::get(GenericThrowableStreamFactory::class), ErrorHandlerMiddleware::class => new ErrorHandlerMiddlewareFactory(), ErrorHandlerMiddlewareFactory::class => new InvokableFactory(ErrorHandlerMiddlewareFactory::class), diff --git a/src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php b/src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php new file mode 100644 index 0000000..c27c582 --- /dev/null +++ b/src/ServiceProvider/Factory/NegotiatingThrowableResponseFactoryFactory.php @@ -0,0 +1,81 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; + +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; +use Psr\Container\ContainerInterface; + +/** Composes lazy representations without resolving a current request or development page. */ +final class NegotiatingThrowableResponseFactoryFactory +{ + /** + * Creates a reusable selector; only the selected representation is constructed on a failure. + * + * @param ContainerInterface $container the resolver supplying concrete pure renderers + * + * @return NegotiatingThrowableResponseFactory the request-independent negotiation service + * + * @throws \Throwable when construction of the required generic fallback fails + */ + public function __invoke(ContainerInterface $container): NegotiatingThrowableResponseFactory + { + $fallback = self::resolveRenderer($container, ThrowableResponseFactory::class); + + return new NegotiatingThrowableResponseFactory($fallback, [ + 'text/plain' => static fn(): ThrowableResponseFactoryInterface => $fallback, + 'application/json' => static fn(): ThrowableResponseFactoryInterface => self::resolveRenderer( + $container, + JsonThrowableResponseFactory::class, + ), + 'application/problem+json' => static fn(): ThrowableResponseFactoryInterface => self::resolveRenderer( + $container, + JsonThrowableResponseFactory::class, + ), + 'text/html' => static fn(): ThrowableResponseFactoryInterface => self::resolveRenderer( + $container, + HtmlThrowableResponseFactory::class, + ), + ]); + } + + /** + * Validates an explicitly configured renderer without retrying a different representation. + * + * @param ContainerInterface $container the application resolver + * @param class-string $id the selected renderer service + * + * @return ThrowableResponseFactoryInterface the configured renderer + * + * @throws \Throwable the unchanged service construction failure + * @throws \TypeError when a definition returns an incompatible renderer + */ + private static function resolveRenderer( + ContainerInterface $container, + string $id, + ): ThrowableResponseFactoryInterface { + $renderer = $container->get($id); + if (!$renderer instanceof ThrowableResponseFactoryInterface) { + throw new \TypeError($id . ' must implement ' . ThrowableResponseFactoryInterface::class . '.'); + } + + return $renderer; + } +} diff --git a/src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php b/src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php new file mode 100644 index 0000000..ed4c016 --- /dev/null +++ b/src/ServiceProvider/Factory/SymfonyCliThrowableHandlerFactory.php @@ -0,0 +1,48 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; + +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use Psr\Container\ContainerInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** Resolves an optional Console destination without registering a process or resolving a request. */ +final class SymfonyCliThrowableHandlerFactory +{ + /** + * Constructs the explicitly selected CLI reporter with application output or native stderr. + * + * @param ContainerInterface $container the application resolver supplying an optional output + * + * @return SymfonyCliThrowableHandler the inert diagnostic reporter + * + * @throws \Throwable the unchanged optional-output discovery or construction failure + * @throws \TypeError when an explicitly configured output does not implement OutputInterface + */ + public function __invoke(ContainerInterface $container): SymfonyCliThrowableHandler + { + $output = null; + if ($container->has(OutputInterface::class)) { + $output = $container->get(OutputInterface::class); + if (!$output instanceof OutputInterface) { + throw new \TypeError(OutputInterface::class . ' must resolve to an OutputInterface instance.'); + } + } + + return new SymfonyCliThrowableHandler($output); + } +} diff --git a/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php index 21faae2..bf983ee 100644 --- a/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php +++ b/src/ServiceProvider/Factory/ThrowableHandlerAggregateFactory.php @@ -20,6 +20,7 @@ use CoiSA\ErrorHandler\Handler\DispatchErrorEventThrowableHandler; use CoiSA\ErrorHandler\Handler\DispatchThrowableHandler; use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; use FastForward\Container\Exception\AutowiringException; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -35,6 +36,15 @@ */ final class ThrowableHandlerAggregateFactory { + /** + * Enables an explicit terminal reporter without changing ordinary HTTP discovery. + * + * @param bool $cli whether the application selected CLI diagnostics + */ + public function __construct(private readonly bool $cli = false) + { + } + /** * Creates an instance of ThrowableHandlerAggregate. * @@ -86,6 +96,12 @@ public function __invoke(ContainerInterface $container): ThrowableHandlerAggrega } } + if ($this->cli) { + /** @var SymfonyCliThrowableHandler $cliHandler the explicitly enabled terminal reporter */ + $cliHandler = $container->get(SymfonyCliThrowableHandler::class); + $handler->attach($cliHandler); + } + return $handler; } } diff --git a/src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php b/src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php new file mode 100644 index 0000000..e417509 --- /dev/null +++ b/src/ServiceProvider/Factory/WhoopsThrowableResponseFactoryFactory.php @@ -0,0 +1,50 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\ServiceProvider\Factory; + +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Whoops\Handler\PrettyPageHandler; + +/** Wires the explicitly selected optional Whoops renderer and its application-owned page. */ +final class WhoopsThrowableResponseFactoryFactory +{ + /** + * Resolves HTTP dependencies while deferring the configured page until rendering. + * + * @param ContainerInterface $container the application service resolver + * + * @return WhoopsThrowableResponseFactory the pure renderer with a lazy page service + * + * @throws \Throwable when an HTTP dependency fails or the optional Whoops package is missing + */ + public function __invoke(ContainerInterface $container): WhoopsThrowableResponseFactory + { + /** @var ResponseFactoryInterface $responses the application's PSR-17 factory */ + $responses = $container->get(ResponseFactoryInterface::class); + + /** @var StreamFactoryInterface $streams the application's PSR-17 factory */ + $streams = $container->get(StreamFactoryInterface::class); + + /** @var \Closure(): PrettyPageHandler $pageFactory resolves the configured page only during rendering */ + $pageFactory = static fn() => $container->get(PrettyPageHandler::class); + + return new WhoopsThrowableResponseFactory($responses, $streams, $pageFactory); + } +} diff --git a/src/ServiceProvider/JsonErrorHandlerServiceProvider.php b/src/ServiceProvider/JsonErrorHandlerServiceProvider.php deleted file mode 100644 index 27e761b..0000000 --- a/src/ServiceProvider/JsonErrorHandlerServiceProvider.php +++ /dev/null @@ -1,73 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\ServiceProvider; - -use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use FastForward\Container\Factory\AliasFactory; -use FastForward\Container\Factory\InvokableFactory; -use Interop\Container\ServiceProviderInterface; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; - -/** - * Declares pure JSON services for composition after the major middleware reports. - */ -final class JsonErrorHandlerServiceProvider implements ServiceProviderInterface -{ - /** @var bool whether trusted application configuration enables diagnostic JSON */ - private bool $debug; - - /** - * Selects the JSON representation before container resolution begins. - * - * @param bool $debug explicit development diagnostics; public JSON is the default - */ - public function __construct(bool $debug = false) - { - $this->debug = $debug; - } - - /** - * Selects pure JSON without importing lifecycle or diagnostic-reporting definitions. - * - * @return array JSON-only service definitions - */ - public function getFactories(): array - { - return [ - JsonThrowableResponseFactory::class => new InvokableFactory( - JsonThrowableResponseFactory::class, - ResponseFactoryInterface::class, - StreamFactoryInterface::class, - $this->debug, - ), - ThrowableResponseFactoryInterface::class => AliasFactory::get(JsonThrowableResponseFactory::class), - ]; - } - - /** - * Leaves application services unchanged. - * - * @return array no implicit extensions - */ - public function getExtensions(): array - { - return []; - } -} diff --git a/src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php b/src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php deleted file mode 100644 index 227976b..0000000 --- a/src/ServiceProvider/WhoopsErrorHandlerServiceProvider.php +++ /dev/null @@ -1,87 +0,0 @@ - - * @license https://opensource.org/licenses/MIT MIT License - */ - -declare(strict_types=1); - -namespace CoiSA\ErrorHandler\ServiceProvider; - -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; -use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; -use FastForward\Container\Factory\AliasFactory; -use FastForward\Container\Factory\CallableFactory; -use FastForward\Container\Factory\InvokableFactory; -use Interop\Container\ServiceProviderInterface; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Whoops\Handler\PrettyPageHandler; - -/** - * Selects optional pure development HTML after the major middleware reports. - * - * Applications supply HTTP factories, a private reporter and trusted development - * authorization, and compose lifecycle services separately. Discovery creates no - * page and installs no native callbacks. - */ -final class WhoopsErrorHandlerServiceProvider implements ServiceProviderInterface -{ - /** - * Describes only this profile's page, pure renderer and response alias. - * - * FastForward caches the configured page. Its lazy callback resolves that service - * only during rendering, after the renderer checks its optional dependency. - * - * @return array the lazy service definitions - */ - public function getFactories(): array - { - return [ - PrettyPageHandler::class => new InvokableFactory(PrettyPageHandler::class), - WhoopsThrowableResponseFactory::class => new CallableFactory( - /** - * @param ContainerInterface $container the application service resolver - * - * @return WhoopsThrowableResponseFactory the pure renderer with a lazy page service - */ - static function (ContainerInterface $container): WhoopsThrowableResponseFactory { - /** @var ResponseFactoryInterface $responses the application's PSR-17 factory */ - $responses = $container->get(ResponseFactoryInterface::class); - - /** @var StreamFactoryInterface $streams the application's PSR-17 factory */ - $streams = $container->get(StreamFactoryInterface::class); - - /** @var \Closure(): PrettyPageHandler $pageFactory resolves the configured page only during rendering */ - $pageFactory = static fn() => $container->get(PrettyPageHandler::class); - - return new WhoopsThrowableResponseFactory( - $responses, - $streams, - $pageFactory, - ); - }, - ), - ThrowableResponseFactoryInterface::class => AliasFactory::get(WhoopsThrowableResponseFactory::class), - ]; - } - - /** - * Adds no extensions to the application's services. - * - * @return array the empty extension map - */ - public function getExtensions(): array - { - return []; - } -} diff --git a/tests/Functional/ConsumerSkillExamplesTest.php b/tests/Functional/ConsumerSkillExamplesTest.php index f63a632..06fb6a8 100644 --- a/tests/Functional/ConsumerSkillExamplesTest.php +++ b/tests/Functional/ConsumerSkillExamplesTest.php @@ -25,6 +25,7 @@ use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -130,7 +131,7 @@ public function testPublicResponseIsPureWithoutLeakingDiagnostics(): void $renderer = new PublicThrowableResponseFactory($http, $http); $failure = new \RuntimeException('Private secret detail', 404); - $response = $renderer->createResponseFromThrowable($failure); + $response = $renderer->createResponseFromThrowable($failure, new ServerRequest('GET', 'https://example.test/')); self::assertSame(500, $response->getStatusCode()); self::assertSame('Internal Server Error', $response->getReasonPhrase()); @@ -258,25 +259,49 @@ public static function provideShippedProgramsUseTheExplicitConsumerAutoloaderCas 'json.php', ['debug'], 'JSON debug: original reported once and explicit response passed', ], + 'HTML public' => [ + 'html.php', ['public'], + 'HTTP HTML public: Symfony rendering and one original report passed', + ], + 'HTML debug' => [ + 'html.php', ['debug'], + 'HTTP HTML debug: Symfony rendering and one original report passed', + ], 'Whoops manual' => [ 'whoops.php', [], 'Whoops HTML: explicit development page and one original report passed', ], 'JSON provider public' => [ 'provider-json.php', ['public'], - 'Interop JSON public: independent providers and fresh scope passed', + 'Interop JSON public: single provider and fresh scope passed', ], 'JSON provider debug' => [ 'provider-json.php', ['debug'], - 'Interop JSON debug: independent providers and fresh scope passed', + 'Interop JSON debug: single provider and fresh scope passed', + ], + 'Whoops provider public' => [ + 'provider-whoops.php', ['public'], + 'Interop HTML public: Symfony renderer and original reports passed', ], 'Whoops provider default' => [ 'provider-whoops.php', ['default'], - 'Interop Whoops default: explicit aggregate, shared page and original reports passed', + 'Interop Whoops default: single provider, shared page and original reports passed', ], 'Whoops provider configured' => [ 'provider-whoops.php', ['configured'], - 'Interop Whoops configured: explicit aggregate, shared page and original reports passed', + 'Interop Whoops configured: single provider, shared page and original reports passed', + ], + 'Symfony CLI direct' => [ + 'symfony-cli.php', ['direct'], + 'Symfony CLI direct: one terminal diagnostic and explicit capture ownership passed', + ], + 'Symfony CLI provider' => [ + 'symfony-cli.php', ['provider'], + 'Symfony CLI provider: one terminal diagnostic and explicit capture ownership passed', + ], + 'Symfony CLI console output' => [ + 'symfony-cli.php', ['console'], + 'Symfony CLI console: one terminal diagnostic and explicit capture ownership passed', ], ]; } diff --git a/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php index f216bec..91582b5 100644 --- a/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php +++ b/tests/Functional/Http/Middleware/ErrorHandlerCleanupTest.php @@ -61,12 +61,15 @@ public function __construct(private \Throwable $failure) /** * Throws the configured rendering failure to exercise callback restoration. * - * @param \Throwable $throwable the throwable being rendered + * @param \Throwable $throwable the throwable being rendered + * @param ServerRequestInterface $request the incoming request associated with the failure * * @return ResponseInterface the fixture result */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { throw $this->failure; } }; diff --git a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php index 854fe4d..34cc0ec 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerCompositionTest.php @@ -33,8 +33,9 @@ use FastForward\Container\ServiceProvider\AggregateServiceProvider; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; -use Phly\EventDispatcher\EventDispatcher; -use Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate; +use FastForward\Config\ArrayConfig; +use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider; +use Psr\EventDispatcher\ListenerProviderInterface; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -72,22 +73,21 @@ final class ErrorHandlerCompositionTest extends TestCase private ?ContainerInterface $container = null; /** - * @var EventDispatcher + * @var EventDispatcherInterface the application dispatcher composed through FastForward */ private $eventDispatcher; - /** - * @var ListenerProviderAggregate - */ - private $listenerProvider; - /** * Creates isolated container and reporting collaborators for each test. */ protected function setUp(): void { - $this->listenerProvider = new ListenerProviderAggregate(); - $this->eventDispatcher = new EventDispatcher($this->listenerProvider); + $dispatcher = container( + new ArrayConfig([ListenerProviderInterface::class => []]), + EventDispatcherServiceProvider::class, + )->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $this->eventDispatcher = $dispatcher; $factory = new Psr17Factory(); @@ -266,8 +266,14 @@ static function (\Throwable $throwable) use (&$throwables): void { $throwableListenerProvider = new ThrowableListenerProvider($throwableListener); - $this->listenerProvider->attach($errorEventListenerProvider); - $this->listenerProvider->attach($throwableListenerProvider); + $dispatcher = container( + new ArrayConfig([ + ListenerProviderInterface::class => [$errorEventListenerProvider, $throwableListenerProvider], + ]), + EventDispatcherServiceProvider::class, + )->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $this->eventDispatcher = $dispatcher; $this->services[EventDispatcherInterface::class] = $this->eventDispatcher; diff --git a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php index b5b8390..6bac1df 100644 --- a/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php +++ b/tests/Functional/ServiceProvider/ErrorHandlerServiceProviderTest.php @@ -28,8 +28,12 @@ use CoiSA\ErrorHandler\Handler\PhpErrorHandlerInterface; use CoiSA\ErrorHandler\Handler\ShutdownHandlerInterface; use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface; +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\GenericThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; @@ -75,6 +79,79 @@ #[CoversNothing] final class ErrorHandlerServiceProviderTest extends TestCase { + /** A supplied Console destination cannot activate diagnostics without the explicit CLI option. */ + public function testCliFalseDoesNotPrintEvenWithConfiguredConsoleOutput(): void + { + $output = new BufferedOutput(); + $before = self::callbacks(); + $services = container(new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ]), new ErrorHandlerServiceProvider(cli: false)); + $handler = $services->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + self::assertSame($before, self::callbacks()); + + $handler->handleThrowable(new \RuntimeException('CLI must remain explicitly selected')); + + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + } + + /** A selected CLI profile composes the real adapter without registering process-wide callbacks. */ + public function testCliTrueWritesToApplicationOutputWithoutInstallingCallbacks(): void + { + $output = new BufferedOutput(); + $before = self::callbacks(); + $services = container(new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ]), new ErrorHandlerServiceProvider(cli: true)); + $handler = $services->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + $original = new \RuntimeException('CLI provider diagnostic', 503); + $trace = $original->getTrace(); + + $handler->handleThrowable($original); + $text = $output->fetch(); + + self::assertStringContainsString('CLI provider diagnostic', $text); + self::assertStringContainsString('RuntimeException', $text); + self::assertSame($trace, $original->getTrace()); + self::assertSame($before, self::callbacks()); + } + + /** An application reporter override wins and does not resolve or print through the CLI adapter. */ + public function testApplicationReporterOverridePreventsCliResolutionAndOutput(): void + { + $received = []; + $reporter = new CallableThrowableHandler( + /** @param \Throwable $event the unchanged original failure received privately */ + static function (\Throwable $event) use (&$received): void { + $received[] = $event; + }, + ); + $output = new BufferedOutput(); + $before = self::callbacks(); + $services = container(new ArrayServiceProvider([ + OutputInterface::class => new ServiceFactory($output), + ThrowableHandlerInterface::class => new ServiceFactory($reporter), + SymfonyCliThrowableHandler::class => static function (): never { + throw new \LogicException('An application reporter override must not resolve the CLI adapter.'); + }, + ]), new ErrorHandlerServiceProvider(cli: true)); + self::assertSame($reporter, $services->get(ThrowableHandlerInterface::class)); + $handler = $services->get(ErrorHandlerInterface::class); + self::assertInstanceOf(ErrorHandler::class, $handler); + $original = new \RuntimeException('Private application override'); + + $handler->handleThrowable($original); + + self::assertSame([$original], $received); + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + } + /** * Verifies constructor autowiring preserves the provider's public interface aliases. */ @@ -92,7 +169,7 @@ public function testHttpAdaptersAreAutowiredAndShareTheirInterfaceServices(): vo ); self::assertInstanceOf(ThrowableResponseFactory::class, $container->get(ThrowableResponseFactory::class)); self::assertSame( - $container->get(ThrowableResponseFactory::class), + $container->get(NegotiatingThrowableResponseFactory::class), $container->get(ThrowableResponseFactoryInterface::class), ); } @@ -130,11 +207,14 @@ public function testApplicationCanOverrideAnAutowiredConcreteResponseAdapter(): ), $psr17); $container = self::services([ThrowableResponseFactory::class => $renderer]); - self::assertSame($renderer, $container->get(ThrowableResponseFactoryInterface::class)); - self::assertSame( - 'Application response', - (string) $renderer->createResponseFromThrowable(new \RuntimeException())->getBody(), - ); + $negotiator = $container->get(ThrowableResponseFactoryInterface::class); + self::assertInstanceOf(NegotiatingThrowableResponseFactory::class, $negotiator); + self::assertSame($renderer, $container->get(ThrowableResponseFactory::class)); + self::assertNotSame($renderer, $negotiator); + $response = $negotiator->createResponseFromThrowable(new \RuntimeException(), self::request()); + self::assertSame('Application response', (string) $response->getBody()); + self::assertSame('text/plain', $response->getHeaderLine('Content-Type')); + self::assertSame('Accept', $response->getHeaderLine('Vary')); } /** @@ -730,12 +810,15 @@ public function __construct(private \Throwable $failure) /** * Throws the configured rendering failure to exercise callback restoration. * - * @param \Throwable $throwable the throwable being rendered + * @param \Throwable $throwable the throwable being rendered + * @param ServerRequestInterface $request the current request being rendered * * @return ResponseInterface the fixture result */ - public function createResponseFromThrowable(\Throwable $throwable): ResponseInterface - { + public function createResponseFromThrowable( + \Throwable $throwable, + ServerRequestInterface $request, + ): ResponseInterface { throw $this->failure; } }; diff --git a/tests/Production/container.php b/tests/Production/container.php index 509363d..6b8cc89 100644 --- a/tests/Production/container.php +++ b/tests/Production/container.php @@ -23,15 +23,14 @@ use CoiSA\ErrorHandler\Http\Message\JsonThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; -use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory; use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactoryInterface; use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactoryInterface; use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory; use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory; -use CoiSA\ErrorHandler\ServiceProvider\JsonErrorHandlerServiceProvider; -use CoiSA\ErrorHandler\ServiceProvider\WhoopsErrorHandlerServiceProvider; +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\NegotiatingThrowableResponseFactory; use FastForward\Container\ServiceProvider\ArrayServiceProvider; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -177,8 +176,8 @@ public function createStreamFromResource($resource): StreamInterface JsonThrowableResponseFactory::class, ReportingThrowableResponseFactory::class, WhoopsThrowableResponseFactory::class, - JsonErrorHandlerServiceProvider::class, - WhoopsErrorHandlerServiceProvider::class, + HtmlThrowableResponseFactory::class, + NegotiatingThrowableResponseFactory::class, ]; foreach ($publicClasses as $class) { @@ -187,9 +186,8 @@ public function createStreamFromResource($resource): StreamInterface } } - // Declaring optional profile maps must not construct a Whoops page or register callbacks. - (new JsonErrorHandlerServiceProvider())->getFactories(); - (new WhoopsErrorHandlerServiceProvider())->getFactories(); + // Discovering representations must not construct a diagnostic page or register callbacks. + (new ErrorHandlerServiceProvider())->getFactories(); $services = container(ErrorHandlerServiceProvider::class, $application); $handler = $services->get(ErrorHandlerInterface::class); @@ -199,7 +197,8 @@ public function createStreamFromResource($resource): StreamInterface if ( !$handler instanceof ErrorHandler || !$aggregate instanceof ThrowableHandlerAggregate - || !$streams instanceof GenericThrowableStreamFactory || !$responses instanceof ThrowableResponseFactory + || !$streams instanceof GenericThrowableStreamFactory + || !$responses instanceof NegotiatingThrowableResponseFactory ) { throw new RuntimeException('The Interop provider must resolve its actual default classes.'); } @@ -208,7 +207,7 @@ public function createStreamFromResource($resource): StreamInterface $services->get(ErrorHandler::class) !== $handler || $services->get(ThrowableHandlerAggregate::class) !== $aggregate || $services->get(GenericThrowableStreamFactory::class) !== $streams - || $services->get(ThrowableResponseFactory::class) !== $responses + || $services->get(NegotiatingThrowableResponseFactory::class) !== $responses ) { throw new RuntimeException('Provider aliases must preserve the cached concrete service identities.'); } diff --git a/tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php b/tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php new file mode 100644 index 0000000..9882329 --- /dev/null +++ b/tests/Unit/Handler/SymfonyCliThrowableHandlerTest.php @@ -0,0 +1,225 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit\Handler; + +use CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +/** + * Verifies original terminal diagnostics, raw Console delivery and native stderr ownership. + * + * @internal + */ +#[CoversClass(SymfonyCliThrowableHandler::class)] +final class SymfonyCliThrowableHandlerTest extends TestCase +{ + /** Every diagnostic must use its selected destination rather than ambient stdout. */ + protected function setUp(): void + { + $this->expectOutputString(''); + } + + /** Real BufferedOutput preserves diagnostic markup and leaves the original throwable unchanged. */ + public function testBufferedOutputReceivesTheOriginalDiagnosticAsRawText(): void + { + $failure = $this->createDiagnostic(); + $trace = $failure->getTrace(); + $previous = $failure->getPrevious(); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, true); + $before = self::callbacks(); + $handler = new SymfonyCliThrowableHandler($output); + self::assertSame('', $output->fetch()); + self::assertSame($before, self::callbacks()); + + $handler->handleThrowable($failure); + $text = $output->fetch(); + + self::assertStringContainsString('CLI synthetic failure', $text); + self::assertStringContainsString('CLI previous cause', $text); + self::assertStringContainsString('RuntimeException', $text); + self::assertStringContainsString('createDiagnostic', $text); + self::assertStringContainsString(basename($failure->getFile()), $text); + self::assertSame($trace, $failure->getTrace()); + self::assertSame($previous, $failure->getPrevious()); + self::assertSame(503, $failure->getCode()); + self::assertSame($before, self::callbacks()); + } + + /** ConsoleOutput's separate error channel receives one write while stdout remains untouched. */ + public function testConsoleOutputUsesItsErrorChannelWithoutParsingMarkup(): void + { + $errorOutput = $this->createMock(OutputInterface::class); + $errorOutput->expects(self::once())->method('write')->with( + self::stringContains('Console channel failure'), + false, + OutputInterface::OUTPUT_RAW, + ); + $errorOutput->expects(self::never())->method('writeln'); + $console = $this->createMock(ConsoleOutputInterface::class); + $console->expects(self::once())->method('getErrorOutput')->willReturn($errorOutput); + $console->expects(self::never())->method('write'); + $console->expects(self::never())->method('writeln'); + + (new SymfonyCliThrowableHandler($console))->handleThrowable(new \Error('Console channel failure')); + } + + /** Output failures propagate unchanged without a retry or native callback mutation. */ + public function testOutputFailureRemainsIdentical(): void + { + $failure = new \LogicException('Console transport failed'); + $output = $this->createMock(OutputInterface::class); + $output->expects(self::once())->method('write')->with( + self::stringContains('Original terminal diagnostic'), + false, + OutputInterface::OUTPUT_RAW, + )->willThrowException($failure); + $output->expects(self::never())->method('writeln'); + $before = self::callbacks(); + + try { + (new SymfonyCliThrowableHandler($output))->handleThrowable( + new \RuntimeException('Original terminal diagnostic'), + ); + self::fail('The application output failure must propagate.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + self::assertSame($before, self::callbacks()); + } + + /** A failed error-channel lookup cannot fall back to standard output or native stderr. */ + public function testErrorChannelFailureRemainsIdentical(): void + { + $failure = new \LogicException('Error channel unavailable'); + $console = $this->createMock(ConsoleOutputInterface::class); + $console->expects(self::once())->method('getErrorOutput')->willThrowException($failure); + $console->expects(self::never())->method('write'); + $console->expects(self::never())->method('writeln'); + + try { + (new SymfonyCliThrowableHandler($console))->handleThrowable( + new \RuntimeException('Original terminal diagnostic'), + ); + self::fail('The error-channel failure must propagate.'); + } catch (\LogicException $caught) { + self::assertSame($failure, $caught); + } + } + + /** Sequential calls create current diagnostics without retaining the previous Throwable or request. */ + public function testSequentialDiagnosticsDoNotRetainEarlierFailures(): void + { + $output = new BufferedOutput(); + $handler = new SymfonyCliThrowableHandler($output); + $handler->handleThrowable(new \RuntimeException('FIRST_CLI_DIAGNOSTIC')); + $first = $output->fetch(); + $handler->handleThrowable(new \Error('SECOND_CLI_DIAGNOSTIC')); + $second = $output->fetch(); + + self::assertStringContainsString('FIRST_CLI_DIAGNOSTIC', $first); + self::assertStringContainsString('SECOND_CLI_DIAGNOSTIC', $second); + self::assertStringNotContainsString('FIRST_CLI_DIAGNOSTIC', $second); + } + + /** Native stderr delivery works without loading the optional Console package or touching stdout. */ + public function testNativeStderrDoesNotRequireConsoleOrTerminateTheCaller(): void + { + $autoload = \dirname(__DIR__, 3) . '/vendor/autoload.php'; + $code = <<<'PHP' + $loader = require $argv[1]; + $loader->setPsr4('Symfony\\Component\\Console\\', []); + spl_autoload_register(static function (string $class): void { + if (str_starts_with($class, 'Symfony\\Component\\Console\\')) { + throw new LogicException('Optional Console must not be loaded for native stderr.'); + } + }, true, true); + $handler = new CoiSA\ErrorHandler\Handler\SymfonyCliThrowableHandler(); + $handler->handleThrowable(new RuntimeException('NATIVE_CLI_DIAGNOSTIC')); + $handler->handleThrowable(new Error('SECOND_NATIVE_CLI_DIAGNOSTIC')); + echo "caller continued\n"; + PHP; + $environment = array_fill_keys(array_keys(getenv()), false); + $process = new Process([ + PHP_BINARY, '-d', 'memory_limit=64M', '-d', 'display_errors=stderr', + '-d', 'log_errors=0', '-d', 'error_reporting=-1', '-r', $code, $autoload, + ], null, $environment, null, 2.0); + $bytes = 0; + + try { + $process->run( + /** + * Retains the existing native-process output bound without storing ambient HTML or environment data. + * + * @param string $type the stdout or stderr pipe + * @param string $chunk newly received diagnostic bytes + * + * @throws \LengthException when the synthetic fixture exceeds 64 KiB + */ + static function (string $type, string $chunk) use (&$bytes): void { + $bytes += \strlen($chunk); + if ($bytes > 65536) { + throw new \LengthException('CLI diagnostic fixture exceeded 64 KiB.'); + } + }, + ); + } finally { + $process->stop(0); + } + + self::assertFalse($process->isRunning()); + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertSame("caller continued\n", $process->getOutput()); + self::assertStringContainsString('NATIVE_CLI_DIAGNOSTIC', $process->getErrorOutput()); + self::assertStringContainsString('SECOND_NATIVE_CLI_DIAGNOSTIC', $process->getErrorOutput()); + self::assertStringNotContainsString('Optional Console must not be loaded', $process->getErrorOutput()); + self::assertStringNotContainsString('Warning:', $process->getErrorOutput()); + } + + /** @return \RuntimeException a real named frame and previous cause without application credentials */ + private function createDiagnostic(): \RuntimeException + { + return new \RuntimeException( + 'CLI synthetic failure', + 503, + new \RuntimeException('CLI previous cause'), + ); + } + + /** + * Reads active native callbacks while restoring both temporary probe stacks. + * + * @return array{null|callable, null|callable} current error and exception handlers + */ + private static function callbacks(): array + { + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler( + /** @param \Throwable $event the unused native probe input */ + static function (\Throwable $event): void { + }, + ); + restore_exception_handler(); + + return [$error, $exception]; + } +} diff --git a/tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php b/tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php new file mode 100644 index 0000000..43b4822 --- /dev/null +++ b/tests/Unit/Http/Message/HtmlThrowableResponseFactoryTest.php @@ -0,0 +1,328 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace CoiSA\ErrorHandler\Test\Unit\Http\Message; + +use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; + +/** + * Verifies public HTML, trusted diagnostics and the pure Symfony/PSR-17 boundary. + * + * @internal + */ +#[CoversClass(HtmlThrowableResponseFactory::class)] +final class HtmlThrowableResponseFactoryTest extends TestCase +{ + /** Requires both rendering modes to return bytes without emitting them. */ + protected function setUp(): void + { + $this->expectOutputString(''); + } + + /** + * Preserves integer HTTP error codes without disclosing the original diagnostic. + * + * @param int|string $code the native error code or a PDO-style string + * @param int $status the library's expected HTTP status + */ + #[DataProvider('providePublicHtmlPreservesStatusesAndDoesNotDiscloseDiagnosticsCases')] + public function testPublicHtmlPreservesStatusesAndDoesNotDiscloseDiagnostics(int|string $code, int $status): void + { + $failure = new class ($code) extends \PDOException { + /** @param int|string $code the driver-free native exception code */ + public function __construct(int|string $code) + { + parent::__construct('PRIVATE_HTML_DIAGNOSTIC', 0, new \RuntimeException('PRIVATE_PREVIOUS_CAUSE')); + $this->code = $code; + } + }; + $trace = $failure->getTrace(); + $messages = new Psr17Factory(); + $request = (new ServerRequest('GET', 'https://example.test/private')) + ->withHeader('Accept', 'text/html') + ->withHeader('X-Debug', '1') + ->withAttribute('debug', true) + ; + $response = (new HtmlThrowableResponseFactory($messages, $messages)) + ->createResponseFromThrowable($failure, $request) + ; + $body = (string) $response->getBody(); + + self::assertSame($status, $response->getStatusCode()); + self::assertSame($messages->createResponse($status)->getReasonPhrase(), $response->getReasonPhrase()); + self::assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame('', $response->getHeaderLine('Cache-Control')); + self::assertFalse($response->hasHeader('X-Debug-Exception')); + self::assertFalse($response->hasHeader('X-Debug-Exception-File')); + self::assertStringStartsWith('', $body); + self::assertStringContainsString('getFile(), $body); + self::assertSame($trace, $failure->getTrace()); + self::assertSame($code, $failure->getCode()); + self::assertSame('text/html', $request->getHeaderLine('Accept')); + self::assertTrue($request->getAttribute('debug')); + } + + /** @return array real HTTP boundaries and type-sensitive fallbacks */ + public static function providePublicHtmlPreservesStatusesAndDoesNotDiscloseDiagnosticsCases(): iterable + { + return [ + 'below range' => [399, 500], + 'lower boundary' => [400, 400], + 'not found' => [404, 404], + 'nonstandard client status' => [499, 499], + 'server boundary' => [500, 500], + 'service unavailable' => [503, 503], + 'upper boundary' => [599, 599], + 'above range' => [600, 500], + 'default' => [0, 500], + 'negative' => [-1, 500], + 'HTTP-looking string' => ['404', 500], + 'numeric SQLSTATE' => ['23000', 500], + 'alphanumeric SQLSTATE' => ['HY000', 500], + ]; + } + + /** Diagnostic HTML is a complete document with the original trace and correct displayed status. */ + public function testExplicitDebugIncludesTheOriginalDiagnosticAndTraceInACompleteDocument(): void + { + $failure = $this->createDiagnosticThrowable(); + $trace = $failure->getTrace(); + $messages = new Psr17Factory(); + $request = (new ServerRequest('POST', 'https://example.test/debug')) + ->withHeader('Accept', 'application/json') + ->withAttribute('debug', false) + ; + + $response = (new HtmlThrowableResponseFactory($messages, $messages, true)) + ->createResponseFromThrowable($failure, $request) + ; + $body = (string) $response->getBody(); + + self::assertSame(503, $response->getStatusCode()); + self::assertSame('Service Unavailable', $response->getReasonPhrase()); + self::assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame('no-store', $response->getHeaderLine('Cache-Control')); + self::assertStringStartsWith('', $body); + self::assertStringContainsString('', $body); + self::assertStringContainsString('', $body); + self::assertStringContainsString('503 Service Unavailable', $body); + self::assertStringContainsString('