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.
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:
sessionfor browsers, a token for APIs. - A provider decides where users come from, usually Eloquent and the
Usermodel. - After
Auth::attempt()succeeds, the user's ID lives in the session. On every later request theauthmiddleware 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.phpwithwithMiddleware(), using aliases, groups andtrustProxies. - 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
@canonly to shape the UI. - Scope list queries to the current user or tenant so data never leaks through index pages.