Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Middleware, Auth & Policies

Request pipeline, authentication, gates and policies.

Before a request reaches your controller, it passes through layers of middleware. These layers can inspect, modify or reject it. Authentication and authorisation sit on top of that pipeline. Authentication answers who is this? Authorisation answers are they allowed to do this?

This lesson walks through the middleware pipeline, writing your own middleware, how Laravel's session authentication works, and protecting the Projects & Tasks app with gates and policies.

The middleware onion

Middleware wraps your application like the layers of an onion. Each layer receives the request and a $next closure. It can act before calling $next, after it, or not call it at all to short-circuit with its own response.

Controller TaskController@update EncryptCookies · StartSession VerifyCsrfToken auth · can:update,task Request → → Response short-circuit: 419 / 302 login / 403

Step through a request yourself. Toggle the conditions and watch where it stops:

Writing middleware

This app has a real middleware on its demo API: App\Http\Middleware\AddQueryCountHeader. It shows both halves of the onion. It starts a timer and a query counter before calling $next, then decorates the response after:

php artisan make:middleware AddQueryCountHeader

app/Http/Middleware/AddQueryCountHeader.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

/**
 * Exposes how many SQL queries a request ran and how long it took,
 * so the tutorials can show the cost of each endpoint live.
 */
class AddQueryCountHeader
{
    /**
     * Handle an incoming request.
     *
     * @param  Closure(Request): (Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        // BEFORE: start the clock and count every query that runs
        $startedAt = microtime(true);
        $queryCount = 0;

        DB::listen(function () use (&$queryCount) {
            $queryCount++;
        });

        $response = $next($request);   // pass inward

        // AFTER: decorate the response on its way out
        $response->headers->set('X-Query-Count', (string) $queryCount);
        $response->headers->set('X-Response-Time', round((microtime(true) - $startedAt) * 1000, 1).'ms');

        return $response;
    }
}

Hit the real API and read the headers it added:

A middleware can also short-circuit. Illustrative — not in this app: this one would make archived projects read-only by refusing writes before the controller ever runs:

public function handle(Request $request, Closure $next): Response
{
    if ($request->route('project')?->archived_at && ! $request->isMethodSafe()) {
        abort(423, 'This project is archived and read-only.');
    }

    return $next($request);
}

Registering middleware in bootstrap/app.php

Since Laravel 11 there is no Http/Kernel.php. Routing, middleware and exception handling are configured fluently when the application is built. This is this app's real file:

bootstrap/app.php

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        // API routes always answer with JSON (422 errors, 404s), even without an Accept header
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson()
        );
    })->create();

withMiddleware() is empty because the demo API attaches its middleware by class name in routes/api.php. These are the options you'll reach for most often. Illustrative — not in this app:

->withMiddleware(function (Middleware $middleware): void {
    // Short alias, e.g. ->middleware('query-count')
    $middleware->alias([
        'query-count' => AddQueryCountHeader::class,
    ]);

    // Append to every web request
    $middleware->web(append: [
        \App\Http\Middleware\AddSecurityHeaders::class,
    ]);

    // Behind a proxy or load balancer that terminates HTTPS: trust X-Forwarded-* headers
    $middleware->trustProxies(at: '*');
})

Then apply it to routes:

routes/api.php

// Public, read-only "Projects & Tasks" API used by the tutorials' live demos
Route::prefix('demo')
    ->name('api.demo.')
    ->middleware(['throttle:demo', AddQueryCountHeader::class])
    ->group(function () {
        Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
        Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
        Route::get('/tasks', [TaskController::class, 'index'])->name('tasks.index');
        Route::post('/tasks/validate', [TaskController::class, 'validateOnly'])->name('tasks.validate');
        Route::get('/query-count', QueryCountController::class)->name('query-count');
    });

A typical authenticated web section would look like this. Illustrative — not in this app:

Route::middleware('auth')->group(function () {
    Route::resource('projects.tasks', TaskController::class)->shallow();
});

Why this matters (and why this site doesn't need it yet): when a proxy or load balancer terminates HTTPS, it forwards plain HTTP to PHP, so without trustProxies Laravel thinks every request is http://. That causes insecure URLs and redirect loops. Trusting the proxy makes Laravel read X-Forwarded-Proto instead. This site is self-hosted on a VPS where nginx terminates HTTPS itself (a Let's Encrypt certificate via Certbot) and hands requests straight to PHP-FPM, so Laravel already sees the real scheme and client IP. That's why bootstrap/app.php doesn't configure it. You'd add it the moment you put Cloudflare or a load balancer in front.

Middleware parameters and terminable middleware

Illustrative — not in this app.

// Route::get(...)->middleware('role:admin,editor');
public function handle(Request $request, Closure $next, string ...$roles): Response
{
    abort_unless(in_array($request->user()?->role, $roles, true), 403);
    return $next($request);
}

// Runs after the response has been sent to the browser (with PHP-FPM)
public function terminate(Request $request, Response $response): void
{
    Log::info('request.finished', ['ms' => (microtime(true) - LARAVEL_START) * 1000]);
}

Authentication: a quick recap

Middleware like auth only works because something has already logged the user in. Login, Registration & Logout covers that properly. Here's the short version you need for this lesson:

  • A guard decides how a user is identified: session for browsers, a token for APIs.
  • A provider decides where users come from, usually Eloquent and the User model.
  • After Auth::attempt() succeeds, the user's ID lives in the session. On every later request the auth middleware reads it back, and $request->user() returns the model, or redirects to login if there isn't one.

The rest of this lesson takes it from there: once you know who the user is, middleware, gates and policies decide what they may do.

Authorisation with gates

A gate is a closure that answers a yes/no question about a user. Define gates in a service provider. Illustrative — not in this app:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    Gate::define('view-reports', fn (User $user) => $user->is_admin);

    // Runs before every check: admins can do anything
    Gate::before(fn (User $user) => $user->is_admin ? true : null);
}
if (Gate::denies('view-reports')) { abort(403); }
Gate::authorize('view-reports');                 // throws 403 automatically
$request->user()->can('view-reports');           // boolean

Authorisation with policies

Gates get unwieldy for per-model rules. A policy groups the rules for one model:

php artisan make:policy TaskPolicy --model=Task

This is the real policy from the demo app. Signed-in users can view tasks, the assignee or the project owner can edit, and only the owner can delete:

app/Policies/TaskPolicy.php

namespace App\Policies;

use App\Models\Task;
use App\Models\User;

class TaskPolicy
{
    /**
     * Anyone signed in can see tasks.
     */
    public function view(User $user, Task $task): bool
    {
        return true;
    }

