The LicenseServiceProvider is where the settings property comes to life. When a request arrives with a token, the provider examines the token's settings and configures the application for that specific integration.
// app/Providers/LicenseServiceProvider.php
final class LicenseServiceProvider extends ServiceProvider
{
use CachesBearerTokens;
public function register(): void
{
$this->app->singleton(LicenseManager::class);
}
public function boot(): void
{
$token = request()->bearerToken();
if (! $token) {
return;
}
// Get cached Bearer instance (same cache hit as middleware)
$bearer = $this->rememberBearerToken($token);
if (! $bearer instanceof Bearer) {
return;
}
// Extract configuration from token settings
$settings = $bearer->settings;
if (! is_array($settings)) {
return;
}
$licenseSettings = $settings['license'] ?? null;
if (! is_array($licenseSettings)) {
return;
}
// Set which driver to use (Statamic, Filament, etc.)
$driver = $licenseSettings['driver'] ?? null;
if (! is_string($driver)) {
return;
}
Config::set('services.license.default', $driver);
// Set driver credentials (e.g., Statamic API token)
$driverToken = $licenseSettings['token'] ?? null;
if (is_string($driverToken)) {
Config::set("services.license.drivers.{$driver}.token", $driverToken);
}
}
}
Real example. You create two Bearer tokens:
Token 1: Used by Partner A
{
"license": {
"driver": "statamic",
"token": "partner_a_statamic_token_secret"
}
}
Token 2: Used by Partner B
{
"license": {
"driver": "statamic",
"token": "partner_b_statamic_token_secret"
}
}
Both tokens authenticate to the same API. But Partner A's requests use Partner A's Statamic token. Partner B's requests use Partner B's token. The same LicenseController, the same LicenseService, the same everything. Just different configuration per token.
This is multi-tenancy without microservices, without routing complexity, without tenant tables in your database. It's all in the token.
Middlewares
Middleware is the interceptor layer between a request arriving at your API and it reaching your controller. Think of it as a series of doors the request must pass through. Each door can examine the request, modify it, pass it through unchanged, or slam it shut and send the request back without ever reaching your controller.
Every middleware does one job:
- Validate a Bearer token exists
- Check if the request exceeds a rate limit
- Add security headers to the response
- Log what happened
- Detect the client's preferred language
- Compress the response body
Each middleware receives a request, decides what to do with it, then passes it to the next middleware in the chain. The response comes back through the same chain in reverse order, so each middleware also gets a chance to transform the response before it leaves your API. That is why order matters. A request rejected by authentication middleware never reaches rate limiting. A response compressed last gets the proper content-length headers.
Think of it like an airport security line. Each checkpoint does one thing:
- Check-in verifies your ticket.
- Security scans your bags.
- Passport control verifies your identity.
- The gate confirms you're on the right flight.
If any checkpoint rejects you, you do not board. You do not move to the next checkpoint. Middleware works the same way.
In this API, the protected routes for LicenseController are grouped with route middleware. The order is deliberate and matches how the request should be vetted:
// routes/api.php
Route::middleware([
EnsureTokenIsValidMiddleware::class,
'throttle:api-token',
AddRateLimitHeadersMiddleware::class,
LogApiRequestsMiddleware::class,
])->controller(LicenseController::class)->group(function () {
// routes
});
The /health route stays public, so it does not use these route middleware. It only gets the global API middleware we add later.
EnsureTokenIsValidMiddleware: Token Validation
Every API request must prove its identity. You cannot just accept any token that shows up in the Authorization header. You need to verify it, check it has not expired, and optionally validate that the request origin is allowed. That is what EnsureTokenIsValidMiddleware does.
This middleware extends VerifyBearerToken from Chandler's package, adding company-specific validation logic on top of the base Bearer token verification:
// app/Http/Middleware/EnsureTokenIsValidMiddleware.php
class EnsureTokenIsValidMiddleware extends VerifyBearerToken
{
use CachesBearerTokens;
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if (! is_string($token)) {
return parent::handle($request, $next);
}
$foundToken = $this->rememberBearerToken($token);
if (! $foundToken ||
$foundToken->expired ||
! $this->isTokenValidForDomain($request, $foundToken)) {
return parent::handle($request, $next);
}
return $next($request);
}
}
When a request arrives, this middleware performs three critical checks in sequence:
The middleware first calls $request->bearerToken() to extract the token from the Authorization: Bearer ... header. If there is no token at all, it immediately delegates to the parent handler, which will reject the request with a 401. This is clean, because if there is no token, there is nothing custom to validate.
Next, it calls $this->rememberBearerToken($token). This method from the CachesBearerTokens trait checks the cache first, and if missed, queries the database with Bearer::where('token', $token)->first() and caches the result. If the token does not exist in the database, the middleware rejects with error code 401. If it does exist, you now have a Bearer instance with all its properties available: expiration time, domain restrictions, settings, and everything else.
Once you have the Bearer instance, you check $foundToken->expired. This is a computed property on the Bearer model that compares the token's expires_at timestamp against the current time. If it is expired, rejecting with 401 is the right move. Expired tokens are useless.
Finally, if the token has domain restrictions configured, you validate that the request is coming from an allowed domain. This is where isTokenValidForDomain() comes in. It allows you to bind tokens to specific domains, adding an extra security layer.
Token Extraction and Type Check
↓
Token Lookup and Existence
↓
Expiration Check
↓
Domain Validation
Domain validation is optional but powerful. Tokens can specify which domains are allowed to use them. This is crucial in multi-account or partner scenarios where you want to ensure that a token issued to a specific client can only be used from their domain.
The isTokenValidForDomain() method handles several edge cases:
Domains Configuration Check: First, it checks if domains are even configured on this token. If $token->domains is empty, or if the global bearer.verify_domains configuration is disabled, domain validation is skipped. This means you can use the same middleware everywhere, and it gracefully skips domain checking for tokens that do not have domain restrictions. Flexibility without complexity.
JSON Parsing: Domains might be stored as a JSON string in the database (if they came from an external API or were set that way). The middleware tries to decode them: json_decode($domains, true). If the decode fails or does not return an array, the token is rejected. This guards against malformed data.
Exact Domain Matching: Finally, it compares the request's origin ($request->getSchemeAndHttpHost()) against the allowed domains. This uses in_array(..., true) with strict comparison. The getSchemeAndHttpHost() returns the full origin including protocol, like https://example.com. By matching this exactly, token theft is harder, because an attacker who gets a token bound to https://example.com cannot just request from http://example.com or https://attacker.com.
If any of these checks fail, whether missing token, invalid token, expired token, or wrong domain, the middleware calls parent::handle($request, $next). This is the crucial part: you are delegating to Chandler's VerifyBearerToken middleware, which properly handles the rejection. It returns a 401 Unauthorized response with appropriate headers. Your custom logic does not have to reimplement error handling, it just defers to the framework.
When validation passes, the middleware calls return $next($request). The request reaches your controller with the authenticated bearer available. You can access it like this:
$bearer = Auth::user(); // The authenticated bearer
The bearer becomes available as the authenticated "user" for the request, even though it is not a user. Internally, Laravel treats authenticated bearers as guard-backed users, so all the familiar authentication patterns work. Imagine two clients, Client A and Client B, both using your API:
{
"token": "abc123xyz",
"expires_at": "2026-12-31",
"domains": ["https://client-a.com"],
"settings": {
"license": {
"driver": "statamic",
"token": "statamic_token_for_a"
}
}
}
{
"token": "def456uvw",
"expires_at": "2026-12-31",
"domains": ["https://client-b.com"],
"settings": {
"license": {
"driver": "filament",
"token": "filament_token_for_b"
}
}
}
When Client A sends a request from https://client-a.com with their token, the middleware:
- Finds their Bearer token in the database ✓
- Checks it is not expired ✓
- Verifies the domain matches ✓
- Passes the request to the controller
Client A's request later reaches LicenseServiceProvider::boot(), which checks the bearer's settings and configures the application to use Statamic with Client A's credentials. Perfect.
If Client B tried to use Client A's token from their domain, the domain validation would fail and the request would be rejected. If Client B used their own token but from the wrong domain, same result. This prevents token misuse.
Here is a performance trick: cache your tokens. Looking up a token in the database on every request means a database query on every single API call. That adds up fast.
Instead, cache the token for a short time. If it is revoked or expires in the meantime, the cache will miss and you will do a fresh lookup. This is a small optimization but it matters at scale.
Token Revocation Strategy
Token revocation is a common concern. There are two approaches, each with tradeoffs:
Approach 1: Immediate Revocation (No Caching)
- Query the database on every request
- Revoked tokens rejected instantly
- Downside: 2-3x database load, slower response times
- Use when: Security is critical (e.g., after detecting abuse)
Approach 2: Cache-Based Revocation (This Project)
- Cache tokens as we do here
- Revoked tokens remain valid until cache expires (60-3600s depending on token expiration)
- Downside: Revocation takes up to cache TTL to take effect
- Upside: far fewer database queries, scales to thousands of requests/second
- Use when: You can tolerate temporary validity of revoked tokens
For this API, we use caching because the cache TTL is short (60 seconds minimum). If you revoke a token right now, attackers can use it for at most 60 more seconds, usually fast enough for most emergency situations while keeping the API performant.
If you need instant revocation, you can maintain a separate "revoked tokens" cache alongside the bearer cache. Check the revoked list before accepting a cached bearer. This gives you both performance and immediate revocation.
Token Caching: CachesBearerTokens Trait
Both EnsureTokenIsValidMiddleware and LicenseServiceProvider call rememberBearerToken() to look up Bearer tokens. This is intentional, Bearer tokens are looked up frequently, and database queries add up fast. By caching the token, you avoid a database hit on every single API request.
The caching logic is extracted into a reusable CachesBearerTokens trait:
// app/Concerns/CachesBearerTokens.php
trait CachesBearerTokens
{
private function rememberBearerToken(string $token): ?Bearer
{
$cached = Cache::get("bearer:{$token}");
if ($cached instanceof Bearer) {
return $cached;
}
$bearer = Bearer::where('token', $token)->first();
if ($bearer === null) {
return null;
}
$ttl = $this->calculateBearerCacheTTL($bearer);
Cache::put("bearer:{$token}", $bearer, $ttl);
return $bearer;
}
private function calculateBearerCacheTTL(Bearer $bearer): int
{
if ($bearer->expires_at === null) {
return 3600;
}
return (int) max(60, $bearer->expires_at->diffInSeconds(now()));
}
}
This trait provides two methods: rememberBearerToken($token): This is the private method that both the middleware and service provider call. It wraps the caching logic in a clean, reusable interface. It uses Cache::get() to check if the token is already cached, and if not, it queries the database with Bearer::where('token', $token)->first() and caches the result with Cache::put() using a TTL calculated by the second method.
calculateBearerCacheTTL($bearer): This method determines how long a Bearer token should be cached. It receives only existing Bearer instances (non-existent tokens are not cached). Here is how it works:
-
For tokens with no expiration: Some tokens might not have an expiration date (they are permanent). Cache these for 1 hour (3600 seconds). They are unlikely to change frequently, and the TTL balances caching benefits against the cost of stale data.
-
For tokens with expiration: The clever part is here. The TTL is calculated as the difference between now and when the token expires. If a token expires in 30 minutes, it is cached for 30 minutes. If it expires in 60 seconds, it is cached for 60 seconds. The method uses
max(60, ...)to ensure a minimum 60-second cache even if the token expires very soon. The cache automatically expires exactly when the token expires (or at the minimum 60s window), so you never serve requests with a revoked token longer than necessary.
Both the middleware and the service provider use this trait by calling rememberBearerToken():
// In EnsureTokenIsValidMiddleware
$foundToken = $this->rememberBearerToken($token);
// In LicenseServiceProvider
$bearer = $this->rememberBearerToken($token);
The rememberBearerToken() method encapsulates all the caching logic in one place. The cache key is always bearer:{token}, so both places share the same cached value. The first lookup (usually in the middleware) caches the token and calculates the TTL. The second lookup (in the service provider) hits the cache instantly. No additional database queries.
Why this matters: Imagine 1000 requests per second hitting your API. Without caching, that is 2000 database queries per second (one in middleware, one in provider). With caching, that is maybe 2-3 queries per second for newly authenticated clients, and zero queries for repeat clients within the cache window. That is a dramatic improvement. Your database can breathe.
Edge case handling: If a token is revoked while cached, it remains valid until the cache expires. This is a deliberate trade-off: immediate revocation requires either checking the database on every request (kills performance) or using shorter cache TTLs (more database hits). For most use cases, 60-second cache windows (the minimum) are fast enough that revocation is nearly instant while still providing meaningful performance benefits.
Rate Limiting: throttle:api-token
Rate limiting is your API's defense against abuse and resource exhaustion. Without it, a single misconfigured or malicious client can hammer your backend and degrade performance for everyone else. The throttle:api-token middleware is Laravel's built-in rate limiting system, and it's configured specifically for this API to limit requests per Bearer token.
The rate limiter is configured in AppServiceProvider::configureRateLimiters():
RateLimiter::for('api-token', function (Request $request) {
return Limit::perMinute(60)->by($request->bearerToken() ?: $request->ip());
});
This configuration does three things: it defines a limiter named 'api-token', sets a limit of 60 requests per minute, and keys the limit to the Bearer token when present, falling back to the client's IP address if no token exists (for health checks or public endpoints).
The crucial part is by($request->bearerToken() ?: $request->ip()). Each unique Bearer token gets its own quota counter. If Client A uses token abc123, they get 60 requests per minute. If Client B uses token def456, they also get 60 requests per minute independently. This isolation is critical: it prevents one client from monopolizing your API. If Client A burns through all 60 requests, Client B isn't affected.
The fallback to $request->ip() handles edge cases: internal health checks without tokens, or public endpoints that don't require authentication. They're rate-limited by IP address instead, preventing a single machine from overwhelming the server.
Rate limiting is entirely cache-based. When a request arrives, the middleware checks the cache key api-token:{token} and increments a counter. If the counter exceeds 60 within the minute window, the request is rejected with HTTP 429. The cache backend (database, Redis, file system, etc.) automatically expires the counter after 1 minute, resetting the quota for the next window.
This is why your cache configuration matters. If you're using a database cache on an API with high traffic, cache operations become a bottleneck. Redis is dramatically faster. For most production APIs, Redis or Memcached is the right choice for rate limiting.
When a client hits the rate limit:
- Laravel throws a
ThrottleRequestsException - The exception is caught in your exception handler (see
bootstrap/app.php) - The middleware returns a 429 Too Many Requests response
- The client receives an error and should stop sending requests
Importantly: rate limiting stops the request before your controller ever runs. You don't process the request, query your database, or call your external APIs. You just reject it immediately. This is efficient and protects your backend from the damage a flood of requests could cause.
Smart API clients don't rely on the status code to know they're being rate-limited. They proactively check the rate limit headers (provided by AddRateLimitHeadersMiddleware) and back off before hitting the limit. They see X-RateLimit-Remaining: 5 and know to slow down. They see X-RateLimit-Reset: 1708899234 and know when their quota resets.
This is the difference between a reactive client (waits to get a 429, then backs off) and a proactive client (checks headers, backs off before hitting the limit). Your API headers make proactive clients possible.
The 60 requests per minute limit is reasonable for most internal APIs, but you can adjust it based on your needs. For higher throughput, change it to Limit::perMinute(300). For stricter limits, change it to Limit::perMinute(10). You can also use perSecond(), perHour(), or custom time windows.
If you need different limits for different endpoints, you can define multiple limiters:
RateLimiter::for('api-token', function (Request $request) {
return Limit::perMinute(60)->by($request->bearerToken() ?: $request->ip());
});
RateLimiter::for('api-token-strict', function (Request $request) {
return Limit::perMinute(10)->by($request->bearerToken() ?: $request->ip());
});
Then use 'throttle:api-token-strict' for endpoints that are more expensive or sensitive.
When rate limits are exceeded, an exception is reported to Nightwatch (your error tracking system):
if ($exception instanceof \Illuminate\Http\Exceptions\ThrottleRequestsException) {
Nightwatch::warning('Rate limit exceeded', [
'exception' => class_basename($exception),
]);
}
This gives you visibility into which clients are hitting limits and how often. If a client repeatedly hits the limit, it might indicate misconfiguration on their end, or they might be attacking your API. Either way, you have the data to investigate and respond.
AddRateLimitHeadersMiddleware: Rate Limit Headers
The throttle middleware stops requests when the limit is exceeded, but clients shouldn't have to guess how close they are to the limit. That's where AddRateLimitHeadersMiddleware comes in, providing complete transparency through HTTP headers and allowing smart clients to self-regulate before hitting the limit.
After the response is generated, this middleware reads the rate limiter's state and adds four critical headers:
X-RateLimit-Limit: The maximum number of requests allowed in the window (e.g., 60)X-RateLimit-Remaining: How many requests are left in the current window (e.g., 42 after you've made 18 requests)X-RateLimit-Reset: Unix timestamp when the quota resets (e.g., 1708982564)Retry-After: Only set when the limit is exceeded; tells the client how many seconds to wait before retrying
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$token = $request->bearerToken() ?? $request->ip();
$key = 'api-token:'.$token;
$maxAttempts = 60;
$remainingAttempts = $this->limiter->remaining($key, $maxAttempts);
$retryAfter = $this->limiter->availableIn($key);
$response->headers->set('X-RateLimit-Limit', (string) $maxAttempts);
$response->headers->set('X-RateLimit-Remaining', (string) max(0, $remainingAttempts));
if ($remainingAttempts <= 0) {
$response->headers->set('X-RateLimit-Reset', (string) (time() + $retryAfter));
$response->headers->set('Retry-After', (string) $retryAfter);
}
return $response;
}
The key insight: X-RateLimit-Remaining is available on every response, not just rejections. This means clients can monitor their quota continuously, even when they're nowhere near the limit.
Reactive clients (poorly designed):
- Send requests normally
- Get a 429 response
- Back off for Retry-After seconds
- Retry
This is wasteful, they burn requests until they hit the wall.
Proactive clients (well-designed):
- Send a request
- Read
X-RateLimit-Remaining: 5from the response headers - Slow down their request rate
- Back off before hitting 0
They never even trigger the rate limit because they self-regulate. Your API is happier because it isn't rejecting requests. The client is happier because they get consistent service.
Rate limiting is tricky with concurrent clients. Imagine a client makes 30 concurrent requests. The first request sees 60 - 30 = 30 remaining. But by the time all 30 hit your API, the counter has already decremented. Clients need to understand that X-RateLimit-Remaining is a snapshot, not a guarantee. It's accurate at that moment, but not in the future.
This is why smart clients use X-RateLimit-Remaining as a guide, not a law. They might back off at 10 remaining instead of 0, giving themselves a safety buffer for concurrent activity.
Many HTTP client libraries now read these headers automatically and implement backoff strategies. The Python requests-throttler library, for example, watches X-RateLimit-Remaining and automatically pauses requests. JavaScript's axios with rate limit middleware does the same. By exposing these headers, you enable sophisticated client behavior without having to build it yourself.
LogApiRequestsMiddleware: Request Auditing
An audit trail is essential for any API. You need to know who made what requests, what they sent, what happened, and how long it took. This isn't just for debugging, it's for compliance, security investigations, performance monitoring, and understanding user behavior. LogApiRequestsMiddleware captures everything non-destructively and asynchronously, so it never slows down your API.
What Gets Logged
For every request that passes through the middleware, this data is captured:
- User ID: Which Bearer token made the request (extracted from the Bearer model)
- HTTP Method: GET, POST, DELETE, etc.
- Endpoint: The full URL path that was called
- Request Body: Everything the client sent (all input parameters)
- Response Body: Everything you sent back (response data)
- HTTP Status Code: 200, 201, 400, 429, 500, etc.
- Execution Time: How long the request took in milliseconds
- Timestamp: When the request was made (implicit)
final readonly class LogApiRequestsMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Record start time
$startTime = microtime(true);
// Let request process
$response = $next($request);
// Use defer() to log AFTER response is sent
defer(callback: function () use ($request, $response, $startTime) {
// Identify the user (bearer token holder)
$token = $request->bearerToken();
$bearer = $token ? Bearer::where('token', $token)->first() : null;
$userId = $bearer->id ?? 0;
// Extract response data
$responseData = json_decode($response->getContent(), true) ?? [];
// Dispatch async logging job
SendToLogsJob::dispatch(
user_id: $userId,
method: $request->method(),
endpoint: $request->url(),
request: $request->all(),
response: $responseData,
status: $response->getStatusCode(),
execution_time: microtime(true) - $startTime
);
});
return $response;
}
}
The key is the defer() function: it queues a callback to run after the response is sent to the client. This means logging never delays the response. The client gets their data instantly. The logging happens asynchronously in the background.
The SendToLogsJob is a queued job, not immediate logging:
class SendToLogsJob implements ShouldQueue
{
public $tries = 3; // Retry up to 3 times if queuing fails
public $backoff = [30, 60, 120]; // Exponential backoff
public $onQueue = 'license-logs'; // Use dedicated queue
public function handle(): void
{
// Now the job actually logs to the LogFacade
LogFacade::create(
user_id: $this->user_id,
method: $this->method,
endpoint: $this->endpoint,
request: $this->request,
response: $this->response,
status: $this->status,
execution_time: $this->execution_time
);
}
}
This job is queued on its own 'license-logs' queue, separate from other background jobs. This isolation ensures that logging never blocks critical jobs like payment processing or cache warming.
Without async logging:
- Request comes in: 0ms
- Process request: 50ms
- Create response: 10ms
- Save to database logs: 30ms (I/O wait)
- Send response: 20ms
- Total time to response: 90ms worse
With async logging:
- Request comes in: 0ms
- Process request: 50ms
- Create response: 10ms
- Queue logging job: 2ms (just queueing, no I/O)
- Send response immediately: client gets response in ~62ms
- Background worker saves logs asynchronously: happens later
For a high-traffic API handling thousands of requests, this is a massive difference. You're talking about 18ms per request saved across thousands of requests = server capacity to handle 3x more traffic.
When your API returns 500 errors, the logs show what requests caused them, what data was sent, and what data was returned:
request: { "key": "lic_123", "domain": "example.com" }
response: { "error": "External API timeout", "code": "PROVIDER_ERROR" }
status: 503
execution_time: 30123ms
You immediately see the request timed out. Now you check the external API status and see it was down. Problem solved, you didn't break anything.
You notice one token made 10,000 requests in 5 minutes. The logs show exactly what they requested and help you determine whether it was malicious or misconfigured:
user_id: 42
method: GET
endpoint: /api/licenses
request: {}
status: 200
execution_time: 45ms
Thousands of successful license list requests. Looks like a bug in their client, it's looping. You can reach out and help them fix it.
Regulators often ask who accessed what data and when. Your logs have answers:
2026-02-22 14:35:22 | user_id: 15 | GET /api/licenses
2026-02-22 14:35:45 | user_id: 15 | POST /api/license
2026-02-22 14:36:01 | user_id: 15 | DELETE /api/license/{key}
The logs show execution_time for every request, so you can identify slow endpoints:
GET /api/licenses: 45ms average (good)
POST /api/license: 250ms average (slow!)
DELETE /api/license/{key}: 5000ms average (very slow!)
You can identify bottlenecks.
The logs capture everything the client sends in request and what you send back in response. This includes:
- Query parameters
- Form data
- Request body
- Response data
If this includes passwords, API keys, credit card numbers, or other sensitive data, those are logged too! For security, you should:
defer(callback: function () use ($request, $response, $startTime) {
// ... existing code ...
// Sanitize sensitive fields
$requestData = $request->all();
unset($requestData['password'], $requestData['api_key'], $requestData['secret']);
SendToLogsJob::dispatch(
// ...
request: $requestData, // Sanitized!
// ...
);
});
Or use ShouldBeEncrypted on the job class (which this project does) to auto-encrypt logged data at rest.
Global API Middlewares (affect every API route)
In Laravel 12, "global" for APIs means appending middleware to the API group in bootstrap/app.php. These run on every API route, in the order you add them, before the controller is reached.
We register two global API middlewares so every endpoint gets consistent localization and compression:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->api(append: [
SetRequestLocaleMiddleware::class,
GzipResponseMiddleware::class,
]);
})
This also affects the /health route from Chapter 1. It stays public (no auth or rate limiting), but it now inherits global API behavior: the locale is set for translated labels and validation messages, and the response can be gzipped when the client accepts gzip and the payload is large enough.
SetRequestLocaleMiddleware: Language Detection
Let your API consumers choose their language without special client libraries or extra wiring. This middleware keeps localization explicit and predictable.
Clients can specify their preferred language in two ways, with this priority:
- Query parameter (
?locale=es): Explicit, highest priority. Useful for testing or when a user overrides their preference. - Accept-Language header: Standard HTTP header, second priority. Must be one of the supported locale codes (
en,es). - Default locale: Falls back to application default if neither is provided.
This cascading approach gives clients flexibility while keeping defaults sensible.
final readonly class SetRequestLocaleMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Priority 1: Query parameter or Priority 2: Accept-Language header
$locale = $request->query('locale') ?? $request->header('Accept-Language');
// Validate locale is supported
if ($locale && in_array($locale, LocaleEnum::values(), true)) {
App::setLocale($locale);
}
return $next($request);
}
}
The middleware validates against LocaleEnum, which in this API defines supported languages:
enum LocaleEnum: string
{
case English = 'en';
case Spanish = 'es';
}
This validation is crucial: it prevents injection attacks. A malicious client can't request locale=../../etc/passwd or locale='; DROP TABLE users;--. Invalid locales are silently ignored, falling back to the default.
GET /api/licenses
Accept-Language: en
Response: English validation errors, English error messages, English documentation.
GET /api/licenses
Accept-Language: es
Response: Spanish validation errors, Spanish error messages.
GET /api/licenses?locale=es
Accept-Language: en-US
Response: Spanish (query param overrides header).
GET /api/licenses?locale=fr
Response: Default locale (French isn't supported, so application default applies).
GET /api/licenses
Accept-Language: en-US
Response: Default locale (only exact en or es are accepted by this middleware).
Once the middleware sets the locale with App::setLocale(), everything in Laravel automatically uses that locale:
// app/Http/Requests/CreateLicenseRequest.php
public function messages(): array
{
return [
'name.required' => Lang::get('validation.name.required'),
'name.string' => Lang::get('validation.name.string'),
'name.max' => Lang::get('validation.name.max'),
'domain.required' => Lang::get('validation.domain.required'),
'domain.string' => Lang::get('validation.domain.string'),
'domain.max' => Lang::get('validation.domain.max'),
];
}
{
"errors": [
{
"name": ["The license name is required."],
"domain": ["The domain is required."]
}
]
}
Internationalization isn't just about translation. It's about respect. When a user gets an error message in their language, they trust your API more. They don't feel like outsiders. When Spanish speakers get Spanish errors, English speakers get English errors, and Mandarin speakers get Mandarin errors, you're not just being inclusive, you're being professional. Translation files are cached after the first load, and setting the locale happens once per request, so the overhead is negligible.
Key patterns:
- Create translation files for each language
- Use an enum for supported locales
- Middleware sets locale from request (param or header)
- Validation messages are auto-translated
The outcome is simple: error messages, enums, and health labels follow the user's language without changing controller logic.
GzipResponseMiddleware: Response Compression
Network bandwidth is expensive. Every kilobyte matters when you're serving millions of API requests. Gzip compression can reduce response sizes by 70-80% for typical JSON payloads. But compression has a cost: it takes CPU cycles. This middleware is smart about when to compress, and it only compresses when the benefit outweighs the cost.
The middleware checks five conditions before compressing:
-
Only GET requests: Compression makes sense for read operations that return data. POST/PUT/DELETE requests typically have small bodies and small responses, so compression overhead isn't worth it.
-
Only successful responses (200-299): Compress data responses, not error messages. A 400 or 500 error with a 200-byte message isn't worth compressing.
-
Client must accept gzip: Check the
Accept-Encodingheader. If it doesn't includegzip, the client can't decompress the response, so don't compress. Most modern clients support gzip, but browsers and older clients might not. -
Response not already encoded: Check if
Content-Encodingis already set. If another middleware already compressed the response, don't double-compress. -
Response is large enough: Only compress if the response is at least 1400 bytes. Compressing tiny responses adds overhead (CPU time, header bytes) that outweighs the size savings. A 200-byte JSON response becomes 300+ bytes after compression headers and overhead.
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Only compress GET with success status (200-299)
if ($request->getMethod() !== 'GET') {
return $response;
}
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
return $response;
}
// Check if client accepts gzip
$acceptEncoding = $request->header('Accept-Encoding', '');
if (! str_contains($acceptEncoding, 'gzip')) {
return $response;
}
// Don't compress if already encoded
if ($response->headers->has('Content-Encoding')) {
return $response;
}
$content = $response->getContent();
// Only compress if content is large enough
if (strlen($content) < self::MIN_COMPRESSION_SIZE) {
return $response;
}
// Compress with gzip compression level 9 (maximum)
$compressed = gzencode($content, 9);
$response->setContent($compressed);
$response->headers->set('Content-Encoding', 'gzip');
return $response;
}
The middleware uses gzencode() with compression level 9, which is maximum compression. This takes longer than level 6 (default) but produces smaller files. For APIs, the extra CPU is worth the bandwidth savings, especially for large responses.
Imagine a license listing endpoint that returns 100 licenses with full details. The JSON response might be 50KB. With gzip:
- Uncompressed: 50KB transmitted
- Compressed: ~8KB transmitted (85% reduction)
- Compression overhead: typically a few milliseconds of CPU on the server
- Decompression: Automatic in the browser/client, feels instant
For a global API serving thousands of requests per day, this is a massive bandwidth saving. On a 100Mbps connection, you send that response 6.25x faster. On a mobile connection, it's the difference between instant and noticeably slow.
Clients decoding gzip is automatic. When a browser or HTTP client sees Content-Encoding: gzip, it automatically decompresses. The client code doesn't have to do anything special. In fact, most clients never even think about it, the decompression is transparent.
Some responses shouldn't be compressed:
- Already compressed: Images, PDFs, videos are already compressed; re-compressing wastes CPU.
- Streaming responses: If you're streaming a large file, compression happens transparently in the HTTP layer, not in your middleware.
- Server-Sent Events (SSE): Compressing streaming data breaks the real-time nature; each chunk gets compressed/decompressed separately.
For a typical REST API returning JSON, compression is almost always beneficial.
Because compression should be consistent across all API responses, this middleware is best registered globally in the API middleware group (not the web group).
Here's the magic: you can add, remove, or reorder middlewares without changing your controller code. You can add a new security check, a new logging mechanism, or a new feature that applies to all routes simply by adding a line to your middleware stack. No controller changes needed.