Cookbook

Error Handling

The JSON error envelope, the exceptions to throw, typed database failures, and how to customize the framework's exception Handler

Glueful turns every uncaught exception into a JSON response through one class: Glueful\Http\Exceptions\Handler. Application::handle() wraps router dispatch in a try/catch and passes anything thrown to the Handler (resolved from the container as ExceptionHandlerInterface). Most of the time you just throw the right exception and let the Handler render it.

The Error Envelope

For an HttpException (or any other exception without its own renderer), the Handler renders:

{
  "success": false,
  "message": "Post not found",
  "error": {
    "code": 404,
    "error_code": "NOT_FOUND",
    "timestamp": "2026-10-27T10:30:00+00:00",
    "request_id": "req_6720f1c3a4b5e8.12345678"
  }
}
FieldDescription
successAlways false
messageThe exception message. If it is empty, a default for the status (Not Found, Conflict, ...) is used
error.codeThe HTTP status code
error.error_codeA stable SCREAMING_SNAKE_CASE name derived from the status
error.timestampISO 8601 time (date('c'))
error.request_idFrom request_id(), falling back to X-Request-ID

error_code comes from the status code alone:

Statuserror_code
400BAD_REQUEST
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
405METHOD_NOT_ALLOWED
409CONFLICT
422UNPROCESSABLE_ENTITY
429TOO_MANY_REQUESTS
500INTERNAL_SERVER_ERROR
503SERVICE_UNAVAILABLE
504GATEWAY_TIMEOUT
otherthe status as a string, e.g. "415"

There is no error.type field. To tell two 422s apart, use different messages or a custom renderer (see Customizing the Handler).

Debug Mode

Debug mode is on when APP_DEBUG=true or APP_ENV=development. Then error also contains:

"error": {
  "code": 500,
  "error_code": "INTERNAL_SERVER_ERROR",
  "timestamp": "...",
  "request_id": "...",
  "exception": "RuntimeException",
  "file": "/app/app/Services/PostService.php",
  "line": 42,
  "trace": [
    { "file": "...", "line": 17, "function": "publish", "class": "App\\Services\\PostService" }
  ]
}
  • trace holds at most 10 frames.
  • When an HttpException carries context (withContext(), or the context set by BusinessLogicException and similar), that context appears as error.context, in debug mode only.
  • For exceptions that are not HttpExceptions, message is the real exception message in debug mode only. In production it is the default for the status (Internal Server Error), so driver and internal messages don't reach clients.

Validation Errors (422)

Glueful\Validation\ValidationException has its own shape. Field errors sit in a top-level errors key and there is no error object:

{
  "success": false,
  "message": "The given data was invalid.",
  "errors": {
    "email": ["The email field is required."],
    "name": ["The name must be at least 2 characters."]
  }
}
use Glueful\Validation\ValidationException;

throw new ValidationException(['email' => ['Email is already taken.']]);
throw ValidationException::forField('email', 'Email is already taken.');
throw ValidationException::forFields(['email' => 'Required.', 'name' => 'Too short.']);
throw ValidationException::withErrors(['email' => 'Required.']); // string values are wrapped in arrays

// A custom envelope message: the third constructor argument
throw new ValidationException(['email' => ['Email is already taken.']], [], 'Could not create the account.');

The Handler uses the exception's message as the envelope message. To catch validation failures in the route pipeline, use the validate middleware (->middleware(['validate'])); it also runs #[Validate] validation. Glueful\Routing\Middleware\ValidationMiddleware is deprecated since 1.91 (it was never aliased and does a subset of validate); replace any registration of it with validate.

See Validation for producing these errors from rules.

Responses You Build Yourself

Glueful\Http\Response::error() and its shortcuts (notFound(), unauthorized(), forbidden(), rateLimited(), serverError(), validation()) return the same envelope without error_code. Details go in error.details:

return Response::error('Payment declined', 402, ['gateway' => 'card_declined']);
// {"success":false,"message":"Payment declined","error":{"code":402,"timestamp":"...","request_id":"...","details":{"gateway":"card_declined"}}}

Response::validation($errors), which BaseController::validateRequest() uses, puts field errors in error.details, not in top-level errors. The router's own unmatched-route 404 and 405 responses are built this way too (the 405 lists allowed_methods in error.details and sets an Allow header). Clients should key on error.code (or the HTTP status) and treat error_code as optional.

Exceptions to Throw

Every class below extends Glueful\Http\Exceptions\HttpException. The constructor is HttpException(int $statusCode, string $message = '', array $headers = [], int $code = 0, ?Throwable $previous = null), and the status and headers carry through to the response.

Client Errors: Glueful\Http\Exceptions\Client\*

