Validation errors and messages should come back in the consumer’s language. Laravel translates them if you tell it which locale the request wants. That is a middleware of a few lines:
// 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 is not a locale. It is a ranked list of them: fr;q=0.9,en-US;q=0.8 means French if you have it, American English otherwise. Comparing the raw header against a list of supported codes fails for every real client that sends one.
You don’t have to parse it. getPreferredLanguage() is part of the request object Laravel is built on. It honors the quality values in order, falls back from a regional variant to its base language, and returns the first locale in your list when nothing matches.
The supported list is yours to define, in config/app.php:
'supported_locales' => ['en', 'es'],
The first entry is the fallback. Add a language by adding a folder under lang/ and one entry here.
A Record of Every Request
You need to know who called what, what happened, and how long it took. Not only for debugging: when a partner says “your API was down on Tuesday,” the log is how you find out whether it was.
Writing that record must not slow the response down. A middleware may have a second method, terminate(), and Laravel calls it after the response has been sent to the client. (Laravel documentation: 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),
]);
}
The consumer already has its answer by the time the row is written. The method asks the guard whether it has a user and doesn’t ask it to find one: a refused request must not cost a second token lookup here. elapsed() is the difference between now and the request’s start time, which PHP records in REQUEST_TIME_FLOAT.
Refused requests are recorded too, and that is why this is terminate(). Authentication and the rate limiter run ahead of this middleware, so a 401 or a 429 never passes through its handle(). Laravel still calls terminate() on every middleware of the route, whether or not the request got that far. A 401 is written with no consumer and with the caller’s address, and Chapter 19 uses exactly those rows.
Laravel also has a defer() helper for work that should happen after the response, and it would be the wrong tool here. A deferred function is skipped when the response is an error, unless you ask for it ->always(). A log that records only the requests that succeeded can’t answer the questions a log is kept for.
The table behind it is small:
// 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 is indexed and not constrained: the log is allowed to outlive the consumer it describes. Chapter 17 shows the ApiRequest model, together with the rule that deletes its rows.
Never the token, and never the URL as it was typed. The token’s ID identifies which credential was used, which is what you need the day one leaks, and it is useless to anyone who steals the log. The path is the route’s template, api/licenses/{license}, which says which endpoint was called without copying license keys into a table. The address is personal data in most jurisdictions, which is one more reason Chapter 17 gives this table a lifetime.
Metadata by default, bodies by exception. Method, path, status, and duration answer most questions. Request and response bodies contain whatever your consumers send you, including things you promised to protect. If you must keep them, strip known sensitive keys first and decide how long you keep them. Chapter 17 covers retention.
The log is a passenger. If the write fails, the request has already succeeded. A logging failure that turns a 200 into a 500 has made the API less reliable in the name of observing it.
Laravel’s own tools record requests as well. Pulse and Nightwatch both show request volume and duration per user without a line of your code. This table exists for what they don’t give you: a per-consumer history you can query, export in Chapter 18, and hold against an objective in Chapter 13. If you need none of those, use the tools and skip the table.