    /**
     * The assignee or the project owner can edit a task.
     */
    public function update(User $user, Task $task): bool
    {
        return $user->id === $task->user_id
            || $user->id === $task->project->user_id;
    }

    /**
     * Only the project owner can delete a task.
     */
    public function delete(User $user, Task $task): bool
    {
        return $user->id === $task->project->user_id;
    }
}

It's pinned down by a test:

tests/Feature/Demo/TaskPolicyTest.php (one of three tests)

public function test_assignee_can_update_but_not_delete_their_task(): void
{
    $task = Task::factory()->create();

    $this->assertTrue($task->assignee->can('update', $task));
    $this->assertFalse($task->assignee->can('delete', $task));
}

Need a reason in the 403? Return Illuminate\Auth\Access\Response::deny('Only the project owner can delete tasks.') instead of false.

Laravel auto-discovers policies: App\Models\Task maps to App\Policies\TaskPolicy. You can use a policy in four places. The demo API is public and read-only, so it doesn't call the policy. Illustrative — not in this app:

// 1. Controller
public function update(UpdateTaskRequest $request, Task $task)
{
    Gate::authorize('update', $task);
    // ...
}

// 2. Route middleware
Route::put('/tasks/{task}', [TaskController::class, 'update'])->can('update', 'task');

// 3. Form Request
public function authorize(): bool
{
    return $this->user()->can('update', $this->route('task'));
}
{{-- 4. Blade: hide UI the user can't use --}}
@can('update', $task)
    <a href="{{ route('tasks.edit', $task) }}">Edit</a>
@endcan

@cannot('delete', $task)
    <span class="muted">Only the owner can delete</span>
@endcannot

Hiding a button isn't security. @can in Blade only improves the user experience. The real enforcement must happen on the server, in the controller, a Form Request or route middleware. Otherwise anyone can still send the PUT request with curl.

Gates vs policies

Gate Policy
Shape One closure A class of methods
Best for Global abilities ("view admin dashboard") Per-model CRUD rules
Model argument Optional Usually required, auto-resolved
Discovery Defined manually Auto-discovered by naming convention
Testing Call Gate::allows() Instantiate the policy and call methods directly

Scoping queries to the user

Authorisation also means not leaking other users' data in lists. Scope index queries. Illustrative — not in this app:

public function index(Request $request)
{
    $projects = $request->user()
        ->projects()                 // only mine
        ->withCount('tasks')
        ->latest()
        ->paginate(20);

    return view('projects.index', compact('projects'));
}

For multi-tenant apps, a global scope guarantees that no query ever forgets the filter. Illustrative — not in this app:

#[ScopedBy(TeamScope::class)]
class Project extends Model { /* ... */ }

class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($team = auth()->user()?->current_team_id) {
            $builder->where($model->qualifyColumn('team_id'), $team);
        }
    }
}

Key takeaways

  • Middleware wraps the request like an onion. It can act before or after $next, or short-circuit with its own response.
  • Laravel 11+ registers middleware in bootstrap/app.php with withMiddleware(), using aliases, groups and trustProxies.
  • Authentication = guards + providers. Always regenerate the session on login and invalidate it on logout.
  • Use gates for global abilities and policies for per-model rules. Enforce them on the server, and use @can only to shape the UI.
  • Scope list queries to the current user or tenant so data never leaks through index pages.