Codigos de error sobre los que ramificar
Mira otra vez ApiExceptionRenderer. Elige un código de estado y un mensaje legible por humanos. Ya está. Un 404 por una licencia inexistente y un 404 por un dominio inexistente son idénticos para un cliente: mismo estado, y una cadena de mensaje como única diferencia.
La verdad cruda: un código de estado le dice a quien consume qué clase de cosa salió mal. No le dice qué salió mal en concreto. Y una cadena de mensaje es peor que inútil para ramificar, porque el capítulo 4 ya te enseñó que esta API traduce las respuestas mediante SetRequestLocaleMiddleware. Un cliente que hace if (error.message === 'License not found') se rompe en cuanto ese mensaje llega en español.
Mira de cerca qué hace ErrorResponse con el mensaje que recibe:
// api-infrastructure/src/Responses/ErrorResponse.php
protected function getData(): array
{
return [
'errors' => [
json_decode($this->message, true) ?? $this->message,
],
];
}
Si $message es JSON válido, se decodifica en datos estructurados antes de envolverse. Si no, se queda como cadena. CreateLicenseRequest::failedValidation() ya explota exactamente esto: pasa json_encode($validator->errors()->toArray()) como mensaje, así que los fallos de validación llegan como un objeto estructurado por campo en lugar de una frase. Es un precedente real y funcionando de enviar estructura por un campo que parece texto plano.
Extiende el mismo truco al tipo de excepción, no sólo a la validación, y cada closure de representación podrá adjuntar un identificador estable e independiente del idioma junto al mensaje humano:
// 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());
});
Un consumidor ramifica sobre error.code === 'license_not_found', que no cambia con el idioma en que se represente el mensaje. El código de estado sigue diciéndole a su cliente HTTP si reintentar. El código de error le dice a su lógica de aplicación qué pasó realmente. Necesitas ambos, y hoy esta API sólo te da uno de forma fiable.
Contexto que hace que un registro merezca la pena
Una línea de registro que dice "error" => $e->getMessage() apenas es un registro. Te dice que algo falló, no por qué, ni para quién, ni qué hacer al respecto.
El flujo de compra de dominios de api-server muestra qué necesita una entrada para ser útil de verdad:
// api-server/app/Exceptions/DomainPurchaseException.php
final class DomainPurchaseException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $outcomeUnknown = false,
) {
parent::__construct($message);
}
}
$outcomeUnknown no es decoración. Es el campo que gobierna dinero real: false significa que el registrador definitivamente no hizo nada, así que devolver el importe es seguro. true significa que el registrador podría haber cobrado igualmente el dominio, y devolver o reintentar a ciegas podría cobrar o registrar dos veces. Si sólo registraras $e->getMessage(), esa distinción desaparece en cuanto la traza se pierde de vista. La excepción lleva el contexto porque el mensaje por sí solo no puede.
LogApiRequestsMiddleware aplica la misma disciplina a nivel de petición, no por error sino en cada llamada:
// api-infrastructure/src/Middleware/LogApiRequestsMiddleware.php
defer(function () use ($request, $response, $startTime) {
$bearer = $request->attributes->get('bearer');
$sanitizedRequest = SensitiveDataSanitizer::sanitize($request->all());
$sanitizedResponse = SensitiveDataSanitizer::sanitize(ResponseParser::parse($response));
// ...payload-size and gzip-size calculations omitted here...
SendToLogsJob::dispatch(
user_id: $bearer?->id ?? 0,
method: $request->method(),
endpoint: $request->url(),
request: $sanitizedRequest,
response: $sanitizedResponse,
status: $response->getStatusCode(),
execution_time: microtime(true) - $startTime,
bearer_token: $bearer?->token,
);
});
Quién llamó, qué envió, qué volvió, cuánto tardó: todo, en cada petición, no sólo en las que fallaron. SensitiveDataSanitizer corre primero, redactando cualquier cosa que coincida con una lista de nombres de campo conocidos como sensibles —password, token, credit_card, ssn— antes de que nada llegue a un almacén de registros. El contexto sólo merece conservarse si conservarlo no convierte tu almacén de registros en la siguiente cosa que hay que notificar como brecha.