Problem Details, error codes, field errors and correlation IDs
Poor API error responses cost development time, on both sides. When consumers only get {"error": "Something went wrong"}, they can neither react programmatically nor debug in a targeted way. RFC 9457 Problem Details for HTTP APIs, machine-readable error codes, structured field errors and correlation IDs turn error cases into a first-class feature of the API, not an afterthought.
Table of Contents
- 1. Why error modeling is an API design problem
- 2. RFC 9457 Problem Details: the standard for HTTP errors
- 3. Machine-readable error codes: beyond HTTP status codes
- 4. Structured field errors for validation responses
- 5. Correlation IDs: tracing errors across system boundaries
- 6. Full Symfony implementation
- 7. Error responses compared
- 8. Summary
- 9. FAQ
1. Why error modeling is an API design problem
Error responses in REST APIs are often treated as an afterthought: quickly return a {"error": "Invalid input"} and move on. The result is APIs where consumer developers cannot handle error cases programmatically, where debugging a production error takes hours, and where error messages arrive sometimes in English, sometimes in German, sometimes with a message field, sometimes with error, sometimes with errors as an array. Inconsistent error models are one of the most common sources of friction between API providers and consumer teams.
Professional error modeling for REST APIs has three goals. First, errors must be machine readable: consumer code must be able to decide how to react to an error without text parsing. Second, errors must be debuggable: a developer must be able to find the corresponding log entry from the error response in seconds. Third, errors must be consistent: every endpoint of the API returns errors in the same format. RFC 9457 "Problem Details for HTTP APIs" is the industry standard that addresses these three goals.
2. RFC 9457 Problem Details: the standard for HTTP errors
RFC 9457 (formerly RFC 7807) defines a JSON format for HTTP error responses: application/problem+json. The format defines five standard properties and allows extensions. The type field is a URI that uniquely identifies the error type and can point to a documentation page. The title field is a human-readable, stable short text for the error type. The status field repeats the HTTP status code in the body for systems that lose it from context. The detail field provides a context-specific description of the concrete error. The instance field is a URI that points to the specific error instance, ideal for log links.
The format allows arbitrary extension fields at the top level. This is where you add correlation_id, code for machine-readable error codes, and errors for structured field errors. The content type of the response must be application/problem+json, not application/json. That lets consumer code decide, based on the content type, whether it should parse a Problem Details response. In practice, many APIs use the regular JSON content type out of convenience, a compromise that makes programmatic differentiation harder.
// RFC 9457 Problem Details - full example with extensions
{
"type": "https://mironsoft.de/api/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid or missing fields.",
"instance": "/api/products",
"correlation_id": "req_01HZX9KBQT4XZRMJ6K8VWPY7A",
"code": "VALIDATION_FAILED",
"errors": [
{
"field": "price",
"code": "MUST_BE_POSITIVE",
"message": "The price must be greater than 0.",
"rejected_value": -10.5
},
{
"field": "name",
"code": "MAX_LENGTH_EXCEEDED",
"message": "The name must not exceed 200 characters.",
"rejected_value": "A very long product name...",
"meta": { "max_length": 200, "actual_length": 347 }
}
]
}
3. Machine-readable error codes: beyond HTTP status codes
HTTP status codes alone are too coarse for programmatic error handling in client systems. An HTTP 422 can mean: required field missing, value outside the allowed range, invalid format, business rule violated, or a duplicate key error. Machine-readable error codes distinguish these cases without text parsing. The consumer can check for code === "DUPLICATE_EMAIL" and show the user a specific error message, regardless of which language the API response comes in.
Error codes should be defined as constants, ideally as a PHP enum in Symfony. That enforces a controlled catalog of all possible error codes and prevents codes from being assigned ad hoc as magic strings. The error catalog is then also referenced in the OpenAPI documentation, so consumer developers know all possible codes for an endpoint and can fully implement their error handling. That turns error codes into an explicit API contract, not an implicit implementation detail.
<?php
// src/Api/Error/ApiErrorCode.php
// Typed error code enum for machine-readable error responses
declare(strict_types=1);
namespace App\Api\Error;
enum ApiErrorCode: string
{
// Validation errors
case VALIDATION_FAILED = 'VALIDATION_FAILED';
case FIELD_REQUIRED = 'FIELD_REQUIRED';
case MAX_LENGTH_EXCEEDED = 'MAX_LENGTH_EXCEEDED';
case INVALID_FORMAT = 'INVALID_FORMAT';
case VALUE_OUT_OF_RANGE = 'VALUE_OUT_OF_RANGE';
// Business logic errors
case DUPLICATE_EMAIL = 'DUPLICATE_EMAIL';
case INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK';
case ORDER_ALREADY_SHIPPED = 'ORDER_ALREADY_SHIPPED';
case PAYMENT_DECLINED = 'PAYMENT_DECLINED';
// Authorization errors
case ACCESS_DENIED = 'ACCESS_DENIED';
case RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND';
case TOKEN_EXPIRED = 'TOKEN_EXPIRED';
// System errors
case UPSTREAM_TIMEOUT = 'UPSTREAM_TIMEOUT';
case RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED';
public function toTitle(): string
{
return match ($this) {
self::VALIDATION_FAILED => 'Validation Failed',
self::DUPLICATE_EMAIL => 'Email Already Registered',
self::INSUFFICIENT_STOCK => 'Insufficient Stock',
self::ORDER_ALREADY_SHIPPED => 'Order Already Shipped',
self::RESOURCE_NOT_FOUND => 'Resource Not Found',
self::RATE_LIMIT_EXCEEDED => 'Too Many Requests',
default => 'API Error',
};
}
}
4. Structured field errors for validation responses
When a user submits a form with twelve fields and the API responds with {"error": "Validation failed"}, the frontend has to check all fields individually or ask the user to review everything again. Structured field errors instead deliver a list of errors with field path, error code and human-readable message. That lets the frontend show each error directly next to the affected input field, without its own logic for distributing errors.
The field path must reflect the full hierarchy for nested objects: shipping_address.street is more precise than street. For array fields, the index belongs too: items[2].quantity. In Symfony you translate the validator's ConstraintViolationList into this structure. The violation's propertyPath is used directly as the field path. The rejected_value helps with debugging but should not contain sensitive values and should be truncated to a sensible length for long strings.
5. Correlation IDs: tracing errors across system boundaries
A correlation ID is a unique identifier assigned to a request for its entire life, from acceptance at the API gateway, through all microservices and database queries, to the response. When a user reports an error and provides the correlation ID from their response, a developer can find all related log entries across all systems in seconds. Without correlation IDs, debugging production errors in distributed systems is a tedious search through logs by timestamp and IP address.
The correlation ID is either sent by the client (X-Correlation-ID header) or generated by the API gateway. The recommended approach: if the client sends a header, it is accepted and reused. If not, the server generates a new ID. The ID is entered into the Monolog context so every log entry contains it. It appears in the response header and in the Problem Details body. For the ID itself, a prefix-based format such as req_01HZX9KBQT4XZRMJ6K8VWPY7A (ULID or UUID) is recommended, which is immediately recognizable as a correlation ID.
<?php
// src/EventSubscriber/CorrelationIdSubscriber.php
// Correlation ID propagation through request lifecycle
declare(strict_types=1);
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Uid\Ulid;
final class CorrelationIdSubscriber implements EventSubscriberInterface
{
private const HEADER = 'X-Correlation-ID';
private string $correlationId = '';
public function __construct(
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 100],
KernelEvents::RESPONSE => ['onResponse', -100],
];
}
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
// Accept client-provided ID or generate a new one
$this->correlationId = $request->headers->get(self::HEADER)
?? 'req_' . (new Ulid())->toBase32();
$request->attributes->set('correlation_id', $this->correlationId);
// Inject into logger context for all subsequent log entries
$this->logger->pushProcessor(static function (array $record) use (&$correlationId): array {
$record['extra']['correlation_id'] = $correlationId;
return $record;
});
}
public function onResponse(ResponseEvent $event): void
{
// Always expose correlation ID in response header
$event->getResponse()->headers->set(self::HEADER, $this->correlationId);
}
public function getCorrelationId(): string
{
return $this->correlationId;
}
}
6. Full Symfony implementation
In Symfony, the Problem Details error handling is best implemented as a central exception listener. All domain exceptions are translated into Problem Details responses, with no try/catch blocks in every controller. The exception listener receives all unhandled exceptions, checks the content type of the request (Accept: application/json), and returns a structured Problem Details response. Symfony API Platform does this automatically; for custom implementations you need a kernel.exception listener.
The error hierarchy in the domain layer should inherit from an abstract DomainException that already carries the error code as a property. That makes the translation in the listener trivial: the listener checks whether it is a DomainException, reads the error code, maps it to an HTTP status code, and builds the Problem Details response. System exceptions (database errors, network errors) are mapped to a generic HTTP 500 without exposing internal details. Only the correlation ID link lets the support team find the internal logs.
<?php
// src/EventSubscriber/ProblemDetailsExceptionListener.php
// Central exception-to-ProblemDetails translator
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Api\Error\ApiErrorCode;
use App\Exception\DomainException;
use App\Exception\ValidationException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ProblemDetailsExceptionListener implements EventSubscriberInterface
{
public function __construct(
private readonly CorrelationIdSubscriber $correlationIdSubscriber,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => ['onException', 0]];
}
public function onException(ExceptionEvent $event): void
{
$request = $event->getRequest();
$exception = $event->getThrowable();
// Only intercept JSON API requests
if (!str_contains($request->headers->get('Accept', ''), 'application/json')) {
return;
}
$correlationId = $this->correlationIdSubscriber->getCorrelationId();
[$status, $body] = match (true) {
$exception instanceof ValidationException => [
Response::HTTP_UNPROCESSABLE_ENTITY,
$this->buildValidationProblem($exception, $correlationId, $request->getPathInfo()),
],
$exception instanceof DomainException => [
$this->mapDomainStatus($exception->getErrorCode()),
$this->buildDomainProblem($exception, $correlationId, $request->getPathInfo()),
],
default => [
Response::HTTP_INTERNAL_SERVER_ERROR,
$this->buildGenericProblem($correlationId, $request->getPathInfo()),
],
};
$response = new JsonResponse($body, $status);
$response->headers->set('Content-Type', 'application/problem+json');
$event->setResponse($response);
}
private function buildValidationProblem(ValidationException $e, string $correlationId, string $path): array
{
return [
'type' => 'https://mironsoft.de/api/errors/validation-failed',
'title' => 'Validation Failed',
'status' => 422,
'detail' => 'The request body contains invalid or missing fields.',
'instance' => $path,
'correlation_id' => $correlationId,
'code' => ApiErrorCode::VALIDATION_FAILED->value,
'errors' => $e->getFieldErrors(),
];
}
private function mapDomainStatus(ApiErrorCode $code): int
{
return match ($code) {
ApiErrorCode::RESOURCE_NOT_FOUND => Response::HTTP_NOT_FOUND,
ApiErrorCode::ACCESS_DENIED => Response::HTTP_FORBIDDEN,
ApiErrorCode::RATE_LIMIT_EXCEEDED => Response::HTTP_TOO_MANY_REQUESTS,
default => Response::HTTP_UNPROCESSABLE_ENTITY,
};
}
private function buildDomainProblem(DomainException $e, string $correlationId, string $path): array
{
return [
'type' => 'https://mironsoft.de/api/errors/' . strtolower($e->getErrorCode()->value),
'title' => $e->getErrorCode()->toTitle(),
'status' => $this->mapDomainStatus($e->getErrorCode()),
'detail' => $e->getMessage(),
'instance' => $path,
'correlation_id' => $correlationId,
'code' => $e->getErrorCode()->value,
];
}
private function buildGenericProblem(string $correlationId, string $path): array
{
return [
'type' => 'https://mironsoft.de/api/errors/internal-server-error',
'title' => 'Internal Server Error',
'status' => 500,
'detail' => 'An unexpected error occurred. Please contact support with the correlation ID.',
'instance' => $path,
'correlation_id' => $correlationId,
'code' => 'INTERNAL_ERROR',
];
}
}
7. Error responses compared
| Aspect | Naive approach | RFC 9457 Problem Details | Benefit |
|---|---|---|---|
| Error type recognizable | Text parsing only | Machine readable via code |
Programmatic error handling without text parsing |
| Field errors | None / flat string | Structured errors array |
Frontend can show errors directly at the field |
| Debugging | Log search by timestamp | Correlation ID links response to log | Support finds log entries in seconds |
| Documentable | Undocumented strings | Error code catalog in OpenAPI | Consumer knows all possible error codes |
| Standard compliance | Proprietary | RFC 9457 / IETF standard | Interoperability with tools that understand Problem Details |
8. Summary
Professional error modeling for REST APIs is not a luxury, it is the foundation for a maintainable API with satisfied consumer teams. RFC 9457 Problem Details defines a standardized JSON format with type, title, status, detail and instance, and allows extensions for error codes, field errors and correlation IDs. Machine-readable error codes as a PHP enum enable programmatic error handling without text parsing. Structured field errors let frontend teams show validation errors directly at the affected field. Correlation IDs connect API responses to log entries and make debugging in production systems practical.
In Symfony, this entire infrastructure can be built with a central exception listener, a correlation ID subscriber, an error code enum, and a DomainException hierarchy. The result: every endpoint of the API returns errors in the same format, every exception from the domain layer is automatically translated into a Problem Details response, and debugging production errors is reduced from hours to seconds.
REST error modeling: the essentials at a glance
RFC 9457 Problem Details
Standard format for HTTP errors: type, title, status, detail, instance. Content type: application/problem+json. Extensible for error codes and field errors.
Error codes as enum
PHP enum with all possible error codes, no magic string chaos. Machine readable, documentable in OpenAPI, forms an explicit API contract.
Structured field errors
Every validation error with field path, error code, message and rejected_value. Frontend can show errors directly at the field without its own mapping logic.
Correlation IDs
Unique request ID in response header and Problem Details body. Connects API response to log entries across system boundaries. Support debugging in seconds.