Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 51 additions & 3 deletions src/ErrorBoundary.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace CleatSquad\ErrorBoundary;

use Psr\Log\LoggerInterface;
use Throwable;

/**
Expand All @@ -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
{
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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.
*
Expand Down
86 changes: 86 additions & 0 deletions tests/ErrorBoundaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
}
}