Symfony API

Application error reference

Every error this API can return, with the exception that raises it, the HTTP status it maps to, the machine-readable code clients branch on, why it happens, and how to fix it. Codes are stable; messages are not. Branch on code, log the traceId.

Documented errors
33
Categories
7
Envelope
RFC 9457
Retryable
8

Error envelope

All API errors are serialised as RFC 9457 problem details with two additions: code for branching and traceId for log correlation.

Envelope · json
{
  "type": "https://errors.example.com/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "code": "VALIDATION_FAILED",
  "detail": "The request payload failed validation.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3SJ0V4XG5K7P9T1W3Z5CDF",
  "errors": [
    { "field": "customerId", "message": "This value should not be blank.", "code": "IS_BLANK_ERROR" }
  ]
}
FieldTypeAlways
typestring (URI)yes
titlestringyes
statusintegeryes
codestringyes
detailstringyes
instancestring (path)yes
traceIdstring (ULID)yes
errorsarrayno
metaobjectno

Status code map

Statuses are assigned consistently across the whole API. Use this table when adding a new error so the same situation never gets two different statuses.

StatusMeaning
400Bad Request
401Unauthorized
402Payment Required
403Forbidden
404Not Found
405Method Not Allowed
409Conflict
415Unsupported Media Type
422Unprocessable Entity
429Too Many Requests
500Internal Server Error
502Bad Gateway
503Service Unavailable
504Gateway Timeout

Handling pipeline

A single kernel subscriber owns the conversion from exception to response. Domain code throws typed exceptions and never builds an error payload itself.

src/Http/EventSubscriber/ApiExceptionSubscriber.php · php
namespace App\Http\EventSubscriber;

final readonly class ApiExceptionSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::EXCEPTION => ['onException', -64]];
    }

    public function onException(ExceptionEvent $event): void
    {
        if (!str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
            return;
        }

        $error = $this->mapper->map($event->getThrowable());

        $this->logger->log($error->logLevel, $error->title, [
            'code'      => $error->code,
            'traceId'   => $error->traceId,
            'exception' => $event->getThrowable(),
        ]);

        $event->setResponse(new JsonResponse(
            $error->toArray(),
            $error->status,
            $error->headers, // Retry-After, Allow, X-RateLimit-*
        ));
    }
}

Authentication Errors

Raised while the firewall establishes who the caller is. Every error here is produced before the controller is reached, by an authenticator or by the JWT decoder.

Invalid JWT Token

Permalink to this error
Error code
AUTH_TOKEN_INVALID
HTTP status
HTTP 401
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

The Authorization header carried a bearer token that could not be decoded or whose signature did not verify against the configured public key.

Why it happens

The JWT authenticator decodes the token before the firewall creates a token storage entry. Any structural or cryptographic failure aborts authentication and the entry point converts the AuthenticationException into a 401 response.

Common causes

  • Token was signed with a key pair that no longer matches JWT_PUBLIC_KEY (key rotated, or dev keys used against prod).
  • Token string was truncated, URL-encoded twice, or wrapped in quotes by the client.
  • The header used a scheme other than Bearer, so the raw value was parsed as a token.
  • The payload was hand-crafted in a test and is missing the configured user identity claim.

How to fix it

  1. 1

    Verify the key pair the application actually loaded

    Compare the fingerprint of the private key that issued the token with the public key resolved at runtime. A passphrase mismatch fails the same way as a wrong key.

    bash
    php bin/console lexik:jwt:check-config
    php bin/console debug:config lexik_jwt_authentication
  2. 2

    Confirm the header shape on the client

    The scheme is case sensitive and a single space separates it from the token.

    http
    Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
  3. 3

    Inspect the decode failure reason instead of guessing

    JWTDecodeFailureException exposes a reason constant. Log it in the failure handler so invalid-signature is never confused with expired.

    php
    use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException;
    
    if ($exception instanceof JWTDecodeFailureException) {
        $this->logger->warning('JWT decode failed', [
            'reason' => $exception->getReason(), // invalid_token | unverified_token | expired_token
        ]);
    }

Example request or scenario

Request · http
GET /api/v1/me HTTP/1.1
Host: api.example.com
Authorization: Bearer not-a-real-token

Example error response

401 Unauthorized · json
{
  "type": "https://errors.example.com/auth-token-invalid",
  "title": "Invalid JWT Token",
  "status": 401,
  "code": "AUTH_TOKEN_INVALID",
  "detail": "The bearer token could not be verified.",
  "instance": "/api/v1/me",
  "traceId": "01JC3S9WQ4ZV6H2K8N0T7A1BQD"
}

Related exceptions

  • Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException
  • Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidTokenException
  • Symfony\Component\Security\Core\Exception\AuthenticationException

Related services & controllers

UnitPath
JwtAuthenticationFailureListenersrc/Security/Listener/JwtAuthenticationFailureListener.php
ApiExceptionSubscribersrc/Http/EventSubscriber/ApiExceptionSubscriber.php

Notes for developers

Expired JWT Token

Permalink to this error
Error code
AUTH_TOKEN_EXPIRED
HTTP status
HTTP 401
Severity
Recoverable
Retry policy
Retryable
Log level
log: info

The token was structurally valid and correctly signed, but its exp claim is in the past.

Why it happens

Access tokens are deliberately short lived. The decoder throws before the user provider is queried, so no database round trip happens.

Common causes

  • Normal token lifetime elapsed and the client did not refresh.
  • Client and server clocks drift by more than the configured leeway.
  • A long-running background job reused a token captured at boot.

How to fix it

  1. 1

    Refresh, then replay the original request once

    This is the only 401 the client should retry automatically. Exchange the refresh token and repeat the call a single time to avoid loops.

    http
    POST /api/v1/token/refresh HTTP/1.1
    Content-Type: application/json
    
    {"refresh_token": "e9c1...f0"}
  2. 2

    Distinguish expiry from invalidity on the client

    Branch on the code member, never on the status. Both expiry and invalid signature return 401.

    ts
    if (res.status === 401 && body.code === "AUTH_TOKEN_EXPIRED") {
      await refresh()
      return retryOnce(request)
    }
  3. 3

    Keep clocks in sync for machine-to-machine callers

    Enable NTP on worker hosts; a two minute drift makes freshly minted tokens look expired.

Example request or scenario

Request · http
GET /api/v1/orders HTTP/1.1
Authorization: Bearer <token issued 40 minutes ago, ttl 1800s>

Example error response

401 Unauthorized · json
{
  "type": "https://errors.example.com/auth-token-expired",
  "title": "Expired JWT Token",
  "status": 401,
  "code": "AUTH_TOKEN_EXPIRED",
  "detail": "Access token expired at 2026-07-30T09:14:02+00:00.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3SB1F7QK5M9YR2E4X8HTN3"
}

Related exceptions

  • Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException
  • Symfony\Component\Security\Core\Exception\AuthenticationExpiredException

Related services & controllers

UnitPath
TokenRefreshControllersrc/Controller/Api/TokenRefreshController.php
JwtAuthenticationFailureListenersrc/Security/Listener/JwtAuthenticationFailureListener.php

Notes for developers

Missing Authentication Token

Permalink to this error
Error code
AUTH_TOKEN_MISSING
HTTP status
HTTP 401
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

A firewall-protected route was called with no credentials at all: no Authorization header and no session cookie.

Why it happens

When no authenticator supports the request, the firewall delegates to its entry point, which returns 401 rather than redirecting for stateless APIs.

Common causes

  • The client dropped the header on a redirect or on a cross-origin preflight follow-up.
  • A reverse proxy stripped Authorization before forwarding.
  • The route was assumed public but is matched by the api firewall pattern.
  • Cookie-based session expired and the SPA kept calling protected endpoints.

How to fix it

  1. 1

    Check whether the route is genuinely meant to be public

    bash
    php bin/console debug:router api_health
    php bin/console debug:firewall api
  2. 2

    Allow the header through the proxy

    Nginx and some CDNs drop unknown headers on internal redirects.

    nginx
    proxy_set_header Authorization $http_authorization;
    proxy_pass_header Authorization;
  3. 3

    Mark truly public endpoints explicitly

    yaml
    security:
      access_control:
        - { path: ^/api/v1/health$, roles: PUBLIC_ACCESS }
        - { path: ^/api,           roles: IS_AUTHENTICATED_FULLY }

Example request or scenario

Request · http
GET /api/v1/me HTTP/1.1
Host: api.example.com

Example error response

401 Unauthorized · json
{
  "type": "https://errors.example.com/auth-token-missing",
  "title": "Missing Authentication Token",
  "status": 401,
  "code": "AUTH_TOKEN_MISSING",
  "detail": "No credentials were supplied for a protected resource.",
  "instance": "/api/v1/me",
  "traceId": "01JC3SC4M0YH8P3D6VW1K7QRZ2"
}

Related exceptions

  • Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingTokenException
  • Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException

Related services & controllers

UnitPath
security.yamlconfig/packages/security.yaml
ApiEntryPointsrc/Security/ApiEntryPoint.php

Notes for developers

Invalid Credentials

Permalink to this error
Error code
AUTH_BAD_CREDENTIALS
HTTP status
HTTP 401
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

The login endpoint received an identifier and password pair that did not verify.

Why it happens

The password hasher compares the submitted secret against the stored hash. On mismatch, and also when the identifier does not exist, a BadCredentialsException is thrown so the two cases are indistinguishable to the caller.

Common causes

  • Wrong password, or an identifier that does not exist.
  • User rows were imported without rehashing, so the stored value is not in the configured algorithm.
  • The login payload used different field names than the authenticator expects.
  • Password contained characters mangled by a client that did not send UTF-8.

How to fix it

  1. 1

    Confirm the payload keys the authenticator reads

    json
    {
      "email": "dev@example.com",
      "password": "correct horse battery staple"
    }
  2. 2

    Check the stored hash algorithm

    Imported bcrypt hashes will not verify under a sodium-only configuration without a migrate_from entry.

    yaml
    security:
      password_hashers:
        App\Entity\User:
          algorithm: auto
          migrate_from: ["bcrypt"]
  3. 3

    Keep the response deliberately vague

    Do not tell the client which half was wrong. User enumeration is the reason UserNotFoundException is folded into this error.

Example request or scenario

Request · http
POST /api/v1/login HTTP/1.1
Content-Type: application/json

{"email": "dev@example.com", "password": "hunter2"}

Example error response

401 Unauthorized · json
{
  "type": "https://errors.example.com/auth-bad-credentials",
  "title": "Invalid Credentials",
  "status": 401,
  "code": "AUTH_BAD_CREDENTIALS",
  "detail": "Invalid email or password.",
  "instance": "/api/v1/login",
  "traceId": "01JC3SD8X2NB4T7L9GJ5R0MPY6"
}

Related exceptions

  • Symfony\Component\Security\Core\Exception\BadCredentialsException
  • Symfony\Component\Security\Core\Exception\UserNotFoundException
  • Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException

Related services & controllers

UnitPath
JsonLoginAuthenticatorsrc/Security/JsonLoginAuthenticator.php
UserRepositorysrc/Repository/UserRepository.php
LoginThrottleListenersrc/Security/Listener/LoginThrottleListener.php

Notes for developers

User Not Found

Permalink to this error
Error code
USER_NOT_FOUND
HTTP status
HTTP 404
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

A user was addressed by identifier on a management endpoint and does not exist, or is no longer visible to the caller.

Why it happens

The repository returned null and the controller raised a domain not-found exception. This is distinct from the authentication path, where a missing user is reported as bad credentials.

Common causes

  • The identifier belongs to a soft-deleted or anonymised account.
  • A stale client cache still holds an id from a purged environment.
  • The caller is scoped to a tenant that does not own the record, and the query filter hid it.
  • A user provider still references an id whose row was removed while a session was live.

How to fix it

  1. 1

    Query with the tenant filter in mind

    A Doctrine filter can make a row invisible; that surfaces as not found rather than forbidden. Disable the filter only in admin contexts.

    php
    $user = $this->users->find($id)
        ?? throw new UserNotFoundDomainException($id);
  2. 2

    Treat soft-deleted accounts explicitly

    Return 410 Gone instead if the product needs to distinguish deleted from never-existed.

  3. 3

    Invalidate sessions when a user is removed

    bash
    php bin/console app:sessions:invalidate --user=42

Example request or scenario

Request · http
GET /api/v1/users/9c1f0e2a-0000-4000-8000-000000000000 HTTP/1.1
Authorization: Bearer <admin token>

Example error response

404 Not Found · json
{
  "type": "https://errors.example.com/user-not-found",
  "title": "User Not Found",
  "status": 404,
  "code": "USER_NOT_FOUND",
  "detail": "No user matches the given identifier.",
  "instance": "/api/v1/users/9c1f0e2a-0000-4000-8000-000000000000",
  "traceId": "01JC3SF2QK6R8W1Z3B5N7D9TVX"
}

Related exceptions

  • App\Domain\Exception\UserNotFoundDomainException
  • Symfony\Component\Security\Core\Exception\UserNotFoundException
  • Doctrine\ORM\EntityNotFoundException

Related services & controllers

UnitPath
UserControllersrc/Controller/Api/UserController.php
UserRepositorysrc/Repository/UserRepository.php
TenantFiltersrc/Doctrine/Filter/TenantFilter.php

Account Disabled

Permalink to this error
Error code
AUTH_ACCOUNT_DISABLED
HTTP status
HTTP 403
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

Credentials were correct, but a user checker rejected the account: it is deactivated, locked after abuse, or awaiting email verification.

Why it happens

User checkers run after the credentials are validated, during checkPostAuth. They throw an AccountStatusException subclass, which the failure handler renders as 403 because the identity is known.

Common causes

  • An administrator deactivated the account.
  • Automatic lockout after repeated failed logins.
  • Email or phone verification never completed.
  • The subscription lapsed and a checker gates access on billing state.

How to fix it

  1. 1

    Read the reason from your user checker, not from the status

    php
    public function checkPostAuth(UserInterface $user): void
    {
        if (!$user->isVerified()) {
            throw new AccountNotVerifiedException();
        }
    
        if ($user->isLocked()) {
            throw new LockedException();
        }
    }
  2. 2

    Give the client an actionable next step

    Include a resend-verification or contact-support hint in detail so the UI can render the right recovery path.

  3. 3

    Reactivate from the console when appropriate

    bash
    php bin/console app:user:enable dev@example.com

Example request or scenario

Request · http
POST /api/v1/login HTTP/1.1
Content-Type: application/json

{"email": "pending@example.com", "password": "<correct>"}

Example error response

403 Forbidden · json
{
  "type": "https://errors.example.com/auth-account-disabled",
  "title": "Account Disabled",
  "status": 403,
  "code": "AUTH_ACCOUNT_DISABLED",
  "detail": "This account is not verified yet.",
  "instance": "/api/v1/login",
  "traceId": "01JC3SG5R9TD2H4M6K8P0X1ZWB",
  "meta": { "reason": "email_unverified", "resendUrl": "/api/v1/email/resend" }
}

Related exceptions

  • Symfony\Component\Security\Core\Exception\DisabledException
  • Symfony\Component\Security\Core\Exception\LockedException
  • Symfony\Component\Security\Core\Exception\AccountExpiredException
  • App\Security\Exception\AccountNotVerifiedException

Related services & controllers

UnitPath
AccountStatusCheckersrc/Security/AccountStatusChecker.php
AuthenticationFailureHandlersrc/Security/AuthenticationFailureHandler.php

Notes for developers

Refresh Token Revoked

Permalink to this error
Error code
AUTH_REFRESH_REVOKED
HTTP status
HTTP 401
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

A refresh token was presented that is no longer in the active store: it was rotated, used twice, or invalidated by a logout or password change.

Why it happens

Refresh tokens are single use. On exchange the old record is deleted and a new one is issued. Replaying the old value therefore finds nothing, which is treated as a possible token theft signal.

Common causes

  • Two tabs or two threads refreshed concurrently and one lost the race.
  • The user logged out, changed password, or an admin revoked all sessions.
  • The token exceeded its absolute lifetime.
  • A stolen token was replayed after the legitimate client already rotated it.

How to fix it

  1. 1

    Serialise refresh calls in the client

    Share one in-flight refresh promise across callers so parallel requests cannot double-spend the token.

    ts
    let inflight: Promise<Session> | null = null
    
    export function refresh() {
      inflight ??= doRefresh().finally(() => {
        inflight = null
      })
      return inflight
    }
  2. 2

    Send the user back to login without retrying

    This error is terminal. Clear the local session and stop; retrying only produces more 401s.

  3. 3

    Alert on reuse of an already-rotated token

    Reuse after successful rotation is the classic theft indicator. Revoke the whole family for that user.

    bash
    php bin/console app:refresh-token:revoke --user=42 --all

Example request or scenario

Request · http
POST /api/v1/token/refresh HTTP/1.1
Content-Type: application/json

{"refresh_token": "already-rotated-value"}

Example error response

401 Unauthorized · json
{
  "type": "https://errors.example.com/auth-refresh-revoked",
  "title": "Refresh Token Revoked",
  "status": 401,
  "code": "AUTH_REFRESH_REVOKED",
  "detail": "This refresh token is no longer valid. Sign in again.",
  "instance": "/api/v1/token/refresh",
  "traceId": "01JC3SH8T1WF3J5N7Q9S2Y4BXD"
}

Related exceptions

  • App\Security\Exception\RefreshTokenRevokedException
  • Gesdinet\JWTRefreshTokenBundle\Exception\InvalidRefreshTokenException

Related services & controllers

UnitPath
RefreshTokenStoresrc/Security/RefreshTokenStore.php
TokenRefreshControllersrc/Controller/Api/TokenRefreshController.php

Notes for developers

Validation Errors

Raised while the request body or query string is turned into typed objects. These errors are always the caller's to fix and always carry a machine-readable violation list.

Validation Failed

Permalink to this error
Error code
VALIDATION_FAILED
HTTP status
HTTP 422
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The payload was well-formed JSON and mapped onto a DTO, but one or more constraints failed.

Why it happens

MapRequestPayload validates the DTO after denormalisation and throws a ValidationFailedException, which the kernel converts to 422 with the violation list preserved.

Common causes

  • A required field was empty, or a value fell outside its allowed range.
  • A nested collection item failed while the parent looked fine.
  • Client and server disagree on the version of the schema.
  • A Valid cascade was forgotten, so nested violations appear only after a later save.

How to fix it

  1. 1

    Read the errors array, not just detail

    Each entry carries a property path that maps one-to-one onto a form field.

    ts
    for (const v of body.errors ?? []) {
      setFieldError(v.field, v.message)
    }
  2. 2

    Keep constraints on the DTO, not the entity, for inbound payloads

    php
    final class CreateOrderRequest
    {
        public function __construct(
            #[Assert\NotBlank]
            #[Assert\Uuid]
            public string $customerId,
    
            #[Assert\Count(min: 1, max: 50)]
            #[Assert\Valid]
            public array $lines = [],
        ) {}
    }
  3. 3

    Reproduce quickly against the validator

    bash
    php bin/console debug:validator "App\\Dto\\CreateOrderRequest"

Example request or scenario

Request · http
POST /api/v1/orders HTTP/1.1
Content-Type: application/json

{"customerId": "", "lines": []}

Example error response

422 Unprocessable Entity · json
{
  "type": "https://errors.example.com/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "code": "VALIDATION_FAILED",
  "detail": "The request payload failed validation.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3SJ0V4XG5K7P9T1W3Z5CDF",
  "errors": [
    { "field": "customerId", "message": "This value should not be blank.", "code": "IS_BLANK_ERROR" },
    { "field": "lines", "message": "This collection should contain 1 element or more.", "code": "TOO_FEW_ERROR" }
  ]
}

Related exceptions

  • Symfony\Component\Validator\Exception\ValidationFailedException
  • Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException

Related services & controllers

UnitPath
ValidationExceptionSubscribersrc/Http/EventSubscriber/ValidationExceptionSubscriber.php
OrderControllersrc/Controller/Api/OrderController.php

Notes for developers

Malformed JSON Body

Permalink to this error
Error code
REQUEST_BODY_MALFORMED
HTTP status
HTTP 400
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The request body could not be decoded, or a value could not be coerced into the target type.

Why it happens

The serializer throws before validation runs, so there is no violation list to report; only the syntax or type error is known.

Common causes

  • Trailing comma, single quotes, or an unescaped newline in the JSON.
  • A string was sent where the DTO declares int, and the property is not nullable.
  • The body was gzipped or form-encoded while the header claimed JSON.
  • An empty body on a route that requires a payload.

How to fix it

  1. 1

    Validate the payload locally before blaming the API

    bash
    jq . payload.json > /dev/null && echo "valid JSON"
  2. 2

    Send types that match the DTO declaration

    Correct · json
    { "quantity": 3, "priceCents": 1999 }
  3. 3

    Return the decoder message without leaking internals

    php
    if ($e instanceof NotEncodableValueException) {
        throw new BadRequestHttpException('Request body is not valid JSON.', $e);
    }

Example request or scenario

Request · http
POST /api/v1/orders HTTP/1.1
Content-Type: application/json

{"quantity": "three",}

Example error response

400 Bad Request · json
{
  "type": "https://errors.example.com/request-body-malformed",
  "title": "Malformed JSON Body",
  "status": 400,
  "code": "REQUEST_BODY_MALFORMED",
  "detail": "Request body is not valid JSON.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3SK3W6ZH6M8R0V2Y4B6DEG"
}

Related exceptions

  • Symfony\Component\Serializer\Exception\NotEncodableValueException
  • Symfony\Component\Serializer\Exception\NotNormalizableValueException
  • Symfony\Component\HttpKernel\Exception\BadRequestHttpException

Related services & controllers

UnitPath
ApiExceptionSubscribersrc/Http/EventSubscriber/ApiExceptionSubscriber.php

Missing Required Parameter

Permalink to this error
Error code
REQUEST_PARAMETER_MISSING
HTTP status
HTTP 400
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

A mandatory query parameter or route attribute was absent or empty.

Why it happens

MapQueryParameter and MapQueryString reject the request before the controller body executes when a non-nullable parameter has no value.

Common causes

  • Pagination or filter parameter omitted on a route that requires it.
  • An empty string was sent, which is not the same as absent for typed parameters.
  • Array-style parameters were sent with the wrong bracket syntax.
  • The client built the URL from a template and left a placeholder unreplaced.

