Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 025 / 100

Laravel middleware: a PHP & Laravel guide for developers

Filter requests before they reach controllers.

Expona Admin7 min read

Understanding Laravel middleware: a gateway for every request

Every request entering your Laravel application passes through a series of filters before it reaches your controller. These filters—middleware—are the gatekeepers of your application, handling cross-cutting concerns like authentication, rate limiting, and request transformation. In this guide, we'll explore Laravel middleware: a PHP & Laravel guide for developers who need to secure, optimize, and customize request handling at scale.

Middleware sits at the intersection of HTTP and your business logic. Rather than duplicating security checks or header manipulation across every endpoint, you define the logic once and apply it where needed. This approach keeps your controllers focused on what they do best: handling requests that have already been vetted and prepared.

Authentication, throttling, and CORS are already middleware

Laravel ships with several built-in middleware that handle common concerns. You've likely used them without thinking about how they work. Understanding these will give you a mental model for building your own.

The auth middleware checks whether a request includes a valid session or bearer token. If not, it redirects or returns a 401 response. Throttling middleware (like throttle) limits how many requests a user or IP can make within a time window. CORS middleware adds headers that tell browsers whether a cross-origin request is allowed.

These aren't special—they're regular PHP classes that implement middleware logic. Here's a simplified version of how auth middleware works:

php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class Authenticate
{
    public function handle(Request $request, Closure $next)
    {
        if (! Auth::check()) {
            return response()->json(['error' => 'Unauthenticated'], 401);
        }

        return $next($request);
    }
}

The pattern is straightforward: check a condition, either stop the request and return a response, or pass it to the next middleware or controller. In production systems handling thousands of concurrent users, these middleware classes are your first line of defense. They catch invalid requests before your database even hears about them.

Creating custom middleware for headers and tenancy

Out-of-the-box middleware handles generic concerns. Custom middleware is where you encode business logic that only your application needs. Common scenarios include enforcing custom headers, managing multi-tenant routing, and applying feature flags to specific routes.

Let's say you're building a SaaS platform where each customer is a tenant. Your API should operate within that tenant's data boundary. Create a middleware to extract the tenant identifier from the request and make it available throughout the request lifecycle:

php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $tenantId = $request->header('X-Tenant-ID');

        if (! $tenantId) {
            return response()->json(
                ['error' => 'Tenant ID required'],
                400
            );
        }

        // Store in app container for access throughout request
        app()->instance('tenant_id', $tenantId);

        // Optionally scope database queries
        config(['database.tenant_id' => $tenantId]);

        return $next($request);
    }
}

Now register it in app/Http/Kernel.php under the $routeMiddleware array:

php
protected $routeMiddleware = [
    'tenant' => \App\Http\Middleware\ResolveTenant::class,
];

Apply it to routes that need tenant isolation:

php
Route::middleware('tenant')->group(function () {
    Route::get('/invoices', [InvoiceController::class, 'index']);
    Route::post('/invoices', [InvoiceController::class, 'store']);
});

In your controller or queries, retrieve the tenant context without passing it as a parameter. This keeps your code clean and prevents accidental cross-tenant data leaks—a serious issue in production SaaS.

Another common use case is enforcing headers for API versioning or security compliance. For instance, a payment processing integration might require clients to include an API version header:

php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class EnforceApiVersion
{
    protected $supportedVersions = ['v1', 'v2'];

    public function handle(Request $request, Closure $next)
    {
        $version = $request->header('X-API-Version');

        if (! in_array($version, $this->supportedVersions)) {
            return response()->json(
                ['error' => 'Unsupported API version'],
                400
            );
        }

        // Make version available to controller
        $request->merge(['api_version' => $version]);

        return $next($request);
    }
}

Order matters—middleware execution is sequential

Middleware executes in the order you define it. This matters because each middleware can modify the request or stop it entirely. In a real application, you might have 5-10 middleware on a single route, and the sequence affects behavior significantly.

