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
27 changes: 27 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,33 @@ jobs:
- name: Run tests
run: vendor/bin/phpunit

coverage:
name: Coverage
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: pcov

- name: Install dependencies
uses: ramsey/composer-install@4.0.0

- name: Run tests with coverage
run: vendor/bin/phpunit --coverage-clover=coverage.xml

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

static-analysis:
name: Static Analysis
runs-on: ubuntu-latest
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PHP Error Boundary

[![Tests](https://github.com/CleatSquad/php-error-boundary/actions/workflows/tests.yml/badge.svg)](https://github.com/CleatSquad/php-error-boundary/actions/workflows/tests.yml)
[![codecov](https://codecov.io/gh/CleatSquad/php-error-boundary/branch/main/graph/badge.svg)](https://codecov.io/gh/CleatSquad/php-error-boundary)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.2-777bb4.svg)](composer.json)

Expand Down Expand Up @@ -89,6 +91,19 @@ ErrorBoundary::setShutdownInterception(function (array $error): bool {
});
```

## Examples

Full, runnable-style snippets for common setups live in
[`examples/`](examples/):

- [`examples/basic.php`](examples/basic.php) — installing at the entry
point of a plain PHP script.
- [`examples/psr3-logger.php`](examples/psr3-logger.php) — wiring a PSR-3
logger (Monolog, or any other implementation).
- [`examples/slim.php`](examples/slim.php) — a Slim 4 application.
- [`examples/sse-interception.php`](examples/sse-interception.php) —
intercepting a fatal mid-stream on an active Server-Sent Events response.

## Design notes

**No stack trace, no file path, ever reaches the client.** The default
Expand Down
31 changes: 31 additions & 0 deletions examples/basic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/**
* Minimal setup: install the boundary once, at the entry point of any
* script that answers JSON, before any business logic runs.
*
* Run it: php examples/basic.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use CleatSquad\ErrorBoundary\ErrorBoundary;

ErrorBoundary::install();

header('Content-Type: application/json');

// Anything below this line is covered: an uncaught exception or a PHP
// fatal now answers with the boundary's JSON envelope instead of an HTML
// error page.
function handleRequest(): array
{
// Simulate a bug: a method call on a null value deep in application code.
$user = null;

return ['name' => $user->getName()];
}

echo json_encode(handleRequest());
38 changes: 38 additions & 0 deletions examples/psr3-logger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* Sending the uncaught-exception line through a PSR-3 logger instead of
* error_log(). Works with any PSR-3 implementation — Monolog is shown here
* because it is the most common one, not because this library depends on
* it (it doesn't: only psr/log is required).
*
* Run it (after `composer require --dev monolog/monolog` in your own
* project): php examples/psr3-logger.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use CleatSquad\ErrorBoundary\ErrorBoundary;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;

// 12-factor style: write structured logs to stdout, let the container
// runtime (Docker, FrankenPHP, Kubernetes) collect them from there.
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('php://stdout'));

ErrorBoundary::install(null, $logger);

header('Content-Type: application/json');

function handleRequest(): array
{
throw new RuntimeException('Upstream payment provider is unreachable.');
}

echo json_encode(handleRequest());

// stdout now carries a structured log line for this exception, in addition
// to the JSON envelope the client received — one event, two destinations.
41 changes: 41 additions & 0 deletions examples/slim.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/**
* Slim 4 already has its own error middleware for caught exceptions
* thrown inside a route — this boundary is not a replacement for that.
* Its job is what Slim's middleware cannot reach: a PHP fatal (a timeout,
* a memory limit) that never propagates as a Throwable at all, and would
* otherwise write an HTML error page into a JSON API's response body.
*
* Run it (after `composer require --dev slim/slim slim/psr7` in your own
* project): php -S localhost:8080 examples/slim.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use CleatSquad\ErrorBoundary\ErrorBoundary;
use Slim\Factory\AppFactory;

// Installed first, before Slim's own middleware stack: it only ever answers
// when nothing else — including Slim's error middleware — got the chance to.
ErrorBoundary::install();

$app = AppFactory::create();

$app->get('/status', function ($request, $response) {
$response->getBody()->write(json_encode(['status' => 'ok']));

return $response->withHeader('Content-Type', 'application/json');
});

$app->get('/crash', function () {
// A memory_limit exhaustion here (not simulated — genuinely uncatchable)
// is exactly the case Slim's own error middleware cannot see coming:
// it is a PHP fatal, not a Throwable. ErrorBoundary's shutdown handler
// answers with the same JSON envelope regardless.
throw new RuntimeException('Unreachable in this example — illustrates where the boundary sits.');
});

$app->run();
46 changes: 46 additions & 0 deletions examples/sse-interception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/**
* A Server-Sent Events stream is not a fresh HTTP response by the time a
* fatal happens mid-stream: headers are already sent, and the client is
* mid-way through reading `event:`/`data:` frames. Emitting the boundary's
* standard JSON body there would corrupt the stream. The shutdown
* interceptor lets the SSE endpoint answer with one last, well-formed SSE
* event instead, and tell the boundary to skip its own emit.
*
* Run it: php examples/sse-interception.php
*/

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use CleatSquad\ErrorBoundary\ErrorBoundary;

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');

ErrorBoundary::install();

ErrorBoundary::setShutdownInterception(function (array $error): bool {
// Headers are already sent (text/event-stream) — frame the error as one
// more SSE event instead of letting the boundary attempt a fresh
// http_response_code()/header() pair.
echo "event: error\n";
echo 'data: ' . json_encode(['message' => 'The stream ended unexpectedly.']) . "\n\n";

return true; // Skip the boundary's standard JSON emit — already answered.
});

function streamEvents(): void
{
echo "event: message\n";
echo 'data: ' . json_encode(['step' => 1]) . "\n\n";
flush();

// Simulate a bug partway through the stream.
$state = null;
echo 'data: ' . json_encode(['step' => $state->next()]) . "\n\n";
}

streamEvents();
Loading