Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a546674
refactor(core): improve http error handling
innocenzi Dec 15, 2025
1bb88f6
style: apply fixes from qa
innocenzi Dec 15, 2025
5a67c19
feat: add ability to customize error rendering
innocenzi Dec 15, 2025
2aefd86
refactor: stability and test adjustments
innocenzi Dec 16, 2025
a7b3cbc
style: apply fixes from qa
innocenzi Dec 16, 2025
5c7a7aa
feat: add custom exception page
innocenzi Dec 17, 2025
2ca8f44
fix: include exception page build
innocenzi Dec 17, 2025
4b72a9e
refactor: minor cleanup
innocenzi Dec 17, 2025
aa17283
refactor: clean up
innocenzi Dec 17, 2025
3d585cf
refactor: clean up
innocenzi Dec 18, 2025
150b342
feat: intl support and consistency changes
innocenzi Dec 18, 2025
0e6892e
feat: support custom error messages for manual `HttpRequestFailed` throw
innocenzi Dec 18, 2025
8d5b96f
refactor: clean up and test exception logging
innocenzi Dec 18, 2025
05c510a
refactor: remove duplicate `HttpExceptionHandler` call in router tester
innocenzi Dec 18, 2025
445a953
feat: allow copying current url in exception
innocenzi Dec 18, 2025
eadb07f
feat: add exception context to exception page
innocenzi Dec 18, 2025
67593ea
fix: docblock type for exception renderers
innocenzi Dec 18, 2025
10f4e27
chore: remove outdated comment
innocenzi Dec 18, 2025
e5e633c
docs: improve exception handling docs
innocenzi Dec 18, 2025
ec46538
build: add `exceptions:dev` composer command
innocenzi Dec 20, 2025
b41cd98
feat: add settings ui
innocenzi Dec 20, 2025
55bf936
fix(view): clean output buffer before throwing view exception
innocenzi Dec 22, 2025
a563fc3
feat: improve view exception rendering
innocenzi Dec 22, 2025
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
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
"Tempest\\Cryptography\\Tests\\": "packages/cryptography/tests",
"Tempest\\Database\\Tests\\": "packages/database/tests",
"Tempest\\DateTime\\Tests\\": "packages/datetime/tests",
"Tempest\\Debug\\Tests\\": "packages/debug/tests",
"Tempest\\EventBus\\Tests\\": "packages/event-bus/tests",
"Tempest\\Generation\\Tests\\": "packages/generation/tests",
"Tempest\\HttpClient\\Tests\\": "packages/http-client/tests",
Expand Down Expand Up @@ -249,11 +250,14 @@
"lint:fix": "vendor/bin/mago lint --fix --format-after-fix",
"style": "composer fmt && composer lint:fix",
"test": "composer phpunit",
"test:stop": "composer phpunit --stop-on-error --stop-on-failure",
"lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note",
"phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G",
"rector": "vendor/bin/rector process --no-ansi",
"merge": "php -d\"error_reporting = E_ALL & ~E_DEPRECATED\" vendor/bin/monorepo-builder merge",
"intl:plural": "./packages/intl/bin/plural-rules.php",
"exceptions:dev": "cd ./packages/router/src/Exceptions/local && bun i && bun run dev",
"exceptions:build": "cd ./packages/router/src/Exceptions/local && bun i && bun run build",
"release": [
"composer qa",
"./bin/release"
Expand All @@ -267,7 +271,8 @@
"./bin/validate-packages",
"./tempest discovery:clear --no-interaction",
"composer phpunit",
"composer phpstan"
"composer phpstan",
"composer exceptions:build"
]
}
}
3 changes: 1 addition & 2 deletions docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ final class PasswordlessAuthenticationController
public function __invoke(Request $request): Response
{
if (! $this->uri->hasValidSignature($request)) {
return new Invalid();
throw new HttpRequestFailed(Status::UNPROCESSABLE_CONTENT);
}

// …
Expand Down Expand Up @@ -704,7 +704,6 @@ Tempest provides several response classes for common use cases, all implementing
- {b`Tempest\Http\Responses\Back`} — redirects to previous page, accepts a fallback.
- {b`Tempest\Http\Responses\Download`} — downloads a file from the browser.
- {b`Tempest\Http\Responses\File`} — shows a file in the browser.
- {b`Tempest\Http\Responses\Invalid`} — a response with form validation errors, redirecting to the previous page.
- {b`Tempest\Http\Responses\NotFound`} — the 404 response. Accepts an optional body.
- {b`Tempest\Http\Responses\ServerError`} — a 500 server error response.

Expand Down
220 changes: 159 additions & 61 deletions docs/2-features/14-exception-handling.md
Original file line number Diff line number Diff line change
@@ -1,66 +1,58 @@
---
title: Exception handling
description: "Learn how to gracefully handle exceptions in your application by writing exception processors."
description: "Learn how exception handling works, how to manually report exceptions, and how to customize exception rendering for HTTP responses."
---

## Overview

Tempest comes with its own exception handler, which provides a simple way to catch and process exceptions. During local development, Tempest uses [Whoops](https://github.com/filp/whoops) to display detailed error pages. In production, it will show a generic error page.
Tempest comes with an exception handler that provides a simple way to report exceptions and render error responses.

When an exception is thrown, it will be caught and piped through the registered exception processors. By default, the only registered exception processor, {b`Tempest\Core\LogExceptionProcessor`}, will simply log the exception.
Custom [exception reporters](#writing-exception-reporters) can be created by implementing the {b`Tempest\Core\Exceptions\ExceptionReporter`} interface, and custom [exception renderers](#customizing-exception-rendering) can be created by implementing {b`Tempest\Router\Exceptions\ExceptionRenderer`}. These classes are automatically [discovered](../4-internals/02-discovery.md) and do not require manual registration.

Of course, you may create your own exception processors. This is done by creating a class that implements the {`Tempest\Core\ExceptionProcessor`} interface. Classes implementing this interface are automatically [discovered](../4-internals/02-discovery.md), so you don't need to register them manually.
## Processing exceptions

## Reporting exceptions
Exceptions can be reported without throwing them using the `process()` method of the {b`Tempest\Core\Exceptions\ExceptionProcessor`} interface. This allows putting exceptions through the reporting process without stopping the application's execution.

Sometimes, you may want to report an exception without necessarily throwing it. For example, you may want to log an exception, but not stop the execution of the application. To do this, you can use the `Tempest\report()` function.
```php app/CreateUser.php
use Tempest\Core\Exceptions\ExceptionProcessor;

```php
use function Tempest\report;

try {
// Some code that may throw an exception
} catch (SomethingFailed $e) {
report($e);
}
```

## Disabling default logging

Exception processors are discovered when Tempest boots, then stored in the `exceptionProcessors` property of {`Tempest\Core\AppConfig`}. The default logging processor, {b`Tempest\Core\LogExceptionProcessor`}, is automatically added to the list of processors.

To disable exception logging, you may remove it in a `KernelEvent::BOOTED` event handler:

```php
use Tempest\Core\AppConfig;
use Tempest\Core\KernelEvent;
use Tempest\Core\LogExceptionProcessor;
use Tempest\EventBus\EventHandler;
use Tempest\Support\Arr;

final readonly class DisableExceptionLogging
final readonly class CreateUser
{
public function __construct(
private AppConfig $appConfig,
) {
}
private ExceptionProcessor $exceptions
) {}

#[EventHandler(KernelEvent::BOOTED)]
public function __invoke(): void
{
Arr\forget_values($this->appConfig->exceptionProcessors, LogExceptionProcessor::class);
try {
// Some code that may throw an exception
} catch (SomethingFailed $somethingFailed) {
$this->exceptions->process($somethingFailed);
}
}
}
```

## Disabling exception logging

The default logging reporter, {b`Tempest\Core\Exceptions\LoggingExceptionReporter`}, is automatically added to the list of reporters. To disable it, create a {b`Tempest\Core\Exceptions\ExceptionsConfig`} [configuration file](../1-essentials/06-configuration.md#configuration-files) and set `logging` to `false`:

```php app/exceptions.config.php
use Tempest\Core\Exceptions\ExceptionsConfig;

return new ExceptionsConfig(
logging: false,
);
```

## Adding context to exceptions

Sometimes, an exception may have information that you would like to be logged. By implementing the {`Tempest\Core\HasContext`} interface on an exception class, you can provide additional context that will be logged—and available to other processors.
Exceptions can provide additional information for logging by implementing the {`Tempest\Core\ProvidesContext`} interface. The context data becomes available to exception processors.

```php
use Tempest\Core\HasContext;
use Tempest\Core\ProvidesContext;

final readonly class UserWasNotFound extends Exception implements HasContext
final readonly class UserWasNotFound extends Exception implements ProvidesContext
{
public function __construct(private string $userId)
{
Expand All @@ -76,47 +68,153 @@ final readonly class UserWasNotFound extends Exception implements HasContext
}
```

## Customizing the error page
## Writing exception reporters

In production, when an uncaught exception occurs, Tempest displays a minimalistic, generic error page. You may customize this behavior by adding a middleware dedicated to catching {b`Tempest\Http\HttpRequestFailed`} exceptions.
Exception reporters allow defining custom reporting logic for exceptions, such as sending them to external error tracking services like Sentry or logging them to specific destinations.

For instance, you may display a branded error page by providing a view:
To create a custom reporter, implement the {b`Tempest\Core\Exceptions\ExceptionReporter`} interface and define a `report()` method:

```php
```php app/SentryExceptionReporter.php
use Tempest\Core\Exceptions\ExceptionReporter;
use Throwable;

final class SentryExceptionReporter implements ExceptionReporter
{
public function __construct(
private SentryClient $sentry,
) {}

public function report(Throwable $throwable): void
{
$this->sentry->captureException($throwable);
}
}
```

Exception reporters are automatically [discovered](../4-internals/02-discovery.md) and registered. All registered reporters are invoked whenever an exception is processed, allowing multiple reporters to handle the same exception.

For example, the default logging reporter logs to a file, while the reporter above sends the error to Sentry.

If an exception reporter throws an exception during execution, it is silently caught to prevent infinite loops. This ensures that a failing reporter doesn't prevent other reporters from running.

### Accessing exception context

Exceptions can implement the {b`Tempest\Core\ProvidesContext`} interface, which reporters can leverage to provide additional context data during reporting:

```php app/SentryExceptionReporter.php
use Tempest\Core\Exceptions\ExceptionReporter;
use Tempest\Core\ProvidesContext;
use Sentry\State\HubInterface as Sentry;
use Sentry\State\Scope;

final class SentryExceptionReporter implements ExceptionReporter
{
public function __construct(
private readonly Sentry $sentry,
) {}

public function report(Throwable $throwable): void
{
$this->sentry->withScope(function (Scope $scope) use ($throwable) {
if ($throwable instanceof ProvidesContext) {
$scope->withContext($throwable->context());
}

$scope->captureException($throwable);
});
}
}
```

### Conditional reporting

Reporters can implement conditional logic to only report specific exception types or under certain conditions. There is no built-in filtering mechanism; reporters are responsible for determining when to report an exception.

```php app/CriticalErrorReporter.php
use Tempest\Core\Exceptions\ExceptionReporter;
use Throwable;

final class CriticalErrorReporter implements ExceptionReporter
{
public function __construct(
private AlertService $alerts,
) {}

public function report(Throwable $throwable): void
{
if (! $throwable instanceof CriticalException) {
return;
}

$this->alerts->sendCriticalAlert(
message: $throwable->getMessage(),
);
}
}
```

## Customizing exception rendering

Exception renderers provide control over how exceptions are rendered in HTTP responses. Custom renderers can be used to display specialized error pages for specific exception types, format errors differently based on content type (JSON, HTML, XML), or provide user-friendly error messages for common scenarios like 404 or validation failures.

To create a custom renderer, implement the {b`Tempest\Router\Exceptions\ExceptionRenderer`} interface. It requires a `canRender()` method to determine if the renderer can handle the given exception and request, and a `render()` method to produce the response:

```php app/NotFoundExceptionRenderer.php
use Tempest\Http\ContentType;
use Tempest\Http\HttpRequestFailed;
use Tempest\Router\HttpMiddleware;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Responses\NotFound;
use Tempest\Http\Status;
use Tempest\Router\Exceptions\ExceptionRenderer;
use Throwable;

use function Tempest\view;

final class CatchHttpRequestFailuresMiddleware implements HttpMiddleware
final class NotFoundExceptionRenderer implements ExceptionRenderer
{
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
public function canRender(Throwable $throwable, Request $request): bool
{
try {
return $next($request);
} catch (HttpRequestFailed $failure) {
return new GenericResponse(
status: $failure->status,
body: view('./error.view.php', failure: $failure),
);
if (! $request->accepts(ContentType::HTML)) {
return false;
}

if (! $throwable instanceof HttpRequestFailed) {
return false;
}

return $throwable->status === Status::NOT_FOUND;
}

public function render(Throwable $throwable): Response
{
return new NotFound(
body: view('./404.view.php'),
);
}
}
```

:::info
Exception renderers are automatically [discovered](../4-internals/02-discovery.md) and checked in {b`#[Tempest\Core\Priority]`} order.
:::

## Testing

By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you gain access to the exception testing utilities, which allow you to make assertions about reported exceptions.
By extending {`Tempest\Framework\Testing\IntegrationTest`} from a test case, exception testing utilities may be accessed for making assertions about processed exceptions.

```php
// Prevents exceptions from being actually processed
$this->exceptions->preventReporting();
// Allows exceptions to be processed during tests
$this->exceptions->allowProcessing();

// Asserts that the exception was reported
$this->exceptions->assertReported(UserNotFound::class);
// Assert that the exception was processed
$this->exceptions->assertProcessed(UserNotFound::class);

// Asserts that the exception was not reported
$this->exceptions->assertNotReported(UserNotFound::class);
// Assert that the exception was not processed
$this->exceptions->assertNotProcessed(UserNotFound::class);

// Asserts that no exceptions were reported
$this->exceptions->assertNothingReported();
// Assert that no exceptions were processed
$this->exceptions->assertNothingProcessed();
```

By default, Tempest disables exception processing during tests. It is recommended to unit-test your own {b`Tempest\Core\Exceptions\ExceptionReporter`} implementations.
10 changes: 3 additions & 7 deletions packages/console/src/Exceptions/ConsoleExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@
use Tempest\Console\GlobalFlags;
use Tempest\Console\HasExitCode;
use Tempest\Console\Input\ConsoleArgumentBag;
use Tempest\Container\Container;
use Tempest\Container\Tag;
use Tempest\Core\AppConfig;
use Tempest\Core\ExceptionHandler;
use Tempest\Core\ExceptionReporter;
use Tempest\Core\Exceptions\ExceptionProcessor;
use Tempest\Core\Kernel;
use Tempest\Highlight\Escape;
use Tempest\Highlight\Highlighter;
Expand All @@ -25,20 +23,18 @@
final readonly class ConsoleExceptionHandler implements ExceptionHandler
{
public function __construct(
private AppConfig $appConfig,
private Container $container,
private Kernel $kernel,
#[Tag('console')]
private Highlighter $highlighter,
private Console $console,
private ConsoleArgumentBag $argumentBag,
private ExceptionReporter $exceptionReporter,
private ExceptionProcessor $exceptionProcessor,
) {}

public function handle(Throwable $throwable): void
{
try {
$this->exceptionReporter->report($throwable);
$this->exceptionProcessor->process($throwable);

$this->console
->writeln()
Expand Down
3 changes: 0 additions & 3 deletions packages/core/src/AppConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ public function __construct(

?string $baseUri = null,

/** @var class-string<\Tempest\Core\ExceptionProcessor>[] */
public array $exceptionProcessors = [],

/**
* @var array<class-string<\Tempest\Core\InsightsProvider>>
*/
Expand Down
Loading