Toda API acaba fallando. Una regla de validación rechaza una carga. Statamic agota su tiempo. Un worker muere a mitad de job. La pregunta nunca fue si la Webplo License API fallaría, sino si un fallo tendría el mismo aspecto siempre o si cada controlador inventaría su propia forma de morir.
Este capítulo cubre lo que pasa de verdad cuando algo se rompe: cómo un único manejador de excepciones da la misma forma a todos los fallos, por qué un código de estado por sí solo no basta para que un consumidor ramifique, qué necesita una entrada de registro para merecer conservarse, cómo trazas una petición lógica a través de tres servicios que no comparten identificador, y qué haces cuando lo que escribe tus registros es justo lo que está caído.
Una respuesta de error es parte del contrato de tu API, no un añadido atornillado al camino feliz.
Los errores son un contrato, no un accidente
La regla es esta: quien consume tu API debería poder escribir el manejo de errores una vez, contra la forma de tus errores, y no volver a tocarlo nunca. No una vez por endpoint. No una vez por tipo de excepción. Una vez.
Eso sólo funciona si todos los fallos, vengan de donde vengan, vuelven por la misma puerta. Una ValidationException lanzada en un FormRequest, una ModelNotFoundException lanzada por Eloquent, una ThrottleRequestsException lanzada por el limitador: ninguna es algo que tus controladores deban capturar a mano. Si lo hacen, has reintroducido exactamente el problema que el capítulo 2 dedicó un capítulo entero a eliminar de tus respuestas.
Esta API trata los errores igual que trata las respuestas correctas: como un Responsable, envuelto, predecible y propiedad de una clase en lugar de esparcido por cada controlador que pueda lanzar algo.
Un manejador, todos los fallos con la misma forma
El esqueleto ligero de Laravel 13 ya no te da un app/Exceptions/Handler.php que extender. No hay clase Handler en api-server, api-license ni api-ai; ninguna de las cinco aplicaciones actuales de esta flota tiene una. El manejo de excepciones vive enteramente dentro de bootstrap/app.php, en un closure pasado a withExceptions().
// Bad: extending a Handler class that doesn't exist in this fleet
class ApiExceptionHandler extends Handler
{
public function render($request, Throwable $exception)
{
return match (true) {
$exception instanceof ValidationException => $this->validation($exception),
default => $this->generic($exception),
};
}
}
Esto reventaría al arrancar. No hay Handler que extender, ni Kernel que lo conecte, ni sitio donde Laravel pueda encontrarlo. Es la forma anterior a Laravel 11, y ha desaparecido de todas las aplicaciones de esta flota salvo template-base, que nunca migró.
Esto es lo que hay realmente:
// api-server/bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
// Report exceptions to Nightwatch for error tracking
$exceptions->report(function (Throwable $exception) {
try {
if ($exception instanceof PollingRetryException) {
// silent: expected transient failure, retries handle it
} elseif ($exception instanceof ThrottleRequestsException) {
Nightwatch::warning('Rate limit exceeded', [
'exception' => class_basename($exception),
]);
} elseif ($exception instanceof ValidationException) {
Nightwatch::info('Validation failed', [
'errors' => $exception->errors(),
]);
} elseif ($exception instanceof ModelNotFoundException) {
Nightwatch::warning('Resource not found', [
'exception' => class_basename($exception),
]);
} else {
Nightwatch::captureException($exception);
}
} catch (Throwable) {
// Nightwatch not available or container not initialized
}
});
$exceptions->render(
using: fn (Throwable $exception) => ApiExceptionRenderer::render($exception),
);
})->create();
Dos closures, dos trabajos. report() decide con cuánta fuerza se registra una excepción: una ThrottleRequestsException como aviso, una ValidationException como poco más que información, cualquier cosa sin clasificar escalada como error real. Pero comprueba esa suposición antes de fiarte: el paquete de Nightwatch instalado en esta flota expone exactamente un método de reporte en su facade, report(Throwable $e, bool|null $handled = null). warning(), info() y captureException() no están. Llama a un método que no existe y PHP lanza; el catch (Throwable) que envuelve este closure se lo traga, y la excepción que intentabas clasificar no llega nunca a Nightwatch, con ninguna severidad. render() decide qué ve el cliente, y delega por completo en una clase compartida:
// api-infrastructure/src/Exceptions/ApiExceptionRenderer.php
final readonly class ApiExceptionRenderer
{
public static function render(Throwable $exception): HttpResponse
{
$status = HttpResponse::HTTP_INTERNAL_SERVER_ERROR;
$message = $exception->getMessage();
if ($exception instanceof ThrottleRequestsException) {
$status = HttpResponse::HTTP_TOO_MANY_REQUESTS;
$message = 'Too many requests. Please try again later.';
} elseif ($exception instanceof ValidationException) {
$status = HttpResponse::HTTP_UNPROCESSABLE_ENTITY;
$message = json_encode($exception->errors()) ?: '{}';
} elseif ($exception instanceof AuthenticationException) {
$status = HttpResponse::HTTP_UNAUTHORIZED;
$message = 'Unauthenticated.';
}
// ...
return (new ErrorResponse(message: $message, status: $status))
->toResponse(request());
}
}
ApiExceptionRenderer vive en el paquete compartido, no copiado en cada aplicación. api-server, api-license y api-ai llaman todos al mismo método estático, así que arreglar cómo se representa una ModelNotFoundException lo arregla en todas partes a la vez, y no en tres sitios que inevitablemente se separarán.
ErrorResponse extiende BaseResponse, el mismo Responsable abstracto que viste en el capítulo 2. Sólo implementa getData(); todo lo demás se hereda, no se repite.
No toda excepción quiere reportarse. PollingRetryException implementa el contrato ShouldntReport del propio Laravel:
// api-server/app/Exceptions/PollingRetryException.php
class PollingRetryException extends Exception implements ShouldntReport {}
Esa única interfaz mantiene fuera de tu rastreador de errores los fallos esperados y reintentables: sin un if en el closure de reporte, sin ruido ahogando las excepciones que sí necesitan a una persona. api-license va un paso más allá y añade una representación específica para un fallo aguas abajo que no es realmente un error de servidor:
// api-license/bootstrap/app.php
$exceptions->dontReport(ConnectionException::class);
$exceptions->render(function (ConnectionException $exception) {
return response()->json(
data: ['errors' => [$exception->getMessage()]],
status: HttpResponse::HTTP_GATEWAY_TIMEOUT,
);
});
Un tiempo de espera agotado hablando con Statamic no es un fallo en tu código. Es un 504, no un 500, y no merece una alerta cada vez que Statamic va lento. Un manejador, aplicado una vez, y todos los controladores de la aplicación heredan la decisión.