How Laravel Middleware Works

Plenty of Laravel apps have controllers stuffed with repeated checks: is the user logged in, is the account active, may this person open the admin area. When those checks are copie...

How Laravel Middleware Works

Plenty of Laravel apps have controllers stuffed with repeated checks: is the user logged in, is the account active, may this person open the admin area. When those checks are copied into dozens of methods, one eventually gets missed, and a single missed check can become a security hole. Middleware fixes this by moving cross-cutting rules into one layer that every request passes through.

This guide covers middleware in Laravel 11 (PHP 8.2+): where it sits in the request lifecycle, how to create and register it, before versus after logic, parameters, terminable middleware, execution order, and the errors developers hit most often.

1. Where Middleware Sits in the Lifecycle

A request enters through public/index.php, Laravel boots the application and hands the request to the HTTP kernel. From there it travels through a stack of middleware, layered like an onion:

  1. Global middleware runs first (trimming input strings, checking maintenance mode).
  2. The router matches a route, then runs the group middleware (web or api) and the route's own middleware.
  3. The controller or route closure produces a response.
  4. The response travels back out through the same layers in reverse order.
  5. The response is sent to the browser, then any terminate() methods run.

Because of this layering, one middleware class can run code before the request moves on and after the response exists.

2. Creating Your First Middleware

php artisan make:middleware EnsureUserIsActive

Laravel creates app/Http/Middleware/EnsureUserIsActive.php. Fill in handle():

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsActive
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user === null || ! $user->is_active) {
            auth()->logout();

            return redirect()->route('login')
                ->with('error', 'Your account is inactive. Please contact support.');
        }

        return $next($request);
    }
}

Everything hinges on $next. Return $next($request) and the request continues. Return a redirect or call abort() first and the request stops here; the controller never runs.

3. Registering Middleware in Laravel 11

New Laravel 11 projects no longer ship app/Http/Kernel.php. Registration lives in bootstrap/app.php:

use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'active' => \App\Http\Middleware\EnsureUserIsActive::class,
        'role'   => \App\Http\Middleware\EnsureUserHasRole::class,
    ]);

    // global: runs on EVERY request
    $middleware->append(\App\Http\Middleware\AddSecurityHeaders::class);

    // web group only
    $middleware->web(append: [
        \App\Http\Middleware\SetLocale::class,
    ]);
})

Projects upgraded from Laravel 10 may still use the old kernel file. Pick one approach per project so the team knows where to look.

Applying It to Routes

Route::middleware(['auth', 'active'])->group(function () {
    Route::resource('orders', OrderController::class);

    Route::get('/orders/export', [OrderController::class, 'export'])
        ->withoutMiddleware('active');
});

4. Before, After and Terminable

TypeWhen it runsTypical use
BeforeBefore calling $next()Auth checks, role gates, API keys, locale
AfterAfter $next() returnsSecurity headers, status logging
TerminableAfter the response reaches the browserTiming logs, metrics, cleanup

An after middleware that adds baseline security headers:

public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);

    $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
    $response->headers->set('X-Content-Type-Options', 'nosniff');
    $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');

    return $response;
}

5. Middleware Parameters

Role gates are the classic case. Parameters come after $next, and a variadic accepts several values:

public function handle(Request $request, Closure $next, string ...$roles): Response
{
    $user = $request->user();

    if ($user === null || ! in_array($user->role, $roles, true)) {
        abort(403);
    }

    return $next($request);
}
Route::get('/reports', [ReportController::class, 'index'])
    ->middleware(['auth', 'role:admin,finance']);

Once access rules depend on individual permissions rather than a single role, move to a permission package or Gates and Policies instead of growing this class.

6. Terminable Middleware

public function handle(Request $request, Closure $next): Response
{
    $request->attributes->set('started_at', microtime(true));

    return $next($request);
}

public function terminate(Request $request, Response $response): void
{
    $ms = (microtime(true) - $request->attributes->get('started_at')) * 1000;

    if ($ms > 1000) {
        logger()->warning('Slow request', ['path' => $request->path(), 'ms' => round($ms)]);
    }
}

The "after the browser gets the response" behaviour depends on PHP-FPM. On some hosts it runs before the connection closes, so keep terminate() light and push heavy work to a queue.

7. Order Matters

Middleware runs in array order. If your class reads $request->user() but runs before auth, it always sees null. You can pin the order explicitly:

$middleware->priority([
    \Illuminate\Session\Middleware\StartSession::class,
    \Illuminate\Auth\Middleware\Authenticate::class,
    \App\Http\Middleware\EnsureUserIsActive::class,
]);

8. Middleware or Something Else?

  • Middleware: rules shared by many routes such as login, account status, locale, headers and rate limits (throttle:60,1).
  • Policies: rules tied to a specific record, like "users may only edit their own invoices".
  • Form Requests: input validation.
  • Queues: slow work such as emails or image processing.

9. Common Errors and Fixes

  • "Return value must be of type Response, null returned": a code path forgot return $next($request).
  • "Target class [role] does not exist": the alias is missing from bootstrap/app.php or a stale cache is loaded. Run php artisan optimize:clear.
  • Redirect loop on /login: the active-account check is also attached to the login route. Exclude it with withoutMiddleware().
  • Every page got slower: a global middleware runs a database query. Scope it to specific routes or cache the result.

10. Testing Middleware With Feature Tests

Access-control middleware deserves tests, because a small slip here means data is exposed to people who should not see it. These tests confirm that inactive users are bounced to the login page while active users reach the dashboard:

use App\Models\User;

it('redirects inactive users to login', function () {
    $user = User::factory()->create(['is_active' => false]);

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertRedirect(route('login'));
});

it('lets active users open the dashboard', function () {
    $user = User::factory()->create(['is_active' => true]);

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertOk();
});

The example uses Pest, the default test runner offered for new Laravel projects. With PHPUnit the body is identical, just wrapped in class methods. When you test an unrelated feature, you can skip a specific middleware with $this->withoutMiddleware(EnsureUserIsActive::class) so the test stays focused on what it is checking.

A habit worth adopting: every time you add an admin route, add one test asserting that a regular user gets a 403. These tests take a minute to write and catch the regression when someone later moves a route outside its middleware group by accident. They also document, in executable form, who is supposed to reach each part of the application.

Pre-Deploy Checklist

  • All admin routes use auth plus a role or permission check.
  • Login, registration and password reset are not behind login-only middleware.
  • Global middleware performs no heavy queries.
  • Feature tests assert that unauthorized users receive a 403.
  • You have reviewed php artisan route:list -v to see each route's middleware.

For broader hardening, read 10 must-have web security practices, and if you queue slow work, see Laravel queues and jobs.

Yudhi
Written by
Yudhi
Founder & Lead Developer, GudangCode

Yudhi is the founder of GudangCode and a Laravel developer who has built dozens of ready-to-use business information systems — from POS and HRIS to management apps. He writes guides and articles on GudangCode to help Indonesian developers run, understand, and deploy Laravel source code correctly.

LaravelPHPMySQLSistem Informasi Bisnis See all articles by Yudhi
Want the full source code & apps?

Sign up free to download ready-to-use business applications, information systems, and Laravel source code.

Sign Up Free & Download
Middleware Laravel Programming & Laravel
Read Also

Here are some related articles that might help expand your knowledge:

Share this article
Back to Blog
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →

Get Full Access Now!

Join our membership and unlock exclusive access to all premium features. Fast, easy, and ready to use instantly.

Join Membership Now