Los errores de validación y los mensajes deberían volver en el idioma del consumidor. Laravel los traduce si le dices qué idioma quiere la petición. Eso es un middleware de unas pocas líneas:
// app/Http/Middleware/SetLocale.php
public function handle(
Request $request,
Closure $next,
): Response {
$locale = $request->getPreferredLanguage(
config('app.supported_locales'),
);
App::setLocale($locale);
return $next($request);
}
Accept-Language no es un idioma. Es una lista ordenada de ellos: fr;q=0.9,en-US;q=0.8 significa francés si lo tienes, inglés estadounidense si no. Comparar la cabecera cruda con una lista de códigos admitidos falla con todos los clientes reales que la envían.
No tienes que interpretarla tú. getPreferredLanguage() forma parte del objeto request sobre el que está construido Laravel. Respeta los valores de calidad en orden, retrocede de una variante regional a su idioma base y devuelve el primer idioma de tu lista cuando nada coincide.
La lista de idiomas admitidos la defines tú, en config/app.php:
'supported_locales' => ['en', 'es'],
La primera entrada es el valor de reserva. Añade un idioma añadiendo una carpeta bajo lang/ y una entrada aquí.
Un registro de cada petición
Necesitas saber quién llamó a qué, qué pasó y cuánto tardó. No solo para depurar: cuando un socio dice «su API estuvo caída el martes», el log es la forma de averiguar si lo estuvo.
Escribir ese registro no debe ralentizar la respuesta. Un middleware puede tener un segundo método, terminate(), y Laravel lo llama después de que la respuesta se ha enviado al cliente. (Documentación de Laravel: Middleware › Terminable Middleware.)
// app/Http/Middleware/LogApiRequest.php
public function handle(
Request $request,
Closure $next,
): Response {
return $next($request);
}
public function terminate(
Request $request,
Response $response,
): void {
$guard = Auth::guard('sanctum');
$consumer = $guard->hasUser() ? $guard->user() : null;
ApiRequest::create([
'consumer_id' => $consumer?->id,
'token_id' => $consumer?->currentAccessToken()?->id,
'ip' => $request->ip(),
'method' => $request->method(),
'path' => $request->route()?->uri(),
'status' => $response->getStatusCode(),
'duration_ms' => $this->elapsed($request),
]);
}
El consumidor ya tiene su respuesta cuando se escribe la fila. El método le pregunta al guard si tiene un usuario y no le pide que lo busque: una petición rechazada no debe costar aquí una segunda búsqueda de token. elapsed() es la diferencia entre ahora y el instante de inicio de la petición, que PHP anota en REQUEST_TIME_FLOAT.
Las peticiones rechazadas también se registran, y por eso esto es terminate(). La autenticación y el limitador se ejecutan por delante de este middleware, así que un 401 o un 429 nunca pasan por su handle(). Laravel llama igualmente a terminate() en todos los middleware de la ruta, haya llegado la petición hasta ahí o no. Un 401 se escribe sin consumidor y con la dirección de quien llamó, y el Capítulo 19 usa exactamente esas filas.
Laravel también tiene un helper defer() para el trabajo que debe ocurrir después de la respuesta, y aquí sería la herramienta equivocada. Una función diferida se omite cuando la respuesta es un error, salvo que la pidas con ->always(). Un log que solo registra las peticiones que salieron bien no puede responder las preguntas para las que se lleva un log.
La tabla que hay detrás es pequeña:
// database/migrations/..._create_api_requests_table.php
Schema::create('api_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('consumer_id')->nullable()->index();
$table->unsignedBigInteger('token_id')->nullable();
$table->string('ip', 45);
$table->string('method', 10);
$table->string('path')->nullable();
$table->unsignedSmallInteger('status');
$table->unsignedInteger('duration_ms');
$table->timestamp('created_at')->index();
});
consumer_id está indexada y no tiene restricción de clave foránea: al log se le permite sobrevivir al consumidor que describe. El Capítulo 17 muestra el modelo ApiRequest, junto con la regla que borra sus filas.
Nunca el token, y nunca la URL tal como se tecleó. El ID del token identifica qué credencial se usó, que es lo que necesitas el día que una se filtra, y es inútil para quien robe el log. La columna path guarda la plantilla de la ruta, api/licenses/{license}, que dice a qué endpoint se llamó sin copiar claves de licencia en una tabla. La dirección es un dato personal en la mayoría de las jurisdicciones, una razón más para que el Capítulo 17 le ponga a esta tabla un tiempo de vida.
Metadatos por defecto, cuerpos por excepción. El método, la ruta, el estado y la duración responden a la mayoría de las preguntas. Los cuerpos de las peticiones y las respuestas contienen todo lo que te envíen tus consumidores, incluidas cosas que prometiste proteger. Si tienes que conservarlos, quita primero las claves sensibles conocidas y decide cuánto tiempo los guardas. El Capítulo 17 trata la retención.
El log es un pasajero. Si la escritura falla, la petición ya salió bien. Un fallo de logging que convierte un 200 en un 500 ha vuelto la API menos fiable en nombre de observarla.
Las herramientas del propio Laravel también registran peticiones. Pulse y Nightwatch muestran el volumen y la duración de las peticiones por usuario sin una línea de código tuyo. Esta tabla existe para lo que ellas no te dan: un historial por consumidor que puedes consultar, exportar en el Capítulo 18 y contrastar con un objetivo en el Capítulo 13. Si no necesitas nada de eso, usa las herramientas y sáltate la tabla.