Consider a route that requires both authentication and tenant resolution. If tenant middleware runs before auth, an unauthenticated user still passes tenant resolution checks. If auth runs first, the tenant middleware receives a verified user. The order depends on your business logic:

php
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
    Route::get('/data', [DataController::class, 'index']);
});

In this case, auth runs first. If it fails, the response returns immediately and tenant middleware never executes. This is efficient—why check for a tenant if we don't know who's asking?

Middleware also executes in reverse on the response. If you define middleware as ['a', 'b', 'c'], the request flows through A → B → C → controller, but the response flows back through C → B → A. This matters when middleware modifies response headers or logging. Keep this "onion" model in mind when debugging unexpected header values or timing issues.

In the Kernel.php file, global middleware runs first (in order), then route middleware. This is important because global middleware like CORS or request logging should typically run before authentication or tenant resolution:

php
protected $middleware = [
    \App\Http\Middleware\TrustProxies::class,
    \Illuminate\Http\Middleware\HandleCors::class,
    // Global middleware runs for every request
];

protected $routeMiddleware = [
    'auth' => \App\Http\Middleware\Authenticate::class,
    'tenant' => \App\Http\Middleware\ResolveTenant::class,
];

Keep middleware fast—avoid blocking operations

Middleware runs on every request. If you're handling 1,000 requests per second and each middleware adds 10ms of delay, you've added 10 seconds of latency per request by the time it reaches your controller. Performance compounds.

Avoid slow operations in middleware:

  • Don't query your entire user table to validate an API key. Cache keys or use indexed lookups.
  • Don't make external HTTP calls unless absolutely necessary. If you must, cache the responses.
  • Don't parse large request bodies twice. Extract data once and attach it to the request object.
  • Don't iterate over thousands of items checking permissions. Use database queries with WHERE clauses.

Here's a performance-conscious middleware that validates an API key using cache:

php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class ValidateApiKey
{
    public function handle(Request $request, Closure $next)
    {
        $apiKey = $request->header('X-API-Key');

        if (! $apiKey) {
            return response()->json(['error' => 'API key required'], 401);
        }

        // Check cache first, then database
        $userId = Cache::remember(
            "api_key:{$apiKey}",
            3600, // 1 hour
            function () use ($apiKey) {
                return \App\Models\ApiKey::where('key', $apiKey)
                    ->first()
                    ?->user_id;
            }
        );

        if (! $userId) {
            return response()->json(['error' => 'Invalid API key'], 401);
        }

        $request->merge(['user_id' => $userId]);

        return $next($request);
    }
}

The cache layer ensures that database lookups happen at most once per hour per key. In high-throughput systems, this reduces middleware overhead dramatically.

Common mistakes

Forgetting to call $next($request): Middleware must forward the request down the pipeline. Without it, the response never reaches the controller.

Placing middleware in the wrong location: Global middleware applies to every request. Route middleware applies only to specified routes. Using global middleware for authentication will prevent requests from reaching unprotected endpoints.

Not handling early returns: Once middleware returns a response, the chain stops. Subsequent middleware doesn't run. If you're debugging missing middleware logic, check whether an earlier middleware is returning early.

Modifying the request in ways controllers don't expect: If middleware transforms request data (renaming fields, converting types), document this behavior. Controllers relying on the original format will break silently.

Assuming middleware has access to authenticated user in constructor: Middleware runs before authentication completes in global scope. Move user-dependent logic to the handle method, after auth middleware has run.

Key takeaways

  • Middleware filters requests before they reach controllers. Think of it as security and data-prep layers that keep controllers focused on business logic.
  • Auth, throttling, and CORS are middleware. Understanding how they work gives you a template for custom middleware.
  • Order matters. Request flows through middleware sequentially; responses flow back in reverse. Place fast checks early, slow checks later.
  • Custom middleware handles tenant isolation, header validation, feature flags, and request transformation. Attach processed data to the request object for controller access.
  • Keep middleware fast. Cache expensive lookups, avoid external calls, and use database indexes. Every millisecond compounds across thousands of requests.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles