Skip to content

Latest commit

 

History

History
1711 lines (1383 loc) · 63.6 KB

examples.md

File metadata and controls

1711 lines (1383 loc) · 63.6 KB

Executable cookbook

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.

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 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 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.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;

require __DIR__ . '/bootstrap.php';

$reporter = new CallableThrowableHandler(static function (Throwable $error): void {
    fwrite(STDERR, $error::class . ': ' . $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.');

Interop service provider

Resolve real FastForward definitions without installing callbacks during lookup. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandlerInterface;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
use Interop\Container\ServiceProviderInterface;

use function FastForward\Container\container;

require __DIR__ . '/bootstrap.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 [
            CallableThrowableHandler::class => static fn(): CallableThrowableHandler => new CallableThrowableHandler(
                /**
                 * Prints only this CLI recipe's synthetic diagnostic.
                 *
                 * @param Throwable $error the converted warning supplied for reporting
                 */
                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($application, ErrorHandlerServiceProvider::class);

/**
 * @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

Deliver the same throwable in order; the existing aggregate remains fail-fast. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;

require __DIR__ . '/bootstrap.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;

PSR-14 dispatch and PSR-3 event logging

Exercise original Throwable and ErrorEvent notification paths with only an application dispatcher. The default mode keeps the full event-string logger. Run php examples/event-dispatcher.php structured for an explicit callable-listener bridge to LogThrowableHandler, retaining the original Throwable under PSR-3 context['exception'] with the exact message, configured level and application metadata. Both modes produce one raw delivery, one wrapped delivery and Private log records: 1; structured mode also checks and announces original-object context. The bridge forwards logger failures without retry or implicit discovery; the application dispatcher's failure boundaries still apply. The installed-package program shows a complete structured route with only the wrapped event delivery. See optional discovery and fallback before relying on the default reporter. Source.

<?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\Handler\LogThrowableHandler;
use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
use FastForward\Config\ArrayConfig;
use FastForward\EventDispatcher\ServiceProvider\EventDispatcherServiceProvider;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;

use function FastForward\Container\container;

require __DIR__ . '/bootstrap.php';

$mode = $argv[1] ?? 'legacy';
if (!in_array($mode, ['legacy', 'structured'], true)) {
    throw new InvalidArgumentException('Choose legacy or structured event logging.');
}

$deliveries = [];
$reported = [];

$logger = new class extends AbstractLogger {
    /**
     * @var list<array{mixed, string, array<string, mixed>}> synthetic private records
     */
    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, $context];
    }
};

$throwables = new ThrowableListenerProvider(
    // @param Throwable $error the unchanged diagnostic also retained for the context assertion
    new ThrowableCallableListener(static function (Throwable $error) use (&$deliveries, &$reported): void {
        $deliveries[] = 'throwable: ' . $error->getMessage();
        $reported[] = $error;
    }),
);
$events = new ErrorEventListenerProvider(
    // @param ErrorEventInterface $event the wrapper carrying the same original throwable
    new ErrorEventCallableListener(static function (ErrorEventInterface $event) use (&$deliveries): void {
        $deliveries[] = 'event: ' . $event->getThrowable()->getMessage();
    }),
);
if ('structured' === $mode) {
    $reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, ['channel' => 'errors']);
    $events->attach(new ErrorEventCallableListener(
        /** @param ErrorEventInterface $event the wrapper retaining the original diagnostic */
        static function (ErrorEventInterface $event) use ($reporter): void {
            $reporter->handleThrowable($event->getThrowable());
        },
    ));
} else {
    $events->attach(new LogErrorEventListener($logger, LogLevel::CRITICAL));
}

$configuration = new ArrayConfig([
    ListenerProviderInterface::class => [$throwables, $events],
]);

$services = container($configuration, EventDispatcherServiceProvider::class, ErrorHandlerServiceProvider::class);

$errors = $services->get(ErrorHandlerInterface::class);

$errors->register();

try {
    trigger_error('Synthetic warning', E_USER_WARNING);
} finally {
    $errors->unregister();
}