ClassStatusConstructor / notes
BadRequestException400($message = 'Bad Request', $headers = [], $previous = null)
UnauthorizedException401Adds WWW-Authenticate: Bearer unless you pass one
ForbiddenException403($message = 'Forbidden', ...)
NotFoundException404($message = 'Not Found', ...)
MethodNotAllowedException405(array $allowedMethods = [], $message = ...), sets Allow
ConflictException409($message = 'Conflict', ...)
UnprocessableEntityException422($message = 'Unprocessable Entity', ...)
TooManyRequestsException429(int $retryAfter = 60, $message = ...), sets Retry-After

Server Errors: Glueful\Http\Exceptions\Server\*

ClassStatusNotes
InternalServerException500
ServiceUnavailableException503($message, ?int $retryAfter = null, ...), sets Retry-After when given
GatewayTimeoutException504

Domain Exceptions: Glueful\Http\Exceptions\Domain\*

ClassStatusNotes
AuthenticationException401($message = 'Unauthenticated', array $guards = []), sets WWW-Authenticate: Bearer
TokenExpiredException401Sets WWW-Authenticate: Bearer error="invalid_token", ...
HttpAuthException401 (default)Factories: missingAuthorizationHeader(), malformedAuthorizationHeader(), invalidJwtFormat(), tokenExpired(), unsupportedScheme()
AuthorizationException403($message = 'This action is unauthorized.', ?string $ability = null)
ModelNotFoundException404Thrown by Model::findOrFail() and firstOrFail(). Message: No query results for model [Post]
BusinessLogicException422(string $message, array $context = []). Factories: operationNotAllowed(), invalidState(), limitExceeded(), hasDependencies()
HttpProtocolException400 (default)Factories: malformedJson(), missingHeader(), invalidContentType(), requestTooLarge()
SecurityException400 (default)($message, int $statusCode = 400, ?array $context = null). Factories: invalidContentType() (415), suspiciousActivity() (403), rateLimitAbuse() (429)
ExtensionException400 (default)($message, int $statusCode = 400, ?array $context = null)
DatabaseException500 (default)HTTP-facing wrapper with factories connectionFailed(), queryFailed(), constraintViolation() (409), createFailed(), updateFailed(), deleteFailed(). This is a different class from Glueful\Database\Exceptions\DatabaseException (below)
ProvisioningException500failedAtStep(string $step, string $reason)

Factory context such as BusinessLogicException::invalidState()'s current_state is shown to clients only in debug mode, as error.context. In production, put whatever the client needs into the message.

Example

use Glueful\Bootstrap\ApplicationContext;
use Glueful\Http\Exceptions\Client\ForbiddenException;
use Glueful\Http\Exceptions\Domain\BusinessLogicException;
use App\Models\Post;

final class PostService
{
    public function __construct(private readonly ApplicationContext $context)
    {
    }

    public function publish(string $uuid, string $userUuid): Post
    {
        $post = Post::findOrFail($this->context, $uuid);   // 404 if missing

        if ($post->author_uuid !== $userUuid) {
            throw new ForbiddenException('You can only publish your own posts');
        }

        if ($post->status === 'published') {
            throw BusinessLogicException::invalidState('post', 'published', 'draft'); // 422
        }

        $post->status = 'published';
        $post->save();

        return $post;
    }
}

The controller doesn't need a try/catch. The exceptions reach the Handler and become 404, 403, or 422 envelopes.

Typed Database Exceptions

Since 1.76.0, the database layer converts every PDOException into a typed exception under Glueful\Database\Exceptions. The base class is DatabaseException extends \PDOException, so existing catch (\PDOException $e) blocks still work.

ClassRaised for
ConstraintViolationExceptionAny integrity violation (parent of the next three)
UniqueConstraintViolationExceptionDuplicate key (pgsql 23505, MySQL 1062, SQLite UNIQUE constraint failed)
ForeignKeyConstraintViolationExceptionFK violation (23503, MySQL 1451/1452, SQLite FOREIGN KEY constraint failed)
NotNullConstraintViolationExceptionNULL in a NOT NULL column (23502, MySQL 1048, SQLite NOT NULL constraint failed)
DeadlockExceptionDeadlock (40P01, MySQL 1213)
SerializationFailureExceptionSerialization failure (40001)
LockContentionExceptionLock wait timeout (55P03, MySQL 1205, SQLite busy/locked)
ConnectionLostExceptionServer gone or connection dropped (57P0x, 08xxx, MySQL 2006/2013)
CommitOutcomeUnknownExceptionConnection lost while COMMIT was in flight. Never retried

Every type implements DatabaseExceptionInterface:

$e->sqlState();    // ?string, e.g. "23505"
$e->driverCode();  // int|string|null, the vendor code (errorInfo[1])
$e->driver();      // "mysql" | "pgsql" | "sqlite"
$e->getPrevious(); // the original PDOException

Deadlocks, serialization failures, and lock contention implement RetryableTransactionFailureInterface. db($context)->transaction(...) retries them automatically, along with connection losses it can prove were uncommitted, within one shared budget (DB_RETRY_MAX_ATTEMPTS, DB_RETRY_BACKOFF_MS). The final typed exception is rethrown only after the retries run out.

SQLite reports constraint failures as a bare SQLITE_CONSTRAINT, so the classifier reads SQLite's fixed message prefixes to pick the unique, foreign-key, or not-null subclass. Since 1.91 a duplicate key on SQLite is therefore a 409, as on MySQL and PostgreSQL (before, it was a generic 500). A constraint SQLite words differently, such as CHECK, stays the generic ConstraintViolationException.

Unique Violations Become 409

The Handler renders an uncaught UniqueConstraintViolationException as a 409 with a fixed message. The message stays fixed even in debug mode, because the driver's message would reveal the table, column, and value:

{
  "success": false,
  "message": "A conflicting record already exists.",
  "error": { "code": 409, "error_code": "CONFLICT", "timestamp": "...", "request_id": "..." }
}

The violation is still logged, at warning level with the driver's detail, so operators see a poisoned row or a stale sequence. Other typed database exceptions are rendered as generic 500s and logged at error.

To give clients a field-level message, catch the exception where you know which field caused it:

use Glueful\Database\Exceptions\UniqueConstraintViolationException;
use Glueful\Validation\ValidationException;

try {
    $user = User::create($this->context, ['email' => $email, 'name' => $name]);
} catch (UniqueConstraintViolationException) {
    throw ValidationException::forField('email', 'That email is already registered.');
}

Rate Limiting (429)

The rate_limit middleware (EnhancedRateLimiterMiddleware) doesn't throw. It returns its own response from RateLimitHeaders::createExceededResponse(), which has a different body from the error envelope:

{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Please try again later.",
  "retry_after": 42,
  "limit": 60,
  "tier": null
}

It sends these headers:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1793097042
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 1793097042
  • Retry-After is set when retry_after > 0.
  • The X-RateLimit-* and RateLimit-* sets can each be turned off (include_legacy / include_ietf under api.rate_limiting.headers).
  • RateLimit-Policy is added when the limit carries a policy.
  • *-Reset is a Unix timestamp.

Each 429 also dispatches Glueful\Events\Auth\RateLimitExceededEvent (since 1.91); a failing listener never blocks the response. The router attaches rate_limit automatically to routes with a ->rateLimit() or #[RateLimit] limit, so those routes can return this response without listing the middleware.

When you throw TooManyRequestsException yourself, the client gets the standard envelope (error_code: TOO_MANY_REQUESTS) and a Retry-After header. See Rate Limiting to configure limits.

Customizing the Handler

Renderable Exceptions

An exception that implements Glueful\Http\Exceptions\Contracts\RenderableException renders itself. The Handler checks for this interface before anything else:

use Glueful\Http\Exceptions\Contracts\RenderableException;
use Glueful\Http\Response;
use Symfony\Component\HttpFoundation\Request;

final class PaymentDeclinedException extends \RuntimeException implements RenderableException
{
    public function __construct(private readonly string $declineCode)
    {
        parent::__construct('Payment was declined');
    }

    public function render(?Request $request = null): Response
    {
        return new Response([
            'success' => false,
            'message' => $this->getMessage(),
            'error' => ['code' => 402, 'error_code' => 'PAYMENT_DECLINED', 'decline_code' => $this->declineCode],
        ], 402);
    }
}

The exception is still reported as usual. Only the rendering is replaced.

Handler Hooks

Handler is a shared container service, and each hook returns static, so you can chain them:

MethodEffect
renderable(string $exception, callable $renderer)$renderer(Throwable $e, ?Request $request): Response for that class and its subclasses. Runs after RenderableException and before the built-in renderers
map(string $exception, int $statusCode)Status code for exceptions that fall through to the generic renderer, i.e. ones that are not HttpExceptions
dontReport(string $exception)Don't log the class, and don't pass it to reporters
reportUsing(callable $reporter)$reporter(Throwable $e, ?Request $request): void runs after logging. Reporters that throw are ignored
mapChannel(string $exception, string $channel)Sets the channel value in the log context. App mappings win over the built-in rules

Register them from the boot() of an app provider that extends Glueful\Extensions\ServiceProvider and is listed in config/serviceproviders.php:

<?php

namespace App\Providers;

use App\Exceptions\PaymentGatewayTimeout;
use App\Exceptions\QuotaExceeded;
use Glueful\Bootstrap\ApplicationContext;
use Glueful\Database\Exceptions\ForeignKeyConstraintViolationException;
use Glueful\Extensions\ServiceProvider;
use Glueful\Http\Exceptions\Handler;
use Glueful\Http\Response;

final class ExceptionServiceProvider extends ServiceProvider
{
    public function boot(ApplicationContext $context): void
    {
        $handler = app($context, Handler::class);

        $handler
            ->map(ForeignKeyConstraintViolationException::class, 409)
            ->map(PaymentGatewayTimeout::class, 504)
            ->renderable(QuotaExceeded::class, fn (\Throwable $e) => new Response([
                'success' => false,
                'message' => $e->getMessage(),
                'error' => ['code' => 402, 'error_code' => 'QUOTA_EXCEEDED'],
            ], 402))
            ->dontReport(QuotaExceeded::class)
            ->mapChannel(PaymentGatewayTimeout::class, 'payments')
            ->reportUsing(function (\Throwable $e, $request): void {
                // forward to an error tracker
            });
    }
}
// config/serviceproviders.php
return [
    'enabled' => [
        'App\\Providers\\AppServiceProvider',
        'App\\Providers\\ExceptionServiceProvider',
    ],
];

Some behavior to know when using these hooks:

  • HttpExceptions are never reported by default. That covers every class in the tables above, including the 5xx Server\* ones, and ValidationException. Uncaught non-HTTP exceptions, including typed database exceptions, are logged.
  • Precedence of map(). map() adds to the end of the existing status map, which is checked in order. A mapping for a subclass of an already-mapped type has no effect. A mapping for an HttpException subclass is ignored, because those use their own status code.
  • Log channels. The channel value goes into the log context of the single PSR-3 logger. Your mapChannel() entries are checked first, and the most specific matching class wins whatever order you registered them in. Without an app mapping, typed database exceptions get database; then anything thrown in framework src/ or with a Glueful\ class in its stack trace gets framework (a request-time exception has router frames, so unmapped ones usually land there); then the built-in defaults (validation, auth, security, ...) apply. Before 1.91 the framework check ran first, so app mappings rarely took effect.
  • ExceptionEvent. For every handled exception that has a request, the Handler dispatches Glueful\Events\Http\ExceptionEvent, with getException(), getRequest(), and isServerError(). Listening for it is an alternative to reportUsing() (see Events).

Reading the Envelope on the Client

async function api(path, options = {}) {
  const res = await fetch(`/api/v1${path}`, {
    ...options,
    headers: { 'Content-Type': 'application/json', ...options.headers },
  });
  const body = await res.json().catch(() => ({}));
  if (res.ok) return body.data;

  const err = new Error(body.message ?? res.statusText);
  err.status = res.status;
  err.errorCode = body.error?.error_code ?? body.error;   // envelope, or "rate_limit_exceeded"
  err.fields = body.errors ?? body.error?.details ?? {};  // thrown ValidationException, or Response::validation()
  err.requestId = body.error?.request_id;
  err.retryAfter = Number(res.headers.get('Retry-After')) || undefined;
  throw err;
}

Testing Error Responses

The Handler is a plain class, so you can render an exception without booting the app:

use Glueful\Database\Exceptions\UniqueConstraintViolationException;
use Glueful\Http\Exceptions\Client\NotFoundException;
use Glueful\Http\Exceptions\Handler;
use Glueful\Validation\ValidationException;
use PHPUnit\Framework\TestCase;

final class ErrorEnvelopeTest extends TestCase
{
    private function body(\Throwable $e): array
    {
        $response = (new Handler())->setDebug(false)->render($e);
        return json_decode((string) $response->getContent(), true);
    }

    public function test_http_exception_envelope(): void
    {
        $response = (new Handler())->setDebug(false)->render(new NotFoundException('Post not found'));
        $body = json_decode((string) $response->getContent(), true);

        $this->assertSame(404, $response->getStatusCode());
        $this->assertFalse($body['success']);
        $this->assertSame('Post not found', $body['message']);
        $this->assertSame('NOT_FOUND', $body['error']['error_code']);
        $this->assertArrayNotHasKey('trace', $body['error']);
    }

    public function test_validation_uses_top_level_errors(): void
    {
        $body = $this->body(ValidationException::forField('email', 'Required.'));

        $this->assertSame(['email' => ['Required.']], $body['errors']);
        $this->assertArrayNotHasKey('error', $body);
    }

    public function test_unique_violation_is_409(): void
    {
        $pdo = new \PDOException('duplicate key');
        $pdo->errorInfo = ['23505', 7, 'duplicate key'];

        $body = $this->body(UniqueConstraintViolationException::fromPdo($pdo, 'pgsql'));

        $this->assertSame('CONFLICT', $body['error']['error_code']);
        $this->assertSame('A conflicting record already exists.', $body['message']);
    }
}

Use setDebug(false) to pin production behavior, because the constructor turns debug on by itself whenever APP_DEBUG or APP_ENV=development is set. For end-to-end checks through the router, dispatch a Request as shown in Testing.