How to fix it

  1. 1

    Give parameters defaults when they are genuinely optional

    php
    #[Route('/api/v1/orders', methods: ['GET'])]
    public function list(
        #[MapQueryParameter] int $page = 1,
        #[MapQueryParameter] int $perPage = 25,
        #[MapQueryParameter] string $status = 'any',
    ): JsonResponse {
  2. 2

    Name the missing parameter in the response

    Reuse the errors array with a field entry so the client can point at the exact input.

  3. 3

    Inspect what the route expects

    bash
    php bin/console debug:router order_list -v

Example request or scenario

Request · http
GET /api/v1/reports?from= HTTP/1.1

Example error response

400 Bad Request · json
{
  "type": "https://errors.example.com/request-parameter-missing",
  "title": "Missing Required Parameter",
  "status": 400,
  "code": "REQUEST_PARAMETER_MISSING",
  "detail": "Query parameter \"from\" is required.",
  "instance": "/api/v1/reports",
  "traceId": "01JC3SM6Y8BJ7P0T2X4A6D8FGH",
  "errors": [{ "field": "from", "message": "This parameter is required.", "code": "MISSING_PARAMETER" }]
}

Related exceptions

  • Symfony\Component\HttpKernel\Exception\BadRequestHttpException
  • Symfony\Component\HttpKernel\Exception\NotFoundHttpException

Related services & controllers

UnitPath
ReportControllersrc/Controller/Api/ReportController.php

Unsupported Media Type

Permalink to this error
Error code
REQUEST_MEDIA_TYPE_UNSUPPORTED
HTTP status
HTTP 415
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The Content-Type of the request is not one this endpoint can read.

Why it happens

The payload mapper picks a decoder from Content-Type. With no matching decoder it refuses the request rather than guessing the format.

Common causes

  • Content-Type omitted entirely, so the body was treated as form data.
  • A file was posted as raw binary to a route expecting multipart/form-data.
  • A charset suffix or vendor media type the endpoint does not declare.
  • An HTML form posted to a JSON-only endpoint.

How to fix it

  1. 1

    Always set Content-Type explicitly

    bash
    curl -X POST https://api.example.com/api/v1/orders \
      -H "Content-Type: application/json" \
      -d '{"customerId":"9c1f..."}'
  2. 2

    Declare accepted formats on the route

    php
    #[Route('/api/v1/orders', methods: ['POST'], format: 'json')]
  3. 3

    Use multipart for uploads

    Send the file part plus a JSON metadata part rather than encoding the file inside JSON.

Example request or scenario

Request · http
POST /api/v1/orders HTTP/1.1
Content-Type: text/xml

<order><customerId>9c1f</customerId></order>

Example error response

415 Unsupported Media Type · json
{
  "type": "https://errors.example.com/request-media-type-unsupported",
  "title": "Unsupported Media Type",
  "status": 415,
  "code": "REQUEST_MEDIA_TYPE_UNSUPPORTED",
  "detail": "This endpoint accepts application/json.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3SN9A0DK8R1V3Z5C7F9HJK"
}

Related exceptions

  • Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException

Related services & controllers

UnitPath
ApiExceptionSubscribersrc/Http/EventSubscriber/ApiExceptionSubscriber.php

Authorization Errors

Raised after the caller is known but the action is not permitted. Voters and access control rules produce these; they are never retryable with the same credentials.

Access Denied

Permalink to this error
Error code
ACCESS_DENIED
HTTP status
HTTP 403
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

An authenticated caller attempted an action that the access decision manager refused.

Why it happens

IsGranted or a manual denyAccessUnlessGranted call triggers the voters. If none grants access, an AccessDeniedException is thrown and the firewall renders 403.

Common causes

  • The required role is absent from the token.
  • A voter returned abstain for every voter, and the strategy denies by default.
  • Roles changed in the database but the caller still holds an old token.
  • The attribute string in IsGranted does not match what the voter supports.

How to fix it

  1. 1

    Ask the decision manager what happened

    The profiler security panel lists every voter and its vote for the request.

    bash
    php bin/console debug:container security.access.decision_manager
  2. 2

    Make voter support explicit

    php
    protected function supports(string $attribute, mixed $subject): bool
    {
        return \in_array($attribute, ['ORDER_VIEW', 'ORDER_CANCEL'], true)
            && $subject instanceof Order;
    }
  3. 3

    Reissue the token after a role change

    Roles are baked into the JWT payload. Force a refresh so new roles take effect.

Example request or scenario

Request · http
DELETE /api/v1/orders/8f21 HTTP/1.1
Authorization: Bearer <token with ROLE_USER only>

Example error response

403 Forbidden · json
{
  "type": "https://errors.example.com/access-denied",
  "title": "Access Denied",
  "status": 403,
  "code": "ACCESS_DENIED",
  "detail": "You are not allowed to perform this action.",
  "instance": "/api/v1/orders/8f21",
  "traceId": "01JC3SP1C2FM9T3X5B7E9H1KLM"
}

Related exceptions

  • Symfony\Component\Security\Core\Exception\AccessDeniedException
  • Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException

Related services & controllers

UnitPath
OrderVotersrc/Security/Voter/OrderVoter.php
security.yamlconfig/packages/security.yaml

Notes for developers

Insufficient Role

Permalink to this error
Error code
ROLE_INSUFFICIENT
HTTP status
HTTP 403
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

A route guarded by a specific role was reached by a caller whose token does not include it, directly or through the hierarchy.

Why it happens

Role checks are resolved by RoleHierarchyVoter. Only roles reachable from those on the token count, so a missing hierarchy edge fails the check even when the intent was to allow it.

Common causes

  • role_hierarchy is missing an edge, for example ROLE_MANAGER does not include ROLE_SUPPORT.
  • The role was granted on the user entity but not exposed by getRoles.
  • Environment drift: the hierarchy differs between staging and production.
  • A typo in the role constant, such as ROLE_ADMINISTRATOR versus ROLE_ADMIN.

How to fix it

  1. 1

    Model the hierarchy once, in config

    yaml
    security:
      role_hierarchy:
        ROLE_SUPPORT: [ROLE_USER]
        ROLE_MANAGER: [ROLE_SUPPORT]
        ROLE_ADMIN:   [ROLE_MANAGER]
  2. 2

    Inspect the effective roles of the token

    bash
    php bin/console app:user:roles dev@example.com --effective
  3. 3

    Reference roles through constants

    php
    #[IsGranted(Roles::MANAGER)]
    final class AdminReportController { }

Example request or scenario

Request · http
GET /api/v1/admin/reports HTTP/1.1
Authorization: Bearer <token with ROLE_SUPPORT>

Example error response

403 Forbidden · json
{
  "type": "https://errors.example.com/role-insufficient",
  "title": "Insufficient Role",
  "status": 403,
  "code": "ROLE_INSUFFICIENT",
  "detail": "ROLE_MANAGER is required for this endpoint.",
  "instance": "/api/v1/admin/reports",
  "traceId": "01JC3SQ4E4HN0V5Z7D9G1K3MNP"
}

Related exceptions

  • Symfony\Component\Security\Core\Exception\AccessDeniedException

Related services & controllers

UnitPath
security.yamlconfig/packages/security.yaml
Rolessrc/Security/Roles.php

Resource Ownership Mismatch

Permalink to this error
Error code
RESOURCE_FORBIDDEN
HTTP status
HTTP 403
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

The caller holds the right role but does not own the addressed record, and the endpoint does not allow cross-tenant access.

Why it happens

Role checks are coarse. A voter comparing the subject owner with the current user provides the fine-grained decision, and denies when they differ.

Common causes

  • An identifier from another tenant was guessed or leaked into a client cache.
  • The controller checked the role but forgot the object-level voter.
  • A support agent impersonated a user without the impersonation attribute.
  • A shared resource was moved to another workspace.

How to fix it

  1. 1

    Always pass the subject to the authorization check

    php
    #[Route('/api/v1/orders/{id}', methods: ['GET'])]
    #[IsGranted('ORDER_VIEW', subject: 'order')]
    public function show(Order $order): JsonResponse
    {
        return $this->json($this->presenter->present($order));
    }
  2. 2

    Decide deliberately between 403 and 404

    403 confirms the record exists. If existence itself is confidential, answer 404 instead and document that choice per endpoint.

  3. 3

    Enforce tenancy at the query layer as a second line of defence

    php
    $qb->andWhere('o.tenant = :tenant')
       ->setParameter('tenant', $this->tenantContext->current());

Example request or scenario

Request · http
GET /api/v1/orders/8f21 HTTP/1.1
Authorization: Bearer <token for a different tenant>

Example error response

403 Forbidden · json
{
  "type": "https://errors.example.com/resource-forbidden",
  "title": "Resource Ownership Mismatch",
  "status": 403,
  "code": "RESOURCE_FORBIDDEN",
  "detail": "This resource belongs to another account.",
  "instance": "/api/v1/orders/8f21",
  "traceId": "01JC3SR7G6JP1X7B9F1H3M5NPQ"
}

Related exceptions

  • Symfony\Component\Security\Core\Exception\AccessDeniedException
  • App\Domain\Exception\TenantMismatchException

Related services & controllers

UnitPath
OrderVotersrc/Security/Voter/OrderVoter.php
TenantContextsrc/Tenancy/TenantContext.php

Notes for developers

Invalid CSRF Token

Permalink to this error
Error code
CSRF_TOKEN_INVALID
HTTP status
HTTP 403
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

A session-backed form or cookie-authenticated endpoint received a request without a valid CSRF token.

Why it happens

The CSRF manager compares the submitted token with the one stored in the session. A missing session, a rotated token, or a cross-site submission all fail the comparison.

Common causes

  • The form page was cached and its token expired with the session.
  • The session cookie was blocked by SameSite rules during a cross-site post.
  • The token field was renamed, or the form was rebuilt client side without it.
  • CSRF protection is enabled on an endpoint that is actually stateless and token-authenticated.

How to fix it

  1. 1

    Render the token with the form

    twig
    <form method="post">
      <input type="hidden" name="_token" value="{{ csrf_token('delete_item') }}">
    </form>
  2. 2

    Do not enable CSRF on stateless JSON APIs

    Bearer-token endpoints are not vulnerable to classic CSRF; leave the firewall stateless and skip the check.

    yaml
    security:
      firewalls:
        api:
          stateless: true
  3. 3

    Avoid caching pages that embed tokens

    Send Cache-Control: private, no-store on any HTML containing a CSRF token.

Example request or scenario

Request · http
POST /admin/items/12/delete HTTP/1.1
Cookie: PHPSESSID=...
Content-Type: application/x-www-form-urlencoded

_token=stale-value

Example error response

403 Forbidden · json
{
  "type": "https://errors.example.com/csrf-token-invalid",
  "title": "Invalid CSRF Token",
  "status": 403,
  "code": "CSRF_TOKEN_INVALID",
  "detail": "The security token is invalid or expired. Reload the page and retry.",
  "instance": "/admin/items/12/delete",
  "traceId": "01JC3SS0J8KQ2Z9D1H3K5P7QRS"
}

Related exceptions

  • Symfony\Component\Security\Csrf\Exception\InvalidCsrfTokenException
  • Symfony\Component\Form\Exception\InvalidCsrfTokenException

Related services & controllers

UnitPath
AdminItemControllersrc/Controller/Admin/AdminItemController.php
framework.yamlconfig/packages/framework.yaml

Business Logic Errors

Raised by the domain layer when a request is well-formed and permitted but conflicts with the current state of an aggregate. These are expected outcomes, not defects.

Invalid State Transition

Permalink to this error
Error code
STATE_TRANSITION_INVALID
HTTP status
HTTP 409
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The requested transition is not allowed from the entity's current state.

Why it happens

The workflow component blocks transitions whose guards or from-places do not match. The domain service converts that block into a conflict rather than a server error.

Common causes

  • The client acted on stale data and the entity already moved on.
  • Two operators acted on the same record at once.
  • A guard expression depends on data that is not set yet.
  • The workflow definition omits the transition the product expects.

How to fix it

  1. 1

    Ask the workflow before acting

    php
    if (!$this->workflow->can($order, 'ship')) {
        throw new InvalidStateTransitionException($order->getStatus(), 'ship');
    }
    
    $this->workflow->apply($order, 'ship');
  2. 2

    Expose the allowed transitions in the resource representation

    Let the UI hide impossible actions instead of discovering them through 409s.

    json
    { "id": "8f21", "status": "paid", "allowedTransitions": ["ship", "refund"] }
  3. 3

    Visualise the definition when debugging

    bash
    php bin/console workflow:dump order | dot -Tpng -o order.png

Example request or scenario

Request · http
POST /api/v1/orders/8f21/ship HTTP/1.1
Authorization: Bearer <token>

(order status is "draft")

Example error response

409 Conflict · json
{
  "type": "https://errors.example.com/state-transition-invalid",
  "title": "Invalid State Transition",
  "status": 409,
  "code": "STATE_TRANSITION_INVALID",
  "detail": "An order in status \"draft\" cannot be shipped.",
  "instance": "/api/v1/orders/8f21/ship",
  "traceId": "01JC3ST3L0MR3B1F3K5N7Q9STU",
  "meta": { "currentStatus": "draft", "attempted": "ship", "allowedTransitions": ["submit", "cancel"] }
}

Related exceptions

  • App\Domain\Exception\InvalidStateTransitionException
  • Symfony\Component\Workflow\Exception\NotEnabledTransitionException
  • Symfony\Component\Workflow\Exception\UndefinedTransitionException

Related services & controllers

UnitPath
OrderWorkflowconfig/packages/workflow.yaml
OrderShippingServicesrc/Domain/Order/OrderShippingService.php

Notes for developers

Order Already Paid

Permalink to this error
Error code
ORDER_ALREADY_PAID
HTTP status
HTTP 409
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

A payment was attempted for an order that is already settled.

Why it happens

The payment service checks the aggregate before contacting the provider so a duplicate submission cannot create a second charge.

Common causes

  • The user double-clicked, or the client retried after a timeout that had actually succeeded.
  • A provider webhook marked the order paid before the synchronous response returned.
  • A replayed webhook without idempotency handling.
  • Manual reconciliation marked the order paid out of band.

How to fix it

  1. 1

    Send an idempotency key with every payment attempt

    http
    POST /api/v1/orders/8f21/pay HTTP/1.1
    Idempotency-Key: 4f1c9b2e-7d3a-4a91-9c6e-0b2f8d7a1c33
  2. 2

    Treat this as success in the client

    The desired end state is already reached. Show the receipt instead of an error dialog.

  3. 3

    Guard inside the transaction

    php
    $order = $this->orders->findForUpdate($id);
    
    if ($order->isPaid()) {
        throw new OrderAlreadyPaidException($order->getId());
    }

Example request or scenario

Request · http
POST /api/v1/orders/8f21/pay HTTP/1.1
Authorization: Bearer <token>

{"paymentMethodId": "pm_1234"}

Example error response

409 Conflict · json
{
  "type": "https://errors.example.com/order-already-paid",
  "title": "Order Already Paid",
  "status": 409,
  "code": "ORDER_ALREADY_PAID",
  "detail": "Order 8f21 was paid on 2026-07-29T18:22:41+00:00.",
  "instance": "/api/v1/orders/8f21/pay",
  "traceId": "01JC3SV6N2PS4D3H5M7Q9S1TUV",
  "meta": { "paymentId": "pay_88213", "receiptUrl": "/api/v1/payments/pay_88213/receipt" }
}

Related exceptions

  • App\Domain\Exception\OrderAlreadyPaidException
  • App\Domain\Exception\DomainConflictException

Related services & controllers

UnitPath
PaymentServicesrc/Domain/Payment/PaymentService.php
PaymentWebhookControllersrc/Controller/Webhook/PaymentWebhookController.php

Insufficient Stock

Permalink to this error
Error code
STOCK_INSUFFICIENT
HTTP status
HTTP 409
Severity
Client fault
Retry policy
Retryable
Log level
log: info

The requested quantity exceeds what can be reserved for one or more lines.

Why it happens

Reservation happens inside a transaction with a row lock. If available stock is below the requested amount the whole reservation is rolled back, so an order is never partially reserved.

Common causes

  • Another customer reserved the remaining units first.
  • A stock sync from the warehouse lowered the figure after the page was rendered.
  • Reserved-but-unconfirmed carts are holding inventory that has not expired yet.
  • The catalogue read model is cached and shows a stale availability figure.

How to fix it

  1. 1

    Return per-line availability so the cart can self-correct

    json
    {
      "errors": [
        { "field": "lines[0].quantity", "message": "Only 2 units available.", "code": "STOCK_INSUFFICIENT", "meta": { "requested": 5, "available": 2 } }
      ]
    }
  2. 2

    Lock the row, do not read then write

    php
    $this->em->wrapInTransaction(function () use ($sku, $qty): void {
        $item = $this->inventory->findOneBySkuForUpdate($sku);
    
        if ($item->available() < $qty) {
            throw new InsufficientStockException($sku, $qty, $item->available());
        }
    
        $item->reserve($qty);
    });
  3. 3

    Expire abandoned reservations

    A scheduled release job keeps phantom shortages from accumulating.

Example request or scenario

Request · http
POST /api/v1/orders HTTP/1.1
Content-Type: application/json

{"customerId": "9c1f...", "lines": [{"sku": "TSHIRT-M", "quantity": 5}]}

Example error response

409 Conflict · json
{
  "type": "https://errors.example.com/stock-insufficient",
  "title": "Insufficient Stock",
  "status": 409,
  "code": "STOCK_INSUFFICIENT",
  "detail": "One or more lines cannot be reserved.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3SW9Q4RT5F5K7P9S1U3VWX",
  "errors": [
    { "field": "lines[0].quantity", "message": "Only 2 units available.", "code": "STOCK_INSUFFICIENT" }
  ]
}

Related exceptions

  • App\Domain\Exception\InsufficientStockException
  • Doctrine\DBAL\Exception\LockWaitTimeoutException

Related services & controllers

UnitPath
InventoryReservationServicesrc/Domain/Inventory/InventoryReservationService.php
InventoryRepositorysrc/Repository/InventoryRepository.php

Notes for developers

Plan Limit Reached

Permalink to this error
Error code
PLAN_LIMIT_REACHED
HTTP status
HTTP 402
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The account reached a quota defined by its subscription plan, such as seats, projects, or monthly API calls.

Why it happens

A quota guard runs before the write. It compares current usage with the plan entitlement and rejects the operation with a payment-required status so the client can route the user to billing.

Common causes

  • The plan entitlement is genuinely exhausted.
  • Usage counters were not decremented when resources were deleted.
  • A downgrade took effect while usage exceeded the lower tier.
  • Trial expired and entitlements fell back to the free tier.

How to fix it

  1. 1

    Report usage and the upgrade path

    json
    { "meta": { "limit": 5, "used": 5, "resource": "projects", "upgradeUrl": "/billing/plans" } }
  2. 2

    Check quota in one place

    php
    $this->quota->assertWithin('projects', $account)
        ?: throw new PlanLimitReachedException('projects', $limit);
  3. 3

    Recompute counters after bulk deletions

    bash
    php bin/console app:quota:recalculate --account=42

Example request or scenario

Request · http
POST /api/v1/projects HTTP/1.1
Authorization: Bearer <token on the starter plan>

{"name": "Sixth project"}

Example error response

402 Payment Required · json
{
  "type": "https://errors.example.com/plan-limit-reached",
  "title": "Plan Limit Reached",
  "status": 402,
  "code": "PLAN_LIMIT_REACHED",
  "detail": "The starter plan allows 5 projects.",
  "instance": "/api/v1/projects",
  "traceId": "01JC3SX2S6TV6H7M9Q1U3W5XYZ",
  "meta": { "limit": 5, "used": 5, "resource": "projects", "upgradeUrl": "/billing/plans" }
}

Related exceptions

  • App\Domain\Exception\PlanLimitReachedException
  • App\Billing\Exception\EntitlementException

Related services & controllers

UnitPath
QuotaGuardsrc/Billing/QuotaGuard.php
SubscriptionRepositorysrc/Repository/SubscriptionRepository.php

Notes for developers

Database Errors

Raised by Doctrine DBAL and ORM. Constraint violations are mapped to specific client-facing conflicts; connectivity and deadlock failures are treated as infrastructure problems.

Unique Constraint Violation

Permalink to this error
Error code
RESOURCE_ALREADY_EXISTS
HTTP status
HTTP 409
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

An insert or update collided with a unique index, for example a duplicate email or slug.

Why it happens

Application-level uniqueness checks are racy. The database index is the real guarantee, so a duplicate that slips past validation surfaces here and is translated into a 409.

Common causes

  • Two concurrent requests passed the UniqueEntity check before either committed.
  • A retried request created the row twice because the endpoint is not idempotent.
  • An import job did not deduplicate its input.
  • Case sensitivity differs between the validator and the collation of the index.

How to fix it

  1. 1

    Keep the validator for messaging and the index for correctness

    php
    #[ORM\Entity]
    #[ORM\UniqueConstraint(name: 'uniq_user_email', columns: ['email'])]
    #[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
    class User { }
  2. 2

    Translate the DBAL exception into a domain conflict

    php
    use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
    
    try {
        $this->em->flush();
    } catch (UniqueConstraintViolationException $e) {
        throw new ResourceAlreadyExistsException('user.email', $e);
    }
  3. 3

    Make creation idempotent where duplicates are expected

    An upsert keyed on a natural identifier removes the error class entirely for importers and webhooks.

Example request or scenario

Request · http
POST /api/v1/users HTTP/1.1
Content-Type: application/json

{"email": "dev@example.com", "name": "Dev"}

Example error response

409 Conflict · json
{
  "type": "https://errors.example.com/resource-already-exists",
  "title": "Unique Constraint Violation",
  "status": 409,
  "code": "RESOURCE_ALREADY_EXISTS",
  "detail": "A user with this email already exists.",
  "instance": "/api/v1/users",
  "traceId": "01JC3SY5U8VW7K9P1S3W5Y7ZAB",
  "errors": [{ "field": "email", "message": "This email is already registered.", "code": "NOT_UNIQUE" }]
}

Related exceptions

  • Doctrine\DBAL\Exception\UniqueConstraintViolationException
  • App\Domain\Exception\ResourceAlreadyExistsException

Related services & controllers

UnitPath
DoctrineExceptionSubscribersrc/Http/EventSubscriber/DoctrineExceptionSubscriber.php
UserRegistrationServicesrc/Domain/User/UserRegistrationService.php

Notes for developers

Foreign Key Violation

Permalink to this error
Error code
RESOURCE_IN_USE
HTTP status
HTTP 409
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

A delete was refused because dependent rows still reference the record, or an insert referenced a parent that does not exist.

Why it happens

Referential integrity is enforced by the database. Doctrine surfaces the driver error, which is translated into a conflict describing the blocking relation.

Common causes

  • Deleting a category that still has products.
  • Inserting a child with an id whose parent was removed concurrently.
  • Cascade rules configured in the ORM but not in the schema, or the reverse.
  • Fixtures loaded in the wrong order.

How to fix it

  1. 1

    Decide the deletion policy explicitly

    php
    #[ORM\ManyToOne(inversedBy: 'products')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
    private Category $category;
  2. 2

    Offer a soft alternative in the API

    Archiving instead of deleting usually matches user intent and avoids the conflict altogether.

  3. 3

    Tell the client what blocks the delete

    json
    { "meta": { "blockedBy": "products", "count": 14 } }

Example request or scenario

Request · http
DELETE /api/v1/categories/7 HTTP/1.1
Authorization: Bearer <admin token>

Example error response

409 Conflict · json
{
  "type": "https://errors.example.com/resource-in-use",
  "title": "Foreign Key Violation",
  "status": 409,
  "code": "RESOURCE_IN_USE",
  "detail": "This category still has products assigned.",
  "instance": "/api/v1/categories/7",
  "traceId": "01JC3SZ8W0XY8M1S3U5Y7A9BCD",
  "meta": { "blockedBy": "products", "count": 14 }
}

Related exceptions

  • Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException
  • Doctrine\DBAL\Exception\NotNullConstraintViolationException

Related services & controllers

UnitPath
CategoryControllersrc/Controller/Api/CategoryController.php
DoctrineExceptionSubscribersrc/Http/EventSubscriber/DoctrineExceptionSubscriber.php

Entity Not Found

Permalink to this error
Error code
RESOURCE_NOT_FOUND
HTTP status
HTTP 404
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

A record addressed by identifier does not exist, or a lazy proxy could not be initialised because its row disappeared.

Why it happens

Route parameter converters and repository lookups throw when nothing matches, which the kernel renders as 404. Proxy initialisation failures indicate the row vanished after the reference was loaded.

Common causes

  • The id is wrong, or belongs to another environment.
  • The row was deleted between two requests.
  • A Doctrine filter hides the row from the current context.
  • A stale relation still points at a deleted parent, so the proxy cannot load.

How to fix it

  1. 1

    Prefer explicit lookups over silent nulls

    php
    $order = $this->orders->find($id)
        ?? throw new NotFoundHttpException(\sprintf('Order %s not found.', $id));
  2. 2

    Check for orphaned references

    bash
    php bin/console doctrine:schema:validate
  3. 3

    Keep the message generic in production

    Do not echo internal identifiers or table names in the public detail string.

Example request or scenario

Request · http
GET /api/v1/orders/does-not-exist HTTP/1.1

Example error response

404 Not Found · json
{
  "type": "https://errors.example.com/resource-not-found",
  "title": "Entity Not Found",
  "status": 404,
  "code": "RESOURCE_NOT_FOUND",
  "detail": "The requested resource does not exist.",
  "instance": "/api/v1/orders/does-not-exist",
  "traceId": "01JC3T0BY2ZA9P3U5W7A9C1DEF"
}

Related exceptions

  • Doctrine\ORM\EntityNotFoundException
  • Doctrine\ORM\NoResultException
  • Symfony\Component\HttpKernel\Exception\NotFoundHttpException

Related services & controllers

UnitPath
EntityValueResolvervendor/symfony (built in)
OrderRepositorysrc/Repository/OrderRepository.php

Optimistic Lock Failure

Permalink to this error
Error code
CONCURRENT_MODIFICATION
HTTP status
HTTP 409
Severity
Recoverable
Retry policy
Retryable
Log level
log: info

The record was modified by someone else after the caller read it, so the versioned update was rejected.

Why it happens

A Version column is compared during flush. When the stored version no longer matches the one loaded, Doctrine aborts to prevent a lost update.

Common causes

  • Two users edited the same record simultaneously.
  • A background job wrote to the entity while a request held an older copy.
  • The client omitted or hardcoded the version field.
  • A long-lived form was submitted much later than it was rendered.

How to fix it

  1. 1

    Version the entity and require the version on write

    php
    #[ORM\Version]
    #[ORM\Column(type: 'integer')]
    private int $version = 1;
  2. 2

    Reload, re-apply, retry once

    php
    try {
        $this->em->flush();
    } catch (OptimisticLockException) {
        $this->em->refresh($order);
        $this->applyChanges($order, $command);
        $this->em->flush();
    }
  3. 3

    Surface a merge prompt in the UI

    For human-facing edits, show what changed rather than silently overwriting the other person's work.

Example request or scenario

Request · http
PATCH /api/v1/orders/8f21 HTTP/1.1
If-Match: "3"
Content-Type: application/json

{"note": "leave at door"}

Example error response

409 Conflict · json
{
  "type": "https://errors.example.com/concurrent-modification",
  "title": "Optimistic Lock Failure",
  "status": 409,
  "code": "CONCURRENT_MODIFICATION",
  "detail": "This record changed since you loaded it. Reload and try again.",
  "instance": "/api/v1/orders/8f21",
  "traceId": "01JC3T1EA4AB0S5W7Y9C1E3FGH",
  "meta": { "expectedVersion": 3, "currentVersion": 5 }
}

Related exceptions

  • Doctrine\ORM\OptimisticLockException
  • Doctrine\ORM\PessimisticLockException

Related services & controllers

UnitPath
OrderUpdateHandlersrc/Domain/Order/OrderUpdateHandler.php

Notes for developers

Database Unavailable

Permalink to this error
Error code
DATABASE_UNAVAILABLE
HTTP status
HTTP 503
Severity
Server fault
Retry policy
Retryable
Log level
log: error

The connection could not be established or was lost mid-request: refused connection, exhausted pool, deadlock, or lock wait timeout.

Why it happens

These are infrastructure failures. The exception subscriber deliberately hides driver detail and answers 503 with a Retry-After hint so callers back off instead of hammering a struggling database.

Common causes

  • Credentials or DATABASE_URL wrong after a deploy or secret rotation.
  • Connection limit reached because workers or PHP-FPM children outnumber available slots.
  • Failover or maintenance window on the managed instance.
  • Deadlock or lock wait timeout under write contention.

How to fix it

  1. 1

    Verify connectivity from the application host

    bash
    php bin/console dbal:run-sql "SELECT 1"
    php bin/console debug:config doctrine dbal
  2. 2

    Size the pool against the real worker count

    Web workers plus Messenger consumers plus cron all draw from the same limit. Cap consumers before raising server limits.

  3. 3

    Retry deadlocks in the transaction boundary, not in the controller

    php
    use Doctrine\DBAL\Exception\DeadlockException;
    
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        try {
            return $this->em->wrapInTransaction($work);
        } catch (DeadlockException) {
            usleep(50_000 * $attempt);
        }
    }
    
    throw new DatabaseUnavailableException('Deadlock retries exhausted.');

Example request or scenario

Request · http
GET /api/v1/orders HTTP/1.1
Authorization: Bearer <token>

Example error response

503 Service Unavailable · json
{
  "type": "https://errors.example.com/database-unavailable",
  "title": "Database Unavailable",
  "status": 503,
  "code": "DATABASE_UNAVAILABLE",
  "detail": "The service is temporarily unable to process this request.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3T2HC6BC1U7Y9A1E3G5HIJ"
}

Related exceptions

  • Doctrine\DBAL\Exception\ConnectionException
  • Doctrine\DBAL\Exception\ConnectionLost
  • Doctrine\DBAL\Exception\DeadlockException
  • Doctrine\DBAL\Exception\LockWaitTimeoutException

Related services & controllers

UnitPath
DoctrineExceptionSubscribersrc/Http/EventSubscriber/DoctrineExceptionSubscriber.php
HealthControllersrc/Controller/HealthController.php

Notes for developers

External API Errors

Raised when a call to a third-party service fails. Every entry here distinguishes an upstream fault from a caller mistake, because only the former is safe to retry.

Upstream Timeout

Permalink to this error
Error code
UPSTREAM_TIMEOUT
HTTP status
HTTP 504
Severity
Server fault
Retry policy
Retryable
Log level
log: error

A dependency did not respond inside the configured timeout.

Why it happens

HttpClient enforces separate connect and total timeouts. Exceeding either aborts the transfer, and the gateway returns 504 to keep the caller's own timeout budget intact.

Common causes

  • The upstream is degraded or overloaded.
  • Timeouts are set higher than the caller's own budget, so failures cascade.
  • DNS or TLS negotiation is slow from the application network.
  • A large payload was requested without pagination.

How to fix it

  1. 1

    Set explicit, tight timeouts per client

    yaml
    framework:
      http_client:
        scoped_clients:
          billing.client:
            base_uri: 'https://billing.example.com'
            timeout: 3
            max_duration: 8
  2. 2

    Retry idempotent calls with jittered backoff only

    yaml
    framework:
      http_client:
        scoped_clients:
          billing.client:
            retry_failed:
              max_retries: 2
              delay: 250
              multiplier: 2
              jitter: 0.2
  3. 3

    Move slow work off the request path

    Dispatch a Messenger message and let the client poll, rather than holding an HTTP connection open.

Example request or scenario

Scenario · php
// Billing did not answer within max_duration
$response = $this->billing->request('POST', '/v1/invoices', ['json' => $payload]);
$data = $response->toArray(); // throws TransportException

Example error response

504 Gateway Timeout · json
{
  "type": "https://errors.example.com/upstream-timeout",
  "title": "Upstream Timeout",
  "status": 504,
  "code": "UPSTREAM_TIMEOUT",
  "detail": "The billing service did not respond in time.",
  "instance": "/api/v1/invoices",
  "traceId": "01JC3T3KE8CD2W9A1C3G5I7JKL",
  "meta": { "dependency": "billing", "timeoutSeconds": 8 }
}