if ('structured' === $mode) {
    $original = $reported[0] ?? null;
    $record = $logger->records[0] ?? null;
    if (
        null === $original || 1 !== count($reported) || null === $record || 1 !== count($logger->records)
        || LogLevel::CRITICAL !== $record[0] || 'Synthetic warning' !== $record[1]
        || 2 !== count($record[2]) || 'errors' !== ($record[2]['channel'] ?? null)
        || $original !== ($record[2]['exception'] ?? null)
    ) {
        throw new RuntimeException('Expected one structured log retaining the original throwable.');
    }
}

echo implode(PHP_EOL, $deliveries) . PHP_EOL;
printf("Private log records: %d\n", count($logger->records));

if ('structured' === $mode) {
    echo 'Structured exception context: original throwable' . PHP_EOL;
}

PSR-15 middleware with the provider

Report once, render a generic response and restore the process handler. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandlerInterface;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
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 __DIR__ . '/bootstrap.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([
    CallableThrowableHandler::class => static fn(): CallableThrowableHandler => $reporter,
    ResponseFactoryInterface::class => static fn(): ResponseFactoryInterface => $psr17,
    StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17,
]);

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

$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.

Explicit JSON streams

Return application-owned JSON without echo or buffering. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Http\Message\ThrowableResponseFactory;
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 __DIR__ . '/bootstrap.php';

$privateDiagnostics = [];
$reporter = new CallableThrowableHandler(
    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 {
    /**
     * 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);
$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'));
echo (string) $response->getBody() . PHP_EOL;
printf("Private reports: %d\n", count($privateDiagnostics));

Application PHP-error and shutdown handlers

Supply explicit interface implementations and a shutdown callback. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandlerInterface;
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;

require __DIR__ . '/bootstrap.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;

/** 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(
        /**
         * Routes diagnostics to the application's private sink.
         *
         * @param Throwable $error the original diagnostic
         */
        static function (Throwable $error): void {
            error_log($error->getMessage());
        },
    ),
    PhpErrorHandlerInterface::class => static fn(): PhpErrorHandlerInterface => $phpErrors,
    ShutdownHandlerInterface::class => new ServiceFactory($shutdown),
]);

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

$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

Use factory services for a new scope; ordinary lookups remain cached. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandlerInterface;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerFactory;
use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
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 __DIR__ . '/bootstrap.php';

$reported = 0;

$psr17 = new Psr17Factory();

$reporter = new CallableThrowableHandler(static function () use (&$reported): void {
    ++$reported;
});

$application = new ArrayServiceProvider([
    CallableThrowableHandler::class => static fn(): CallableThrowableHandler => $reporter,
    ResponseFactoryInterface::class => static fn(): ResponseFactoryInterface => $psr17,
    StreamFactoryInterface::class => static fn(): StreamFactoryInterface => $psr17,
]);

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

$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);

Explicit public output capture

Capture a separate public renderer, preserving the string zero. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Http\Message\ThrowableStreamFactory;
use Nyholm\Psr7\Factory\Psr17Factory;

require __DIR__ . '/bootstrap.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);

Direct PSR-3 logging

Log exact messages with the original exception context. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
use CoiSA\ErrorHandler\Handler\LogThrowableHandler;
use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;

require __DIR__ . '/bootstrap.php';

$logger = new class extends AbstractLogger {
    /** @var list<array{level: mixed, message: mixed, context: array<array-key, mixed>}> Synthetic log records. */
    public array $records = [];

    /**
     * Record arguments without transport or output, using signatures compatible with PSR-3 1.x–3.x.
     *
     * @param mixed                   $level   the level chosen by the application
     * @param object|string           $message the logger's message argument
     * @param array<array-key, mixed> $context structured context retained without coercion
     */
    public function log($level, $message, array $context = []): void
    {
        $this->records[] = ['level' => $level, 'message' => $message, 'context' => $context];
    }
};

$context = ['component' => 'worker', 'exception' => new LogicException('Caller context')];
$failure = new RuntimeException('Synthetic worker failure');
$reporter = new LogThrowableHandler($logger, LogLevel::CRITICAL, $context);

