Problem Details for a Small PHP API - Give Errors a Stable Shape Without Hiding HTTP
An API can return 422, but a client may still need to know whether a title is missing, a date is invalid, or a state transition is no longer allowed. A status code gives the response its broad HTTP meaning. It does not, by itself, provide a stable application-level error contract.
The usual reaction is to invent a JSON envelope: perhaps {"error":"invalid"} in one endpoint and {"success":false,"message":"..."} in another. That works until clients must learn several shapes, human prose becomes an accidental identifier, or an intermediary receives 200 OK for a request that actually failed.
RFC 9457, Problem Details for HTTP APIs, offers a narrower solution. It standardizes a small object for describing HTTP API errors without replacing HTTP status semantics or defining an application's business rules. This article asks how a small PHP API can use that format honestly: what each field means, what remains application-specific, and what must stay out of the response.
The status code and the body have different jobs
HTTP already has a vocabulary for the general result of a request. RFC 9110 section 15 defines status-code semantics that clients, proxies, caches, and other generic software can understand. A 404 says the origin server did not find a current representation of the target resource; a 409 describes a conflict with the resource's current state; a 500 describes an unexpected server condition.
Those categories should remain on the actual HTTP response. Problem Details adds a structured body for API-specific meaning. It does not justify returning 200 and hiding failure inside JSON. Nor does it require a body when the status code already says everything useful.
RFC 9457 says the format can be used with any status code, although it fits 4xx and 5xx responses most naturally. It also cautions that an existing domain format may be more appropriate. Adoption is therefore a contract decision, not a claim that every error response on the Web must look the same.
The five standard members are not five required boxes
A JSON problem document uses the media type application/problem+json. The standard defines five members, all optional in the JSON schema, with distinct roles:
| Member | Role | What a client should assume |
|---|---|---|
type | A URI reference identifying the problem type | This is the primary machine-readable identity after URI resolution. |
status | The origin server's HTTP status code for this occurrence | It is advisory; generic HTTP behavior follows the response status line. |
title | A short human-readable summary of the type | It should remain stable across occurrences except for localization. |
detail | A human-readable explanation of this occurrence | Display it if appropriate, but do not parse it as an identifier. |
instance | A URI reference identifying this particular occurrence | It may be an opaque correlation identifier and need not be fetchable. |
If type is absent, its value is treated as about:blank. That type means the problem has no semantics beyond the HTTP status code. In that case, the title should match the recommended status phrase, possibly localized. A plain not-found response can use this default without minting an elaborate URI that merely renames 404.
The status member duplicates the real status deliberately, for example when a body is stored without its HTTP metadata. A generator that includes it must send the same status on the response. If a proxy later changes the response status, the two can disagree; that is one reason clients cannot treat the JSON number as a substitute for HTTP.
A problem type is a stable contract, not a sentence
Suppose an editor tries to publish an article before its required fields are valid. The API could identify that condition with:
{
"type": "https://example.com/problems/article-not-publishable",
"title": "The article is not ready to publish.",
"status": 422,
"detail": "Correct the highlighted fields and submit again.",
"instance": "https://example.com/problem-occurrences/7f3a2c"
}
The client should branch on the resolved type URI, not on the English title or detail. Human wording can improve, and localization can change it entirely, without creating a new problem type. Conversely, changing what the type means is a contract change even if its URI stays unchanged.
RFC 9457 encourages resolvable type URIs. When an HTTP or HTTPS type URI is opened by a developer, it should provide human-readable documentation: its meaning, usual status code, relevant extensions, and possible recovery. Normal clients should not automatically fetch that documentation while handling every error. The URI is an identifier first, not an instruction to add another network dependency.
Before defining a type, an API designer can also inspect the evolving IANA HTTP Problem Types registry. Its registration policy is intended for common, specification-backed types, not every application's private validation condition. Application-specific types can live under a stable URI space controlled by that application.
A deliberately small PHP responder
PHP already supplies the three basic operations needed here: set the response status, set a content type before output, and encode JSON. The following helper protects the standard member names from being replaced by extensions and asks json_encode() to throw if encoding fails:
<?php
declare(strict_types=1);
function sendProblem(
int $status,
string $type,
string $title,
string $detail,
?string $instance = null,
array $extensions = []
): void {
$problem = [
'type' => $type,
'title' => $title,
'status' => $status,
'detail' => $detail,
];
if ($instance !== null) {
$problem['instance'] = $instance;
}
foreach ($extensions as $name => $value) {
if (array_key_exists($name, $problem) || $name === 'instance') {
throw new InvalidArgumentException('Extension name is reserved.');
}
$problem[$name] = $value;
}
$json = json_encode(
$problem,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
http_response_code($status);
header('Content-Type: application/problem+json');
echo $json;
exit;
}
The PHP manual documents http_response_code() as the status setter, while header() must run before output is sent. The json_encode() documentation also notes that input strings must be UTF-8. Encoding before sending headers prevents this helper from intentionally emitting half of a problem document if JSON encoding throws.
This is not a complete exception handler. A real application still needs a central mapping from known domain failures to public problem types, a separate path for unexpected exceptions, logging, localization, authorization, and tests through the actual web server. The helper also assumes its caller supplies a sensible 4xx or 5xx status and safe strings. Keeping it small makes those missing responsibilities visible rather than pretending one function solved error handling.
Validation details belong in a documented extension
A form can fail in several fields while still representing one problem type: the submitted representation is invalid. RFC 9457 illustrates this with an errors extension. The shape of that extension is part of the problem type's definition; errors is not a universal standard member that every client already understands.
sendProblem(
422,
'https://example.com/problems/invalid-article',
'The article is not valid.',
'Correct the listed fields and submit again.',
'/problem-occurrences/7f3a2c',
[
'errors' => [
['pointer' => '#/title', 'code' => 'required'],
['pointer' => '#/publish_at', 'code' => 'invalid_date'],
],
]
);
Here, pointer locates input and code provides a stable application token. Human explanations can be added if the contract needs them. Clients must ignore extension members they do not recognize, which lets a problem type gain compatible metadata later. Servers should likewise avoid making a new optional extension secretly mandatory for old clients.
Multiple unrelated failures are harder. RFC 9457 recommends representing the most relevant or urgent problem when the failures do not share a type. A generic array of unrelated problems may be possible, but it maps poorly onto one HTTP response's semantics. Batch operations need an explicit domain design rather than an assumption that Problem Details defines one universal batch envelope.
Useful detail can become an information leak
An error format makes information consistent; it does not decide which information is safe. RFC 9457's security section warns about disclosing implementation, system-access, and user-privacy details. The OWASP REST Security Cheat Sheet similarly recommends generic public messages rather than call stacks or internal hints.
A public detail should help the caller correct the request, not reveal a SQL statement, filesystem path, access token, private record, or stack trace. Unexpected failures can return a stable generic type and an opaque instance identifier while protected logs retain diagnostic context. The identifier itself must not encode a secret, and any endpoint that dereferences it needs its own authorization policy.
Error distinctions can leak state too. Returning “account exists but password is wrong” to an anonymous login client reveals more than “credentials were not accepted.” Problem Details does not overrule threat modeling. Sometimes two internal causes should intentionally share one public type, title, and detail.
Test the wire contract, not only the PHP array
A useful test suite should observe the response as a client receives it:
- The actual HTTP status and the optional JSON
statusagree. - The response uses
application/problem+jsonand contains valid JSON. - Known types keep the same semantics, title policy, and documented extensions.
- Localized prose does not change machine identifiers.
- Clients tolerate unknown extension members.
- Unexpected exceptions expose no stack, query, path, credential, or private object.
- The
instancevalue supports correlation without granting unintended access. - Proxies and framework middleware do not replace the intended status or content type.
Migration deserves the same care. If an existing API already has a stable error format, replacing it may cost more than it saves. Content negotiation, a versioned endpoint, or a documented transition can be safer than changing every error body in place. RFC 9457 exists to avoid unnecessary new formats, not to erase working contracts without considering their consumers.
Conclusion
Problem Details gives a small PHP API a useful division of labor. HTTP status codes retain their protocol meaning. A stable type identifies the application problem. title and detail speak to people, instance can correlate one occurrence, and documented extensions carry structured information that belongs to that type.
The format is deliberately incomplete. It does not select the right status, design validation codes, secure diagnostic data, or make an unstable API stable. Those remain design responsibilities. Used with that limitation in view, RFC 9457 can make errors more predictable without pretending that one JSON shape explains every failure.
References
- Nottingham, Wilde, and Dalal, “Problem Details for HTTP APIs,” RFC 9457, July 2023.
- Fielding, Nottingham, and Reschke, “HTTP Semantics,” RFC 9110, June 2022.
- IANA, “Hypertext Transfer Protocol (HTTP) Problem Types”, accessed October 24, 2026.
- OWASP Cheat Sheet Series, “REST Security Cheat Sheet”, accessed October 24, 2026.
- PHP Documentation Group, “http_response_code”, accessed October 24, 2026.
- PHP Documentation Group, “header”, accessed October 24, 2026.
- PHP Documentation Group, “json_encode”, accessed October 24, 2026.