Related exceptions

  • Symfony\Component\HttpClient\Exception\TimeoutException
  • Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface

Related services & controllers

UnitPath
BillingGatewaysrc/Integration/Billing/BillingGateway.php
http_client.yamlconfig/packages/framework.yaml

Notes for developers

Upstream Unavailable

Permalink to this error
Error code
UPSTREAM_UNAVAILABLE
HTTP status
HTTP 502
Severity
Server fault
Retry policy
Retryable
Log level
log: error

The dependency answered with a 5xx, closed the connection, or returned a body this application cannot parse.

Why it happens

A ServerException from the upstream is not the caller's fault, so it is reported as a bad gateway rather than propagating the upstream status verbatim.

Common causes

  • The upstream is deploying or has an internal fault.
  • A proxy in between returned an HTML error page instead of JSON.
  • TLS verification failed because of an expired or mismatched certificate.
  • The upstream changed its response schema.

How to fix it

  1. 1

    Fail fast with a circuit breaker

    After a burst of failures, stop calling for a cool-down window so queues do not fill with doomed work.

  2. 2

    Validate the upstream payload before trusting it

    php
    try {
        $data = $response->toArray();
    } catch (DecodingExceptionInterface|ServerExceptionInterface $e) {
        throw new UpstreamUnavailableException('billing', $e);
    }
  3. 3

    Degrade gracefully when the data is not critical

    Serve the last known good value from cache and flag the response as stale instead of failing the whole page.

Example request or scenario

Scenario · php
// Upstream returned 503 with an HTML maintenance page
$response = $this->billing->request('GET', '/v1/plans');
$plans = $response->toArray(); // throws ServerException

Example error response

502 Bad Gateway · json
{
  "type": "https://errors.example.com/upstream-unavailable",
  "title": "Upstream Unavailable",
  "status": 502,
  "code": "UPSTREAM_UNAVAILABLE",
  "detail": "The billing service is currently unavailable.",
  "instance": "/api/v1/plans",
  "traceId": "01JC3T4NG0DE3Y1C3E5I7K9LMN",
  "meta": { "dependency": "billing", "upstreamStatus": 503 }
}

Related exceptions

  • Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
  • Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
  • App\Integration\Exception\UpstreamUnavailableException

Related services & controllers

UnitPath
BillingGatewaysrc/Integration/Billing/BillingGateway.php
CircuitBreakersrc/Integration/CircuitBreaker.php

Payment Declined

Permalink to this error
Error code
PAYMENT_DECLINED
HTTP status
HTTP 402
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The payment provider processed the request successfully and refused the charge.

Why it happens

A decline is a valid upstream answer, not a failure of the integration. It is mapped to 402 with the provider decline code preserved so the client can explain what to do next.

Common causes

  • Insufficient funds, or the card is expired or blocked.
  • Issuer fraud rules rejected the transaction.
  • Strong customer authentication was required and not completed.
  • The billing address or CVC did not match.

How to fix it

  1. 1

    Pass through the decline reason, never the raw provider payload

    json
    { "meta": { "declineCode": "insufficient_funds", "provider": "stripe", "canRetry": false } }
  2. 2

    Handle the authentication-required branch

    When the provider asks for 3-D Secure, return the client secret and let the client complete the challenge.

  3. 3

    Do not auto-retry a decline

    Repeated attempts increase issuer risk scores. Ask for a different payment method instead.

Example request or scenario

Request · http
POST /api/v1/orders/8f21/pay HTTP/1.1
Content-Type: application/json

{"paymentMethodId": "pm_card_insufficient_funds"}

Example error response

402 Payment Required · json
{
  "type": "https://errors.example.com/payment-declined",
  "title": "Payment Declined",
  "status": 402,
  "code": "PAYMENT_DECLINED",
  "detail": "The card was declined for insufficient funds.",
  "instance": "/api/v1/orders/8f21/pay",
  "traceId": "01JC3T5QJ2EF4A3E5G7K9M1NOP",
  "meta": { "declineCode": "insufficient_funds", "provider": "stripe", "canRetry": false }
}

Related exceptions

  • App\Integration\Payment\Exception\PaymentDeclinedException
  • Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface

Related services & controllers

UnitPath
PaymentGatewaysrc/Integration/Payment/PaymentGateway.php
PaymentServicesrc/Domain/Payment/PaymentService.php

Notes for developers

Invalid Webhook Signature

Permalink to this error
Error code
WEBHOOK_SIGNATURE_INVALID
HTTP status
HTTP 400
Severity
Client fault
Retry policy
Not retryable
Log level
log: warning

An inbound webhook could not be verified against the shared signing secret.

Why it happens

Webhook endpoints are public, so the signature is the only proof of origin. Verification runs on the raw body before any parsing, and a mismatch rejects the delivery outright.

Common causes

  • The wrong signing secret for the environment.
  • The body was re-encoded by a proxy or read as a parsed array, so the raw bytes changed.
  • The timestamp is outside the replay tolerance window.
  • A genuine forgery attempt.

How to fix it

  1. 1

    Verify against the raw request content

    php
    $payload = $request->getContent(); // raw bytes, never the parsed array
    
    if (!hash_equals($this->sign($payload, $timestamp), $signature)) {
        throw new WebhookSignatureException();
    }
  2. 2

    Compare in constant time and enforce a tolerance

    Use hash_equals and reject timestamps older than five minutes to block replays.

  3. 3

    Confirm the secret bound to this environment

    bash
    php bin/console secrets:list --reveal | grep WEBHOOK

Example request or scenario

Request · http
POST /webhook/payment HTTP/1.1
X-Signature: t=1753862400,v1=deadbeef
Content-Type: application/json

{"type": "payment.succeeded"}

Example error response

400 Bad Request · json
{
  "type": "https://errors.example.com/webhook-signature-invalid",
  "title": "Invalid Webhook Signature",
  "status": 400,
  "code": "WEBHOOK_SIGNATURE_INVALID",
  "detail": "Signature verification failed.",
  "instance": "/webhook/payment",
  "traceId": "01JC3T6SL4FG5C5G7I9M1O3PQR"
}

Related exceptions

  • App\Integration\Exception\WebhookSignatureException
  • Symfony\Component\Webhook\Exception\RejectWebhookException

Related services & controllers

UnitPath
PaymentWebhookControllersrc/Controller/Webhook/PaymentWebhookController.php
WebhookSignatureVerifiersrc/Integration/WebhookSignatureVerifier.php

Notes for developers

Rate Limit Exceeded

Permalink to this error
Error code
RATE_LIMIT_EXCEEDED
HTTP status
HTTP 429
Severity
Recoverable
Retry policy
Retryable
Log level
log: warning

Too many requests were made in the current window, either against this API or against an upstream whose limit was propagated back.

Why it happens

The RateLimiter component consumes a token per request. When the bucket is empty it returns the wait time, which is sent as Retry-After so callers can back off precisely.

Common causes

  • A client loop without throttling, or a retry storm after a failure.
  • Many users sharing one NAT address on an IP-keyed limiter.
  • An import job running at full speed against a per-minute limit.
  • An upstream 429 that this service surfaces to its own caller.

How to fix it

  1. 1

    Honour Retry-After instead of guessing

    ts
    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") ?? 1) * 1000
      await sleep(wait)
      return retryOnce(request)
    }
  2. 2

    Configure a limiter per audience

    yaml
    framework:
      rate_limiter:
        api_authenticated:
          policy: 'token_bucket'
          limit: 120
          rate: { interval: '1 minute', amount: 120 }
        api_anonymous:
          policy: 'sliding_window'
          limit: 20
          interval: '1 minute'
  3. 3

    Key on the identity, not only the IP

    Use the user or API key as the limiter key so shared addresses do not punish unrelated callers.

Example request or scenario

Request · http
GET /api/v1/orders HTTP/1.1
Authorization: Bearer <token>

(121st request in the same minute)

Example error response

429 Too Many Requests · json
{
  "type": "https://errors.example.com/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "code": "RATE_LIMIT_EXCEEDED",
  "detail": "Request quota exhausted. Retry in 24 seconds.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3T7VN6GH6E7I9K1O3Q5RST",
  "meta": { "limit": 120, "remaining": 0, "retryAfter": 24 }
}

Related exceptions

  • Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException
  • Symfony\Component\RateLimiter\Exception\RateLimitExceededException

Related services & controllers

UnitPath
RateLimitSubscribersrc/Http/EventSubscriber/RateLimitSubscriber.php
rate_limiter.yamlconfig/packages/rate_limiter.yaml

Notes for developers

Platform & Runtime Errors

Everything produced by the kernel or by an unhandled failure. An entry here almost always means a defect or a misconfiguration rather than a caller mistake.

Route Not Found

Permalink to this error
Error code
ROUTE_NOT_FOUND
HTTP status
HTTP 404
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

No route matched the requested path.

Why it happens

The router throws before any controller is resolved, so this error never reaches application code.

Common causes

  • A typo, or a call to a route that only exists in another version prefix.
  • A missing trailing segment, or a stale client built against an older API version.
  • The route is defined but its controller is excluded from the current environment.
  • The reverse proxy rewrote the path before forwarding.

How to fix it

  1. 1

    List what the router actually knows

    bash
    php bin/console debug:router | grep orders
  2. 2

    Match on the exact path and method

    bash
    php bin/console router:match /api/v1/orders --method=POST
  3. 3

    Version the API in the path

    A /api/v1 prefix makes breaking changes additive and keeps old clients working.

Example request or scenario

Request · http
GET /api/v1/order HTTP/1.1

Example error response

404 Not Found · json
{
  "type": "https://errors.example.com/route-not-found",
  "title": "Route Not Found",
  "status": 404,
  "code": "ROUTE_NOT_FOUND",
  "detail": "No route matches GET /api/v1/order.",
  "instance": "/api/v1/order",
  "traceId": "01JC3T8XQ8HJ7G9K1M3Q5S7TUV"
}

Related exceptions

  • Symfony\Component\HttpKernel\Exception\NotFoundHttpException
  • Symfony\Component\Routing\Exception\ResourceNotFoundException

Related services & controllers

UnitPath
routes.yamlconfig/routes.yaml

Method Not Allowed

Permalink to this error
Error code
METHOD_NOT_ALLOWED
HTTP status
HTTP 405
Severity
Client fault
Retry policy
Not retryable
Log level
log: info

The path exists but does not accept the HTTP method used.

Why it happens

The router matched the path and rejected the verb. The response includes an Allow header listing the supported methods.

Common causes

  • POST sent where PUT or PATCH is expected.
  • A browser form issuing GET to a mutation endpoint.
  • A preflight OPTIONS request reaching a route that does not allow it because CORS is misconfigured.
  • Partial update implemented as PATCH while the client sends PUT.

How to fix it

  1. 1

    Read the Allow header from the response

    http
    HTTP/1.1 405 Method Not Allowed
    Allow: GET, PATCH, DELETE
  2. 2

    Declare methods on the route

    php
    #[Route('/api/v1/orders/{id}', methods: ['GET', 'PATCH', 'DELETE'])]
  3. 3

    Let the CORS layer answer OPTIONS

    Preflight must be handled before routing, not by an application route.

Example request or scenario

Request · http
PUT /api/v1/orders/8f21 HTTP/1.1

Example error response

405 Method Not Allowed · json
{
  "type": "https://errors.example.com/method-not-allowed",
  "title": "Method Not Allowed",
  "status": 405,
  "code": "METHOD_NOT_ALLOWED",
  "detail": "PUT is not supported for this resource.",
  "instance": "/api/v1/orders/8f21",
  "traceId": "01JC3T90SAJK8I1M3O5S7U9VWX",
  "meta": { "allow": ["GET", "PATCH", "DELETE"] }
}

Related exceptions

  • Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
  • Symfony\Component\Routing\Exception\MethodNotAllowedException

Related services & controllers

UnitPath
nelmio_cors.yamlconfig/packages/nelmio_cors.yaml

Internal Server Error

Permalink to this error
Error code
INTERNAL_SERVER_ERROR
HTTP status
HTTP 500
Severity
Server fault
Retry policy
Not retryable
Log level
log: error

An unhandled exception escaped the application. The response carries no detail beyond a trace identifier.

Why it happens

Anything not mapped to a specific error becomes a 500. The subscriber logs the full stack trace with the traceId and returns a deliberately opaque body so internals are never disclosed.

Common causes

  • A programming error such as a null dereference or a type error.
  • Missing configuration or an unset environment variable in a new deployment.
  • A Messenger handler failure surfaced through HandlerFailedException.
  • Exhausted memory or execution time.

How to fix it

  1. 1

    Start from the traceId

    Every 500 response and every log line share the same identifier. Search the log stream by it before anything else.

    bash
    php bin/console app:logs:trace 01JC3TA2UBKL9K3O5Q7U9W1XYZ
  2. 2

    Unwrap Messenger failures

    php
    if ($e instanceof HandlerFailedException) {
        $e = $e->getPrevious() ?? $e;
    }
  3. 3

    Map the root cause to a real error entry

    A 500 that repeats is a missing catalogue entry. Add a mapping in the subscriber and document it here so it becomes actionable next time.

Example request or scenario

Request · http
POST /api/v1/orders HTTP/1.1
Content-Type: application/json

{"customerId": "9c1f...", "lines": [{"sku": "TSHIRT-M", "quantity": 1}]}

Example error response

500 Internal Server Error · json
{
  "type": "https://errors.example.com/internal-server-error",
  "title": "Internal Server Error",
  "status": 500,
  "code": "INTERNAL_SERVER_ERROR",
  "detail": "An unexpected error occurred. Quote the traceId when contacting support.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3TA2UBKL9K3O5Q7U9W1XYZ"
}

Related exceptions

  • Throwable
  • Symfony\Component\Messenger\Exception\HandlerFailedException
  • Symfony\Component\ErrorHandler\Error\FatalError

Related services & controllers

UnitPath
ApiExceptionSubscribersrc/Http/EventSubscriber/ApiExceptionSubscriber.php
monolog.yamlconfig/packages/monolog.yaml

Notes for developers

Service In Maintenance

Permalink to this error
Error code
SERVICE_MAINTENANCE
HTTP status
HTTP 503
Severity
Server fault
Retry policy
Retryable
Log level
log: info

The application is intentionally refusing traffic while a migration or deployment step runs.

Why it happens

A maintenance listener short-circuits the request before routing. It is a planned state, so the response advertises when to come back rather than looking like a fault.

Common causes

  • A maintenance flag is set during a schema migration.
  • A deployment left the flag on after finishing.
  • A readiness probe reports not-ready while caches warm up.

How to fix it

  1. 1

    Check and clear the flag

    bash
    php bin/console app:maintenance:status
    php bin/console app:maintenance:disable
  2. 2

    Always advertise a Retry-After

    http
    HTTP/1.1 503 Service Unavailable
    Retry-After: 120
  3. 3

    Keep health endpoints outside the gate

    Probes must bypass the listener or the orchestrator will never see the service recover.

Example request or scenario

Request · http
GET /api/v1/orders HTTP/1.1

Example error response

503 Service Unavailable · json
{
  "type": "https://errors.example.com/service-maintenance",
  "title": "Service In Maintenance",
  "status": 503,
  "code": "SERVICE_MAINTENANCE",
  "detail": "Scheduled maintenance in progress. Retry in 120 seconds.",
  "instance": "/api/v1/orders",
  "traceId": "01JC3TB5WCLM0M5Q7S9W1Y3ZAB",
  "meta": { "retryAfter": 120 }
}

Related exceptions

  • Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException
  • App\Http\Exception\MaintenanceModeException

Related services & controllers

UnitPath
MaintenanceSubscribersrc/Http/EventSubscriber/MaintenanceSubscriber.php
HealthControllersrc/Controller/HealthController.php
Built with v0