$process = new ErrorHandler($reporter);

$process->handleThrowable($failure);

$empty = new RuntimeException('');
$process->handleThrowable($empty);

exampleCheck(2 === count($logger->records), 'Each adapter invocation must make one logger call.');
exampleCheck(LogLevel::CRITICAL === $logger->records[0]['level'], 'The configured level changed.');
exampleCheck($failure->getMessage() === $logger->records[0]['message'], 'The exact message changed.');
exampleCheck($failure === $logger->records[0]['context']['exception'], 'The original throwable must win its key.');
exampleCheck('worker' === $logger->records[0]['context']['component'], 'Caller context changed.');
exampleCheck($context['exception'] instanceof LogicException, 'Caller context was mutated.');

exampleCheck('' === $logger->records[1]['message'], 'An empty message must stay empty.');
exampleCheck($empty === $logger->records[1]['context']['exception'], 'The second original throwable changed.');

echo 'direct PSR-3 logging, exact messages and original context passed' . PHP_EOL;

Filtering

Select per invocation without changing the event or defaults. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Handler\FilteringThrowableHandler;
use CoiSA\ErrorHandler\Handler\ThrowableHandlerAggregate;

require __DIR__ . '/bootstrap.php';

$all = [];
$deprecations = [];
$allReports = new CallableThrowableHandler(
    /**
     * Retain the general diagnostic branch alongside the selected branch.
     *
     * @param Throwable $error the original synthetic failure
     */
    static function (Throwable $error) use (&$all): void {
        $all[] = $error;
    },
);
$deprecationReports = new CallableThrowableHandler(
    /**
     * Record selected events without output or a real logging transport.
     *
     * @param Throwable $error the selected original failure
     */
    static function (Throwable $error) use (&$deprecations): void {
        $deprecations[] = $error;
    },
);

$filtered = new FilteringThrowableHandler(
    $deprecationReports,
    /**
     * Select PHP deprecations by severity, not arbitrary exception codes.
     *
     * @param Throwable $error the original failure being selected
     *
     * @return bool true forwards this event to the deprecation branch
     */
    static fn(Throwable $error): bool => $error instanceof ErrorException
        && E_USER_DEPRECATED === $error->getSeverity(),
);
$routes = new ThrowableHandlerAggregate($allReports, $filtered);
$deprecation = new ErrorException('Synthetic deprecation', 0, E_USER_DEPRECATED);
$ordinary = new RuntimeException('Synthetic ordinary exception', E_USER_DEPRECATED);

$routes->handleThrowable($deprecation);
$routes->handleThrowable($ordinary);

exampleCheck([$deprecation, $ordinary] === $all, 'Filtering changed the general branch or object identity.');
exampleCheck([$deprecation] === $deprecations, 'Exception codes were mistaken for PHP severity.');

echo 'explicit filtering, severity selection and original-object delivery passed' . PHP_EOL;

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.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
use CoiSA\ErrorHandler\Exception\ErrorException;

require __DIR__ . '/bootstrap.php';

$calls = [];
$reports = [];
$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; normalization adds neither policy.
     *
     * @param int    $code     the synthetic PHP severity
     * @param string $message  the synthetic diagnostic message
     * @param string $filename the supplied source filename
     * @param int    $line     the supplied source line
     *
     * @throws ErrorException when this callback selects an enabled error
     */
    static function (int $code, string $message, string $filename, int $line) use (&$calls): void {
        $calls[] = [$code, $message, $filename, $line];
        if (error_reporting() & $code) {
            throw ErrorException::fromPhpError($message, $code, $code, $filename, $line);
        }
    },
    /** Count explicit active shutdown notifications without recovering or terminating a process. */
    static function () use (&$shutdowns): void {
        ++$shutdowns;
    },
);

exampleCheck([] === $calls && [] === $reports && 0 === $shutdowns, 'Construction invoked a callback.');
$previous = error_reporting(E_ALL);

try {
    $errors->handlePhpError(E_USER_WARNING, 'Synthetic enabled input', __FILE__, 42);
    error_reporting(0);
    $errors->handlePhpError(E_USER_WARNING, 'Synthetic disabled input', __FILE__, 43);
} finally {
    error_reporting($previous);
}

exampleCheck([
    [E_USER_WARNING, 'Synthetic enabled input', __FILE__, 42],
    [E_USER_WARNING, 'Synthetic disabled input', __FILE__, 43],
] === $calls, 'The callback did not receive the four original arguments.');
exampleCheck(
    1 === count($reports) && $reports[0] instanceof ErrorException,
    'Explicit mask/conversion policy changed.',
);
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;

Best-effort reporting

Attempt every fixed slot in order, then retain the original and all reporter failures. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\Exception\AggregateReportingException;
use CoiSA\ErrorHandler\Handler\BestEffortThrowableHandlerAggregate;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;

require __DIR__ . '/bootstrap.php';

$original = new RuntimeException('Synthetic operation failure');
$auditFailure = new RuntimeException('Synthetic audit failure');
$backupFailure = new Error('Synthetic backup failure');
$attempts = [];

$audit = new CallableThrowableHandler(
    /**
     * Record the first attempt and fail with a distinct synthetic exception.
     *
     * @param Throwable $event the original diagnostic to report
     *
     * @throws RuntimeException the exact synthetic audit failure
     */
    static function (Throwable $event) use (&$attempts, $auditFailure): void {
        $attempts[] = ['audit', $event];

        throw $auditFailure;
    },
);

$archive = new CallableThrowableHandler(
    /**
     * Record the successful middle attempt without an external transport.
     *
     * @param Throwable $event the unchanged original diagnostic
     */
    static function (Throwable $event) use (&$attempts): void {
        $attempts[] = ['archive', $event];
    },
);

$backup = new CallableThrowableHandler(
    /**
     * Record the final attempt and fail with a distinct synthetic Error.
     *
     * @param Throwable $event the unchanged original diagnostic
     *
     * @throws Error the exact synthetic backup failure
     */
    static function (Throwable $event) use (&$attempts, $backupFailure): void {
        $attempts[] = ['backup', $event];

        throw $backupFailure;
    },
);

$reporter = new BestEffortThrowableHandlerAggregate($audit, $archive, $backup);
$recordedFailure = null;

try {
    $reporter->handleThrowable($original);
} catch (AggregateReportingException $failure) {
    $recordedFailure = [
        'original' => $failure->getOriginalThrowable(),
        'failures' => $failure->getFailures(),
    ];
}

exampleCheck([
    ['audit', $original],
    ['archive', $original],
    ['backup', $original],
] === $attempts, 'Every reporter must receive the same original once, in order.');

exampleCheck([
    'original' => $original,
    'failures' => [$auditFailure, $backupFailure],
] === $recordedFailure, 'Partial success must preserve the original and every reporter failure.');

echo 'best-effort order, original cause and all reporter failures passed' . PHP_EOL;

Built-in JSON responses

Public and explicitly enabled debug JSON preserve HTTP error codes. Middleware reports privately once, then uses the pure factory. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
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;

require __DIR__ . '/bootstrap.php';

$messages = new Psr17Factory();
$mode = $argv[1] ?? 'public';
exampleCheck(in_array($mode, ['public', 'debug'], true), 'Choose the public or debug example mode.');

// This explicit CLI fixture choice stands in for trusted application configuration.
$debug = 'debug' === $mode;

/** @return RuntimeException a synthetic downstream failure with a native call frame */
$createFailure = static fn(): RuntimeException => new RuntimeException('Private synthetic upstream details', 503);

$original = $createFailure();
$reports = [];

$reporter = new CallableThrowableHandler(
    /**
     * Record the original privately without printing a public diagnostic.
     *
     * @param Throwable $error the original downstream failure
     */
    static function (Throwable $error) use (&$reports): void {
        $reports[] = $error; // Replace this recording sink with the application's private transport.
    },
);

$errors = new ErrorHandler($reporter);

$serializer = new Serializer([new ProblemNormalizer(true)], [new JsonEncoder()]);

