From ba5c7f9193cccc39c602e23ca16f9da1a97707b2 Mon Sep 17 00:00:00 2001 From: Mohamed El Mrabet Date: Fri, 4 Sep 2026 22:35:39 +0100 Subject: [PATCH] Add optional PSR-3 logger and uninstall() install() accepts a PSR-3 LoggerInterface as a second argument: the uncaught-exception line goes through $logger->error() (exception as context) instead of error_log() when supplied. uninstall() restores PHP's default exception handler and makes the shutdown handler already registered a no-op (PHP has no unregister_shutdown_function()). Clears mapper, logger and shutdown interceptor so a later install() starts clean. isInstalled() added. New required dependency: psr/log ^3.0. --- CHANGELOG.md | 17 ++++++++ README.md | 25 +++++++++++ composer.json | 3 +- src/ErrorBoundary.php | 54 +++++++++++++++++++++-- tests/ErrorBoundaryTest.php | 86 +++++++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7035098..8aba087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to `cleatsquad/php-error-boundary` will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-09-04 + +### Added + +- `ErrorBoundary::install()` accepts an optional PSR-3 `LoggerInterface` as a + second argument. When supplied, the uncaught-exception line goes through + `$logger->error()` (with the exception as context) instead of `error_log()`. +- `ErrorBoundary::uninstall()`, restoring PHP's default exception handler and + making the shutdown handler already registered a no-op. Clears the mapper, + logger and shutdown interceptor so a later `install()` starts clean. +- `ErrorBoundary::isInstalled()`. + +### Changed + +- New required dependency: `psr/log` (`^3.0`). + ## [1.0.0] - 2026-09-04 Initial release. @@ -27,4 +43,5 @@ Initial release. path, or class name to the client — only a fixed code and a generic message. +[1.1.0]: https://github.com/CleatSquad/php-error-boundary/releases/tag/v1.1.0 [1.0.0]: https://github.com/CleatSquad/php-error-boundary/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 0873ef8..e236223 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,31 @@ final class MyMapper implements ErrorResponseMapperInterface ErrorBoundary::install(new MyMapper()); ``` +### Send the uncaught-exception line through your own logger + +```php +ErrorBoundary::install(null, $psr3Logger); +``` + +When a PSR-3 `LoggerInterface` is supplied, the uncaught-exception line goes +through `$logger->error()` (the exception is passed as `['exception' => $e]` +context) instead of `error_log()`. Fatals caught by the shutdown handler are +not logged here — PHP has already written them to the error log itself by +the time it fires. + +### Uninstall + +```php +ErrorBoundary::uninstall(); +``` + +Restores PHP's default exception handler and makes the boundary inert for +the shutdown handler already registered (PHP has no +`unregister_shutdown_function()`, so it stays registered but becomes a +no-op). Clears the mapper, logger and shutdown interceptor, so a later +`install()` starts clean. Useful in tests, or when handing control back to +another error-handling system for the rest of the process. + ### Intercept before the standard response is emitted Useful for a response format the boundary doesn't own by default — an diff --git a/composer.json b/composer.json index fb0fc96..0cc9ed7 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "source": "https://github.com/CleatSquad/php-error-boundary" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "psr/log": "^3.0" }, "require-dev": { "phpstan/phpstan": "^2.0", diff --git a/src/ErrorBoundary.php b/src/ErrorBoundary.php index 27c66db..ea748dd 100644 --- a/src/ErrorBoundary.php +++ b/src/ErrorBoundary.php @@ -4,6 +4,7 @@ namespace CleatSquad\ErrorBoundary; +use Psr\Log\LoggerInterface; use Throwable; /** @@ -19,8 +20,10 @@ final class ErrorBoundary public const FATAL_LEVELS = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR; private static ?ErrorResponseMapperInterface $mapper = null; + private static ?LoggerInterface $logger = null; /** @var (callable(array{type: int, message: string, file?: string, line?: int}): bool)|null */ private static mixed $shutdownInterception = null; + private static bool $installed = false; public static function setMapper(ErrorResponseMapperInterface $mapper): void { @@ -38,24 +41,44 @@ public static function setShutdownInterception(?callable $interception): void self::$shutdownInterception = $interception; } - public static function install(?ErrorResponseMapperInterface $mapper = null): void + /** + * @param ?LoggerInterface $logger Receives the uncaught-exception line this + * boundary would otherwise send to error_log(). + * Fatals caught by the shutdown handler are not + * logged here: PHP has already written them to + * the error log itself by the time it fires. + */ + public static function install(?ErrorResponseMapperInterface $mapper = null, ?LoggerInterface $logger = null): void { if ($mapper !== null) { self::$mapper = $mapper; } + if ($logger !== null) { + self::$logger = $logger; + } + self::$installed = true; // The body belongs to the response, never to PHP's error reporting. ini_set('display_errors', '0'); ini_set('log_errors', '1'); set_exception_handler(static function (Throwable $e): void { - error_log(sprintf( + if (!self::$installed) { + return; + } + + $message = sprintf( 'Uncaught %s: %s in %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine() - )); + ); + if (self::$logger !== null) { + self::$logger->error($message, ['exception' => $e]); + } else { + error_log($message); + } $error = [ 'type' => E_ERROR, @@ -73,6 +96,10 @@ public static function install(?ErrorResponseMapperInterface $mapper = null): vo }); register_shutdown_function(static function (): void { + if (!self::$installed) { + return; + } + $error = error_get_last(); if ($error === null || ($error['type'] & self::FATAL_LEVELS) === 0) { return; @@ -87,6 +114,27 @@ public static function install(?ErrorResponseMapperInterface $mapper = null): vo }); } + /** + * Restores PHP's default exception handler and marks this boundary inert + * for the shutdown function already registered — PHP has no + * `unregister_shutdown_function()`, so the callback stays registered but + * becomes a no-op. A later `install()` reactivates it. Mapper, logger and + * shutdown interceptor are cleared so a fresh `install()` starts clean. + */ + public static function uninstall(): void + { + self::$installed = false; + self::$mapper = null; + self::$logger = null; + self::$shutdownInterception = null; + restore_exception_handler(); + } + + public static function isInstalled(): bool + { + return self::$installed; + } + /** * Maps an error to status code and payload using current or default mapper. * diff --git a/tests/ErrorBoundaryTest.php b/tests/ErrorBoundaryTest.php index ed1a77b..553a856 100644 --- a/tests/ErrorBoundaryTest.php +++ b/tests/ErrorBoundaryTest.php @@ -8,6 +8,8 @@ use CleatSquad\ErrorBoundary\ErrorBoundary; use CleatSquad\ErrorBoundary\ErrorResponseMapperInterface; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; final class ErrorBoundaryTest extends TestCase { @@ -75,4 +77,88 @@ public function testTheClientIsToldNothingAboutTheInternals(): void $this->assertStringNotContainsString('/app/', $payload['error']['message']); $this->assertStringNotContainsString('RetryingDriver', $payload['error']['message']); } + + protected function tearDown(): void + { + // install() leaves a process-global exception handler behind; every + // test that calls it must not leak into the next one. + ErrorBoundary::uninstall(); + ErrorBoundary::setMapper(new DefaultErrorResponseMapper()); + } + + public function testInstallReportsItselfAsInstalled(): void + { + $this->assertFalse(ErrorBoundary::isInstalled()); + + ErrorBoundary::install(); + + $this->assertTrue(ErrorBoundary::isInstalled()); + } + + public function testUninstallReportsItselfAsNotInstalled(): void + { + ErrorBoundary::install(); + ErrorBoundary::uninstall(); + + $this->assertFalse(ErrorBoundary::isInstalled()); + } + + public function testInstalledLoggerReceivesTheUncaughtExceptionLine(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('Uncaught RuntimeException: boom'), + $this->arrayHasKey('exception') + ); + + ErrorBoundary::install(null, $logger); + $handler = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + if (!is_callable($handler)) { + self::fail('set_exception_handler() did not return a callable.'); + } + + ob_start(); + $handler(new RuntimeException('boom')); + ob_end_clean(); + } + + public function testWithNoLoggerTheHandlerStillAnswersWithoutThrowing(): void + { + ErrorBoundary::install(); + $handler = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + if (!is_callable($handler)) { + self::fail('set_exception_handler() did not return a callable.'); + } + + ob_start(); + $handler(new RuntimeException('no logger configured')); + $output = ob_end_clean(); + + $this->assertTrue($output !== false); + } + + public function testUninstallMakesTheCapturedExceptionHandlerInert(): void + { + ErrorBoundary::install(); + $handler = set_exception_handler(static function (): void { + }); + restore_exception_handler(); + if (!is_callable($handler)) { + self::fail('set_exception_handler() did not return a callable.'); + } + + ErrorBoundary::uninstall(); + + ob_start(); + $handler(new RuntimeException('should be ignored')); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } }