Look again at ApiExceptionRenderer. It picks a status code and a human-readable message. That's it. A 404 for a missing license and a 404 for a missing domain look identical to a client: same status, and a message string that's the only thing distinguishing them.
Here's the brutal truth: a status code tells a consumer what class of thing went wrong. It doesn't tell them what actually went wrong. And a message string is worse than useless for branching logic, because Chapter 3 already showed you this API translates responses through SetRequestLocaleMiddleware. A client that does if (error.message === 'License not found') breaks the moment that message comes back in Spanish.
Look closer at what ErrorResponse actually does with the message it's given:
// api-infrastructure/src/Responses/ErrorResponse.php
protected function getData(): array
{
return [
'errors' => [
json_decode($this->message, true) ?? $this->message,
],
];
}
If $message is valid JSON, it gets decoded into structured data before it's wrapped. If it isn't, it falls back to the raw string. CreateLicenseRequest::failedValidation() already exploits exactly this, it passes json_encode($validator->errors()->toArray()) as the message, so validation failures arrive as a structured, field-keyed object instead of a sentence. That's a real, working precedent for shipping structure through a field that looks like plain text.
Extend the same trick for exception type, not just validation, and every render closure can attach a stable, language-independent identifier alongside the human message. Give the failure a domain exception first, following the same shape api-server already uses for DomainPurchaseException later in this chapter:
// api-license/app/Exceptions/LicenseNotFound.php
final class LicenseNotFound extends RuntimeException
{
public static function forKey(string $key): self
{
return new self("License with key '{$key}' not found or already deleted.");
}
}
Then attach the code where the exception is rendered:
// api-license/app/Enums/LicenseErrorCode.php
enum LicenseErrorCode: string
{
case NotFound = 'license_not_found';
case ValidationFailed = 'validation_failed';
case ProviderUnavailable = 'provider_unavailable';
case RateLimited = 'rate_limited';
}
// api-license/bootstrap/app.php
$exceptions->render(function (LicenseNotFound $exception) {
return (new ErrorResponse(
message: json_encode([
'code' => LicenseErrorCode::NotFound->value,
'message' => $exception->getMessage(),
]),
status: HttpResponse::HTTP_NOT_FOUND,
))->toResponse(request());
});
A consumer branches on error.code === 'license_not_found', which never changes no matter what language the message renders in. The status code still tells their HTTP client whether to retry. The code tells their application logic what actually happened. You need both, and today, this API only reliably gives you one.