$json = new JsonThrowableResponseFactory($messages, $messages, $serializer, $debug);

$middleware = new ErrorHandlerMiddleware($errors, $json);

$next = new class ($original) implements RequestHandlerInterface {
    /** @var Throwable the synthetic downstream failure */
    private Throwable $original;

    /**
     * Store the original event for the synthetic application route.
     *
     * @param Throwable $original the event the middleware will catch
     */
    public function __construct(Throwable $original)
    {
        $this->original = $original;
    }

    /**
     * Fail the route without printing diagnostics or emitting a response.
     *
     * @param ServerRequestInterface $request the synthetic request
     *
     * @return ResponseInterface no response is returned by this failing route
     *
     * @throws Throwable the original synthetic failure
     */
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        throw $this->original;
    }
};

$bufferLevel = ob_get_level();
$response = $middleware->process($messages->createServerRequest('GET', '/example'), $next);

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/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(
        $original->getMessage() === $payload['detail'] && [] !== $payload['trace'],
        'Explicit development metadata is missing.',
    );
    foreach ($payload['trace'] as $frame) {
        exampleCheck(
            [] === ($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(
        $publicBody === (string) $response->getBody(),
        'The generic JSON body must not contain the private diagnostic.',
    );
}
exampleCheck(
    $original->getMessage() !== $response->getReasonPhrase(),
    'The diagnostic message must not become an HTTP reason phrase.',
);
exampleCheck($bufferLevel === ob_get_level(), 'JSON composition unexpectedly changed the buffer stack.');

// The application owns response emission; this CLI recipe prints only its verification summary.
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.

<?php

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;

Explicit development HTML

Trusted configuration selects public Symfony HTML or explicit optional Whoops HTML. The application owns diagnostic policy; no request enables it. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\ErrorHandler;
use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Http\Message\HtmlThrowableResponseFactory;
use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory;
use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Whoops\Handler\PrettyPageHandler;

require __DIR__ . '/bootstrap.php';

$messages = new Psr17Factory();
$mode = $argv[1] ?? 'public';
exampleCheck(in_array($mode, ['public', 'development'], true), 'Choose public or development mode.');

// This CLI fixture represents trusted configuration for an explicitly selected HTML pipeline.
// A request header or query parameter must never enable development diagnostics.
$debug = 'development' === $mode;
$pageCalls = 0;
$reports = [];
$original = new RuntimeException('<synthetic-development-detail>', 503);

$reporter = new CallableThrowableHandler(
    /** @param Throwable $error the unchanged diagnostic delivered privately */
    static function (Throwable $error) use (&$reports): void {
        $reports[] = $error; // Replace this recording sink with the application's private transport.
    },
);

$renderer = new HtmlThrowableResponseFactory($messages, $messages);

if ($debug) {
    $renderer = new WhoopsThrowableResponseFactory(
        $messages,
        $messages,
        /** @return PrettyPageHandler a new page using the application's explicit field-hiding policy */
        static function () use (&$pageCalls): PrettyPageHandler {
            ++$pageCalls;
            $page = new PrettyPageHandler();

            $page->hideSuperglobalKey('_SERVER', 'HTTP_AUTHORIZATION');
            $page->hideSuperglobalKey('_COOKIE', 'session');
            $page->hideSuperglobalKey('_ENV', 'APP_SECRET');

            return $page;
        },
    );
}

exampleCheck(0 === $pageCalls, 'Construction must not create a page.');

$errors = new ErrorHandler($reporter);

$middleware = new ErrorHandlerMiddleware($errors, $renderer);

$next = new class ($original) implements RequestHandlerInterface {
    /** @var Throwable the synthetic application failure */
    private Throwable $original;

    /** @param Throwable $original the unchanged failure thrown by the route */
    public function __construct(Throwable $original)
    {
        $this->original = $original;
    }

    /**
     * @param ServerRequestInterface $request the synthetic application request
     *
     * @return ResponseInterface this failing route returns no normal response
     *
     * @throws Throwable the unchanged application failure
     */
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        throw $this->original;
    }
};

$bufferLevel = ob_get_level();
$response = $middleware->process($messages->createServerRequest('GET', '/example'), $next);

exampleCheck([$original] === $reports, 'Expected one private report of the original failure.');
exampleCheck(503 === $response->getStatusCode(), 'Expected the original integer HTTP status.');
exampleCheck($bufferLevel === ob_get_level(), 'The renderer changed the caller buffer stack.');
exampleCheck($original->getMessage() !== $response->getReasonPhrase(), 'The diagnostic became a reason phrase.');

if ($debug) {
    exampleCheck(1 === $pageCalls, 'Expected one fresh configured page.');
    exampleCheck('text/html; charset=UTF-8' === $response->getHeaderLine('Content-Type'), 'Expected HTML.');
    exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.');
    exampleCheck(
        str_contains((string) $response->getBody(), '&lt;synthetic-development-detail&gt;'),
        'Expected an escaped diagnostic in the development page.',
    );
} else {
    exampleCheck(0 === $pageCalls, 'Public mode must not construct a Whoops page.');
    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 negotiation with the single provider

Compose base, JSON and application maps explicitly, with a real bool for debug and a fresh middleware scope. Source.

<?php

declare(strict_types=1);

use CoiSA\ErrorHandler\Handler\CallableThrowableHandler;
use CoiSA\ErrorHandler\Handler\ThrowableHandlerInterface;
use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider;
use CoiSA\ErrorHandler\ServiceProvider\Factory\ErrorHandlerMiddlewareFactory;
use FastForward\Container\Factory\AliasFactory;
use FastForward\Container\Factory\ServiceFactory;
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\Config\config;
use function FastForward\Container\container;

require __DIR__ . '/bootstrap.php';

exampleCheck(
    PHP_VERSION_ID >= 80300 && function_exists('FastForward\Container\container'),
    'Load the FastForward runtime dependency of this source on PHP 8.3 or newer.',
);
$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' => ['debug' => 'debug' === $mode]]);
$messages = new Psr17Factory();
$original = new RuntimeException('Private synthetic provider failure', 503);
$reports = [];
$reporter = new CallableThrowableHandler(
    /**
     * Record one original event without writing diagnostics into a public response.
     *
     * @param Throwable $event the original downstream failure
     */
    static function (Throwable $event) use (&$reports): void {
        $reports[] = $event; // Replace this recording sink with the application's private transport.
    },
);

$application = new ArrayServiceProvider([
    ResponseFactoryInterface::class => new ServiceFactory($messages),
    StreamFactoryInterface::class => AliasFactory::get(ResponseFactoryInterface::class),
    ThrowableHandlerInterface::class => new ServiceFactory($reporter),
]);

// 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);

$middleware = $middlewareFactory($services);

$next = new class ($original) implements RequestHandlerInterface {
    /** @var Throwable the synthetic downstream failure */
    private Throwable $original;

    /**
     * Retain original identity for the private reporter.
     *
     * @param Throwable $original the event that the middleware will catch
     */
    public function __construct(Throwable $original)
    {
        $this->original = $original;
    }

    /**
     * Fail a synthetic route without emission or registration of its own callbacks.
     *
     * @param ServerRequestInterface $request the synthetic HTTP request
     *
     * @return ResponseInterface no response is returned by this failing route
     *
     * @throws Throwable the original downstream failure
     */
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        throw $this->original;
    }
};

$request = $messages->createServerRequest('GET', '/provider-json')
    ->withHeader('Accept', 'application/json')
;

$response = $middleware->process($request, $next);

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['detail'],
        'Trusted debug configuration did not reach the renderer.',
    );
    exampleCheck('no-store' === $response->getHeaderLine('Cache-Control'), 'Diagnostics must not be cached.');
} else {
    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 provider, fresh scope and one private report passed' . PHP_EOL;

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.

<?php

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 FastForward\Container\Factory\AliasFactory;
use FastForward\Container\Factory\CallableFactory;
use FastForward\Container\Factory\ServiceFactory;
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;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Whoops\Handler\PrettyPageHandler;

use function FastForward\Config\config;
use function FastForward\Container\container;

require __DIR__ . '/bootstrap.php';

exampleCheck(
    PHP_VERSION_ID >= 80300 && function_exists('FastForward\Container\container'),
    'Load the FastForward runtime dependency of this source on PHP 8.3 or newer.',
);

// FastForward is a required runtime dependency of this PHP 8.3+ major.
// Trusted debug permits HTML; the current request must still accept it.
$mode = $argv[1] ?? 'default';
exampleCheck(in_array($mode, ['public', 'default', 'configured'], true), 'Choose public, default or configured mode.');

$messages = new Psr17Factory();
$reports = [];
$pageCalls = 0;

$privateReporter = new CallableThrowableHandler(
    /** @param Throwable $event the unchanged application failure delivered privately */
    static function (Throwable $event) use (&$reports): void {
        $reports[] = $event; // Replace with the application's private log transport.
    },
);

$factories = [
    ResponseFactoryInterface::class => new ServiceFactory($messages),
    StreamFactoryInterface::class => AliasFactory::get(ResponseFactoryInterface::class),
    ThrowableHandlerInterface::class => new ServiceFactory($privateReporter),
];

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',
        ],
    ]);

    // Prepare configuration outside get(): factory failures may trigger container fallback.
    $page = new PrettyPageHandler();
    ++$pageCalls;

    $page->setPageTitle($settings->get('development.page_title'));
    $page->hideSuperglobalKey('_SERVER', 'HTTP_AUTHORIZATION');
    $page->hideSuperglobalKey('_COOKIE', 'session');
    $page->hideSuperglobalKey('_ENV', 'APP_SECRET');

    $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; charset=UTF-8' => static fn() => $services->get(WhoopsThrowableResponseFactory::class),
            ]);
        },
    );
}

$application = new ArrayServiceProvider($factories);

// 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.');

$firstScope = $middlewareFactory($services);
$secondScope = $middlewareFactory($services);

exampleCheck(
    ('configured' === $mode ? 1 : 0) === $pageCalls,
    'Scope construction must not create an additional page.',
);
exampleCheck($firstScope !== $secondScope, 'Each HTTP scope needs a new middleware and handler.');

$failures = [
    new RuntimeException(base64_encode('First synthetic provider diagnostic'), 503),
    new RuntimeException(base64_encode('Second synthetic provider diagnostic'), 404),
];

/**
 * Creates a synthetic route for the selected application failure.
 *
 * @param Throwable $failure the original failure for this HTTP scope
 *
 * @return RequestHandlerInterface the failing application route
 */
$route = static function (Throwable $failure): RequestHandlerInterface {
    return new class ($failure) implements RequestHandlerInterface {
        /** @var Throwable the unchanged synthetic route failure */
        private Throwable $failure;

        /** @param Throwable $failure the application error thrown in this request */
        public function __construct(Throwable $failure)
        {
            $this->failure = $failure;
        }

        /**
         * @param ServerRequestInterface $request the synthetic application request
         *
         * @return ResponseInterface this route never produces a normal response
         *
         * @throws Throwable the unchanged application failure
         */
        public function handle(ServerRequestInterface $request): ResponseInterface
        {
            throw $this->failure;
        }
    };
};

$first = $firstScope->process(
    $messages->createServerRequest('GET', '/first')->withHeader('Accept', 'text/html'),
    $route($failures[0]),
);

$second = $secondScope->process(
    $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(
    '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(
        str_contains((string) $first->getBody(), '<title>Synthetic application diagnostics</title>'),
        'Expected the application-configured title.',
    );
    exampleCheck(
        str_contains((string) $second->getBody(), '<title>Synthetic application diagnostics</title>'),
        'The cached page must retain application configuration.',
    );
}

// No HTTP emission. Hiding selected keys is not comprehensive redaction.
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.

<?php

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;

Failure and lifetime boundaries

Middleware requires lifecycle and reporting on one scoped object. It reports once before a separate pure renderer; reporter failure propagates without rendering. Generic output ignores reporter bytes, so diagnostics must use private sinks.

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, API, migration and testing.

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.