Skip to content

API Errors

All API error responses follow RFC 9457 — Problem Details for HTTP APIs. Every error response carries the Content-Type: application/problem+json header.

Response Structure

{
  "type": "about:blank",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Authentication credentials are missing or invalid.",
  "code": "UNAUTHENTICATED"
}
Field Type Description
type string Always "about:blank" — no custom problem type URIs are used.
title string HTTP status phrase derived from the status code.
status integer HTTP status code, mirrored in the response body.
detail string Human-readable explanation of this specific occurrence.
code string Machine-readable error identifier, e.g. RESOURCE_ALREADY_EXISTS. Branch on this rather than on detail, which is prose and may be reworded.

Two further fields appear only on masked server errors: request_id and debug. Both are described under Masked Server Errors.

Validation errors (422) include an additional errors field:

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "The given data was invalid.",
  "code": "VALIDATION_FAILED",
  "errors": {
    "email": ["The email field is required."]
  }
}

Which Requests Receive problem+json

A request gets a problem+json response when either condition holds:

  • its path is under the api/* prefix, which covers every API route including the webhook receiver, or
  • it asks for JSON through an Accept header.

The path check matters: relying on content negotiation alone would hand an HTML error page to any client that omits the Accept header, and in a debug-enabled environment that page carries a full stack trace. Web routes satisfy neither condition and keep Laravel's standard error pages.

The InvalidWebhookSignature handler skips the check entirely, because webhook senders never negotiate content types. Under the path rule it would be covered anyway, so the exception is belt and braces rather than a necessity.

Error Codes

Every code is defined in App\Enums\ApiErrorCode together with its status and its detail text. That enum is the single source of truth for both.

code Status detail
UNAUTHENTICATED 401 Authentication credentials are missing or invalid.
VALIDATION_FAILED 422 The given data was invalid.
RESOURCE_NOT_FOUND 404 The requested resource was not found.
INVALID_WEBHOOK_SIGNATURE 403 The webhook signature is invalid.
RESOURCE_ALREADY_EXISTS 409 An entry with this unique key already exists.
INTERNAL_SERVER_ERROR 500 An unexpected error occurred. Please try again later.

Statuses without a dedicated case take a code derived from the HTTP status phrase, uppercased with non-alphanumerics collapsed to underscores: 429 becomes TOO_MANY_REQUESTS, 405 becomes METHOD_NOT_ALLOWED, an unrecognised status becomes UNKNOWN.

Handled Exception Types

Registered in App\Exceptions\ApiProblemRenderer::register(). The framework evaluates the callbacks in registration order and takes the first one whose parameter type matches and that returns a response, so the broadest type is registered last.

Exception Status code detail
Illuminate\Auth\AuthenticationException 401 UNAUTHENTICATED from the enum
Illuminate\Validation\ValidationException 422 VALIDATION_FAILED from the enum, plus errors
Spatie\WebhookClient\Exceptions\InvalidWebhookSignature 403 INVALID_WEBHOOK_SIGNATURE from the enum
Symfony\...\HttpException, 404 404 RESOURCE_NOT_FOUND from the enum, message discarded
Symfony\...\HttpException, other 4xx from exception from the status phrase the exception message, or the status phrase when empty
Symfony\...\HttpException, 5xx from exception from the status phrase generic text, message discarded
Illuminate\Database\UniqueConstraintViolationException 409 RESOURCE_ALREADY_EXISTS from the enum
any other Throwable 500 INTERNAL_SERVER_ERROR generic text, see below

Headers carried by an HttpException are preserved, so throttling responses keep their Retry-After and X-RateLimit-* headers.

Why 404 and 5xx messages are discarded

A 5xx message can carry driver or infrastructure detail, so it never reaches the client.

A 404 message is written by the framework rather than by this application. prepareException() converts a ModelNotFoundException into a NotFoundHttpException and copies its message over, which reads No query results for model [App\Models\System]. Passing that through would put an internal model class in the response. No abort(404) exists anywhere in the application, so nothing of value is lost.

The same conversion is why there is no ModelNotFoundException handler: it happens before the render callbacks run, so such a handler could never fire. The 404 branch of the HttpException callback produces that response instead.

Other 4xx messages are kept. They come from this application's own abort() calls, which pass static strings, and they carry information the client needs, for example The connection is inactive. from the EnsureSystemConnectionIsActive middleware.

Masked Server Errors

Anything not listed above answers with one generic body. The exception is written to the log in full, including the SQL of a failed query, but nothing of it reaches the client.

{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred. Please try again later.",
  "code": "INTERNAL_SERVER_ERROR",
  "request_id": "a1b2c3d4e5f6a7b8"
}

request_id is the current OpenTelemetry span id. The same value appears in the X-Request-ID response header and in every log record for that request, so a merchant quoting it lets support find the exception without any of it being exposed. It is omitted when no valid span context exists.

A second layer backs this up: any 5xx response on an API route that is not application/problem+json is replaced with the body above, whatever produced it. This catches responses that bypass the render callbacks, for instance an exception that renders itself.

Log context

Every reported exception carries path, method and, when the caller is authenticated, connection_id. Requests without a matched route in a console context, such as queued jobs, get no request context rather than a misleading one. UniqueConstraintViolationException is logged at warning rather than error: it answers the client cleanly as a 409, but reaching the database at all usually means a validation rule is missing.

Debug Output

In the local environment with APP_DEBUG enabled, a masked 500 additionally carries a debug member:

{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred. Please try again later.",
  "code": "INTERNAL_SERVER_ERROR",
  "request_id": "a1b2c3d4e5f6a7b8",
  "debug": {
    "exception": "Illuminate\\Database\\QueryException",
    "message": "SQLSTATE[23000]: ...",
    "file": "/var/www/html/app/Http/Controllers/Api/MeController.php",
    "line": 27,
    "trace": ["#0 ...", "#1 ..."]
  }
}

Both conditions must hold, and the environment check is a positive list rather than an exclusion:

APP_ENV APP_DEBUG debug member
local true present
local false absent
staging, production true absent
anything unrecognised true absent

An APP_DEBUG set by mistake in a production-like environment therefore cannot open the response on its own, and an unfamiliar APP_ENV masks rather than exposes. The envelope is identical in every environment, so client code never runs against a different shape locally than in production.

The test suite runs with APP_ENV=testing, so masking applies there. APP_DEBUG=false is pinned in phpunit.xml.dist to keep that behaviour independent of a developer's local .env.

Do Not Use ExceptionApiProblem

Phpro\ApiProblem\Http\ExceptionApiProblem ships with the installed phpro/api-problem package and looks like the obvious choice when extending the handler. It is not. Its toArray() sets detail to the exception message, and its toDebuggableArray() returns the class, file, line and full stack trace. That is exactly the disclosure this page exists to prevent.

The class is not patched, because it is vendor code and an edit there disappears on the next composer update. Instead, arch('no exception detail leakage') in tests/Unit/ArchTest.php fails the build if anything in App\ imports it or DebuggableApiProblemInterface. ApiProblemRenderer is the only sanctioned path from a Throwable to an API response.

#[ApiProblem] Attribute

Use the App\Http\Attributes\ApiProblem attribute to document error responses in the Scribe-generated API docs. It extends Knuckles\Scribe\Attributes\Response and produces the correct application/problem+json body, including the code member.

use App\Http\Attributes\ApiProblem;
use Knuckles\Scribe\Attributes\Group;

#[Group('Connections')]
#[ApiProblem(401, 'Authentication credentials are missing or invalid.', 'Unauthenticated')]
#[ApiProblem(409, 'An entry with this unique key already exists.', 'Conflict', 'RESOURCE_ALREADY_EXISTS')]
class SystemConnectionsController extends Controller
{
    // ...
}
Argument Type Description
$status int HTTP status code.
$detail string Value for the detail field in the problem body.
$description string Optional label shown in the Scribe docs, e.g. 'Unauthenticated'.
$code ?string Optional code value. Defaults to the code derived from the status phrase, which is correct for anything raised by abort(). Pass it explicitly when the response comes from a dedicated ApiErrorCode case.

$code deliberately comes after $description, because existing call sites pass the description positionally.

The attribute is repeatable and can be placed on a class (applies to all methods) or on an individual method. Regenerate the docs after adding or changing attributes:

docker compose exec php php artisan scribe:generate

Adding a New Error Type

  1. Add a case to App\Enums\ApiErrorCode with its status in status() and its client-safe text in detail(). The text reaches external merchants, so it must name no table, column, index or class.
  2. Register a render() callback in ApiProblemRenderer::register(), before the Throwable catch-all. A callback registered after it can never fire, because a Throwable-typed callback matches everything.
  3. Keep the isApiRequest() guard so web requests fall through to their normal error pages.
$exceptions->render(static fn (MyCustomException $e, Request $request): ?JsonResponse => self::isApiRequest($request)
    ? self::problem(ApiErrorCode::MyNewCase)
    : null);

Build the body from the enum, never from the exception message. If the new error deserves a different log level, add $exceptions->level(MyCustomException::class, LogLevel::WARNING) at the end of register().