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
Acceptheader.
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:
Adding a New Error Type¶
- Add a case to
App\Enums\ApiErrorCodewith its status instatus()and its client-safe text indetail(). The text reaches external merchants, so it must name no table, column, index or class. - Register a
render()callback inApiProblemRenderer::register(), before theThrowablecatch-all. A callback registered after it can never fire, because aThrowable-typed callback matches everything. - 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().