Laravel has middleware in three places that most apps use every day. HTTP middleware wraps each incoming request, job middleware wraps each queued job, and Guzzle middleware wraps each outgoing call made through the HTTP client. The three signatures differ, but each one is a layer that receives the input and a callable for everything below it.
Key takeaways
- All three kinds are a function wrapped around "the rest". Each layer gets the thing being processed and a
$next. It can run code around the call to$next, or return without making it. - Request and job middleware run on the same class. Both go through
Illuminate\Pipeline\Pipeline. HTTP client middleware is Guzzle's own handler stack, which is why its signature looks nothing like the other two. - Job middleware also covers queued listeners, mailables, notifications and broadcast events. They all run through the same pipeline, so
RateLimitedworks on a queued notification exactly as it does on a job. - A job middleware that doesn't call
$nextcounts as a success.Skip, adontRelease()limiter or your own early return deletes the job, and if it was part of a chain the next link still runs. -
Http::globalMiddleware()only sees Laravel's client. SDKs that build their own Guzzle client need the middleware pushed onto their stack too. Faked requests do pass through it, so it can be tested withHttp::fake(). - On a Livewire page, route middleware mostly runs once. Component actions only re-run middleware on Livewire's persistent list, which includes
authandcanbut notverified,throttleor your own classes. Filament'sisPersistentflag adds to the same list.
What middleware is
Middleware is code that runs between a caller and a handler, and can change the input or stop it before the handler sees it. The handler might be a controller, a job's handle() method or the cURL call at the bottom of Guzzle. The middleware is given only the input and a callable that runs everything below it, so the same pattern works for all of them.
Each middleware is a layer. The input passes inwards through every layer to the handler, and the result passes back out through the same layers in reverse, so a layer can run code before the handler and after it:
public function handle($input, Closure $next) { // on the way in: inspect or change $input, or return early $result = $next($input); // on the way out: inspect or change $result return $result; } If a layer returns without calling $next, nothing inside it runs. An authentication middleware uses this to turn away a guest before the controller is constructed, and a rate limiter uses it to put a job back on the queue before the job calls the API.
Why use it
Some code has to run for many handlers but isn't part of what any of them does. Every admin route needs an authenticated admin, and every job that calls a vendor API has to stay under the vendor's rate limit. Written inline, that check is copied into dozens of controllers and jobs, and sooner or later one of them is missed.
As middleware, the check is written once and attached by name. The controller keeps what the endpoint does, with nothing about who may call it, and the job keeps its work, with nothing about pacing. The order is written down too: the session starts before authentication, and authentication runs before authorisation, in a list you can read in one place.
The cost is that you can't see middleware from the handler. If a middleware rewrites input or drops a job, the cause is in a file the reader of the controller never opens. Most of the gotchas later in this post are of that kind.
How Laravel runs it
Request and job middleware both run on Illuminate\Pipeline\Pipeline, which you can also use directly. It takes an object, a list of stages, and a final callback, and folds the stages into nested closures so the first stage in the list is the outermost layer:
use Illuminate\Support\Facades\Pipeline; $order = Pipeline::send($order) ->through([ ApplyCoupon::class, CalculateTax::class, ReserveStock::class, ]) ->then(fn (Order $order) => $order->save()); Each stage is a class with a handle($passable, Closure $next) method, resolved from the container, or a closure with the same signature. The HTTP kernel builds exactly this with the request as the passable and the router as the final callback. The queue worker builds it with the job as the passable and the job's handle() as the final callback.
Guzzle middleware is built differently. It is a function that takes the next handler and returns a new handler, and a handler takes a PSR-7 request and options and returns a promise. The layering is the same, but code that runs after the handler goes in a then() on the promise:
| Kind | Signature | Attached with |
|---|---|---|
| Request | handle(Request $request, Closure $next), on Pipeline | bootstrap/app.php, ->middleware() on routes, controllers |
| Job | handle(object $job, Closure $next), on Pipeline | The job's middleware() method, or ->through() |
| HTTP client | fn (callable $handler): callable, on Guzzle's HandlerStack | Http::withMiddleware(), Http::globalMiddleware() |
Request middleware
A request middleware receives the Illuminate\Http\Request and a $next, and must return a response, either its own or the one $next produced. Generate one with php artisan make:middleware:
namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Context; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; class AssignRequestId { public function handle(Request $request, Closure $next): Response { $id = $request->header('X-Request-Id') ?: (string) Str::uuid(); Context::add('request_id', $id); $response = $next($request); $response->headers->set('X-Request-Id', $id); return $response; } } Context::add() puts the id on every log line written during the request, and on every job dispatched from it, since Laravel carries context into the job payload. The response header lets a client quote the id back to you when something goes wrong.
Where it gets attached
Request middleware can be declared in four places: on every request, in a group, on a route, or on a controller. The place decides which requests the middleware sees, and together they decide the order it runs in. Since Laravel 11 the first two are configured in bootstrap/app.php.
Global middleware runs before the router has matched a route, so it also sees 404s and requests with no matching route. That also means it runs too early to read the session, the logged-in user or route parameters.
->withMiddleware(function (Middleware $middleware) { $middleware->append(AssignRequestId::class); // after Laravel's defaults $middleware->prepend(BlockBadBots::class); // before them $middleware->remove(ConvertEmptyStringsToNull::class); $middleware->replace(TrustProxies::class, CloudflareTrustProxies::class); }) $middleware->use([...]) replaces Laravel's global list with your own.
A group is a named list of middleware. Laravel defines two: web (cookies, session, CSRF and route model binding) and api (binding, plus throttling if you turn it on). withRouting() wraps routes/web.php in the first and routes/api.php in the second, which is why you never see them in your route files. You can change either group or define your own:
->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [SetLocale::class]); $middleware->api(prepend: [EnsureApiVersion::class]); $middleware->group('partner', [ AuthenticatePartner::class, 'throttle:partner', ]); }) appendToGroup(), prependToGroup(), removeFromGroup() and replaceInGroup() do the same for any group by name.
Routes take middleware by class name or by an alias registered in bootstrap/app.php. Groups of routes pass theirs down to every route inside, and a route adds its own on top:
// bootstrap/app.php $middleware->alias(['tenant' => IdentifyTenant::class]); // routes/web.php Route::middleware(['auth', 'tenant'])->group(function () { Route::get('/invoices', [InvoiceController::class, 'index']); Route::post('/invoices/{invoice}/send', SendInvoice::class) ->middleware('role:billing') ->withoutMiddleware('tenant'); }); Parameters go after a colon, so role:editor,admin calls handle($request, $next, 'editor', 'admin'). withoutMiddleware() takes a middleware back off a route or a group of routes. It works for route and group middleware, including subclasses of the class you name, but not for the global stack.
Laravel 13 added the #[Middleware] attribute, which goes on a controller class or on one of its methods. only and except limit a class-level attribute to some methods, and #[WithoutMiddleware] removes one:
use Illuminate\Routing\Attributes\Controllers\Middleware; use Illuminate\Routing\Attributes\Controllers\WithoutMiddleware; #[Middleware('auth')] #[Middleware('verified', except: ['show'])] class InvoiceController extends Controller { #[Middleware('throttle:exports')] public function export(Invoice $invoice) { // ... } #[WithoutMiddleware('auth')] public function show(Invoice $invoice) { // ... } } The older way, which still works, is to implement HasMiddleware and return the list from a static method:
use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; class InvoiceController extends Controller implements HasMiddleware { public static function middleware(): array { return [ 'auth', new Middleware('verified', except: ['show']), ]; } } The order it runs in
A request goes through two pipelines. The kernel runs the global middleware, then hands the request to the router, which matches a route and runs that route's middleware around the controller. The route's list is put together in four steps:
- Collect. Group middleware comes first, from the outermost group inward, so
webleads for anything inroutes/web.php. Then the route's own->middleware(), then the controller's:HasMiddlewareentries, then attributes, with a parent class's before a child's and the class's before the method's. - Expand. Aliases become class names and group names become their contents.
- Remove. Anything named in
withoutMiddleware()or#[WithoutMiddleware]is dropped, and so is any repeat of a middleware already in the list. - Sort. The list is reordered against the priority list, described below.
The global list is never sorted. It runs exactly as written: prepends, then Laravel's defaults, then appends. On the way back the response passes through every layer in reverse, so the last middleware to run before the controller is the first to see the response.
The priority list holds the middleware that depend on each other, in the order they have to run: HandlePrecognitiveRequests, EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, anything implementing AuthenticatesRequests, ThrottleRequests, ThrottleRequestsWithRedis, AuthenticatesSessions, SubstituteBindings and Authorize. When two of these appear out of order, the later one is moved up in front of the earlier one. Middleware that isn't in the list keeps the position you wrote it in.
For the POST /invoices/{invoice}/send route above, after the global stack, that gives:
EncryptCookies web group AddQueuedCookiesToResponse web group StartSession web group ShareErrorsFromSession web group PreventRequestForgery web group Authenticate 'auth', moved up by the priority list SubstituteBindings web group role:billing the route's own ('tenant' removed by withoutMiddleware) auth was written after the whole web group, but it runs before SubstituteBindings. A guest gets the login redirect and doesn't learn whether invoice 42 exists.
Your own middleware goes into the list when it has to run before or after one of these. A tenant middleware usually has to run before route model binding, so that {invoice} is looked up in the tenant's data:
$middleware->prependToPriorityList( before: SubstituteBindings::class, prepend: IdentifyTenant::class, ); appendToPriorityList(after: ..., append: ...) does the opposite, and priority([...]) replaces the whole list.
A middleware with a terminate($request, $response) method gets called again after the response has been sent to the browser. Route middleware is terminated before global middleware, each in the order it ran rather than in reverse. Each call gets a fresh instance unless you bind the class as a singleton, so state set in handle() is gone by then.
What it is used for
Most request middleware decides who may make the request: authentication, roles and permissions, signed URLs, verified email, API token abilities and IP allow-lists. The next biggest group sets something up for the rest of the request, such as the current tenant, the locale or a request id. The rest either cleans up the request (trusting proxy headers, trimming strings, rejecting oversized bodies) or changes the response, by adding security headers, cache headers and ETags, or by serving a cached copy of the whole page. Throttling, honeypots and idempotency keys for payment endpoints are middleware too.
Laravel's built-in request middleware
A new Laravel 13 app runs these on every request, in this order:
| Global middleware | What it does |
|---|---|
ValidatePathEncoding | Rejects a path that isn't valid UTF-8 with a MalformedUrlException. |
InvokeDeferredCallbacks | Runs the callbacks registered with defer() after the response is sent. |
TrustHosts | Only when enabled with $middleware->trustHosts(). Rejects requests for a Host you don't serve. |
TrustProxies | Reads the client IP, scheme and host from X-Forwarded-* headers set by proxies you trust. |
HandleCors | Answers preflight requests and adds CORS headers from config/cors.php. |
PreventRequestsDuringMaintenance | Serves the 503 page while php artisan down is in effect. |
ValidatePostSize | Rejects a body larger than PHP's post_max_size. |
TrimStrings | Trims whitespace from input, except password fields. |
ConvertEmptyStringsToNull | Turns empty input strings into null. |
The web group adds EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, PreventRequestForgery and SubstituteBindings. The api group has only SubstituteBindings, plus Sanctum's EnsureFrontendRequestsAreStateful after $middleware->statefulApi() and throttle:api after $middleware->throttleApi().
PreventRequestForgery is new in Laravel 13 and replaces ValidateCsrfToken, which remains as a deprecated subclass. It lets a state-changing request through when the browser's Sec-Fetch-Site header says same-origin, and falls back to the CSRF token otherwise. allowSameSite extends that to sibling subdomains, and originOnly drops the token fallback altogether.
These are registered as aliases for use on routes:
| Alias | Class | What it does |
|---|---|---|
auth | Authenticate | Requires a logged-in user, optionally for a given guard: auth:sanctum. |
auth.basic | AuthenticateWithBasicAuth | HTTP Basic authentication against your user provider. |
auth.session | AuthenticateSession | Logs out other sessions when the password changes. |
cache.headers | SetCacheHeaders | Sets Cache-Control and optionally an ETag: cache.headers:public;max_age=3600;etag. |
can | Authorize | Runs a gate or policy ability: can:update,post. |
guest | RedirectIfAuthenticated | Sends logged-in users away from login and register pages. |
password.confirm | RequirePassword | Asks for the password again before a sensitive action. |
precognitive | HandlePrecognitiveRequests | Runs validation only, for live form validation with Precognition. |
signed | ValidateSignature | Rejects a URL whose signature is missing, wrong or expired. |
throttle | ThrottleRequests | Rate limits by a named limiter or inline numbers: throttle:uploads, throttle:60,1. Becomes ThrottleRequestsWithRedis after throttleWithRedis(). |
verified | EnsureEmailIsVerified | Requires a verified email address. |
A few more ship in the framework but aren't registered anywhere, so you attach them by class name. FrameGuard sets X-Frame-Options: SAMEORIGIN. CheckResponseForModifications answers a matching If-None-Match with a 304. AddLinkHeadersForPreloadedAssets turns the assets Vite preloads into Link headers, so the browser can start fetching them before it has parsed the HTML. Laravel 13 also has PrefersJsonResponses, enabled with ->prefersJsonResponses() on the application builder, which treats a request with no Accept header, or only wildcards, as asking for JSON. That suits an API whose clients forget the header and then get an HTML error page back.
Popular request middleware packages
| Package | Middleware it adds |
|---|---|
| spatie/laravel-csp | AddCspHeaders, a Content Security Policy built from preset classes, with nonces. |
| spatie/laravel-responsecache | CacheResponse, DoNotCacheResponse and the newer FlexibleCacheResponse, which store whole responses. |
| spatie/laravel-honeypot | ProtectAgainstSpam, which rejects forms filled in by bots. |
| bepsvpt/secure-headers | SecureHeadersMiddleware, for HSTS, CSP, Permissions-Policy and the rest from one config file. |
| spatie/laravel-http-logger | HttpLogger, which logs incoming requests, by default only the ones that change data. |
| monicahq/laravel-cloudflare | A TrustProxies that keeps Cloudflare's IP ranges up to date, so $request->ip() is the visitor's. |
| akaunting/laravel-firewall | firewall.ip, firewall.sqli, firewall.xss, firewall.all and a dozen more, as a basic application firewall. |
| square1/laravel-idempotency | IdempotencyMiddleware, which replays the stored response for a repeated Idempotency-Key. |
Two of the most installed middleware packages are now in core and shouldn't be added to a new app. fruitcake/laravel-cors is marked abandoned and was replaced by HandleCors in Laravel 9.2, and fideloper/proxy by the framework's TrustProxies in Laravel 9. If an upgrade still has either in composer.json, remove it. We wrote up what responsecache's cache key does with tracking parameters in why our response cache kept missing.
Job middleware
Job middleware wraps one attempt at a queued job. The worker builds a pipeline from the job's middleware with the job's handle() at the centre, so the middleware runs on the worker, every time the job is picked up, retries included. Return the list from a middleware() method, or attach it at dispatch time with through():
use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Queue\Middleware\WithoutOverlapping; class SyncInvoiceToXero implements ShouldQueue { public function __construct(public Invoice $invoice) {} public function middleware(): array { return [ new RateLimited('xero'), (new WithoutOverlapping($this->invoice->account_id))->expireAfter(120), ]; } } // or for one dispatch only SyncInvoiceToXero::dispatch($invoice)->through([new RateLimited('xero')]); Instead of calling $next($job), a job middleware can call $job->release($seconds) to put the job back on the queue for later, $job->fail($e) to send it to the failed jobs table, or $job->delete() to drop it. Writing one takes a class with a handle() method. This one stops calling a vendor for a minute after a call fails:
namespace App\Jobs\Middleware; use Closure; use Illuminate\Support\Facades\Cache; use Throwable; class PauseWhenVendorIsDown { public function __construct(private string $vendor) {} public function handle(object $job, Closure $next): void { if (Cache::has("vendor-down:{$this->vendor}")) { $job->release(60); return; } try { $next($job); } catch (Throwable $e) { Cache::put("vendor-down:{$this->vendor}", true, 60); throw $e; } } } It also runs on listeners, mail and notifications
Anything Laravel queues goes through the same pipeline. A queued listener can define middleware(OrderShipped $event), a queued mailable middleware(), and a queued notification middleware(object $notifiable, string $channel), which is called once per channel. That last one is useful for limiting only the channel that has a limit:
public function middleware(object $notifiable, string $channel): array { return match ($channel) { 'vonage' => [new RateLimited('sms')], default => [], }; } What it is used for
The common case is someone else's limit. A vendor allows 60 calls a minute, so every job that calls it shares a limiter. Close behind is mutual exclusion: two jobs updating the same account must not interleave, so they take a lock keyed by the account. Job middleware also backs off from a failing dependency, for example pausing for ten minutes after ten exceptions in a row rather than spending every retry. It drops work that no longer matters, such as a job whose order or batch was cancelled. And it sets up context before the job runs, such as the tenant database or the locale.
Laravel's built-in job middleware
All of these live in Illuminate\Queue\Middleware.
| Middleware | What it does | Options |
|---|---|---|
RateLimited | Applies a limiter defined with RateLimiter::for() and releases the job when the limit is reached. | releaseAfter(), dontRelease() |
RateLimitedWithRedis | The same, using Redis directly rather than the cache store. | As above, plus connection() |
WithoutOverlapping | Takes a cache lock on a key so only one job with that key runs at a time. | releaseAfter(), dontRelease(), expireAfter(), shared() |
ThrottlesExceptions | After a number of exceptions, releases the job for a cool-down instead of running it. | backoff(), by(), byJob(), when(), deleteWhen(), failWhen(), report() |
ThrottlesExceptionsWithRedis | The same, counting in Redis. | As above, plus connection() |
Skip | Deletes the job without running it when a condition holds. | Skip::when(), Skip::unless() |
Release | New in Laravel 13. Puts the job back on the queue without running it when a condition holds. | Release::when($condition, $seconds), Release::unless() |
FailOnException | Fails the job straight away, without retries, on the exceptions you list. | An array of classes, or a closure |
SkipIfBatchCancelled | Returns without running the job when its batch has been cancelled. | None |
Skipped jobs count as done, and releases use up tries
Not calling $next is a success. When the pipeline returns without the job being released or failed, the worker deletes it as completed. That is what Skip and SkipIfBatchCancelled do, and also what RateLimited and WithoutOverlapping do after dontRelease(). In a chain, a skipped link, or one whose middleware simply returns, is treated as done and the next link is dispatched. If the rest of the chain depends on the skipped work, fail the job instead. A ShouldBeUnique link works the other way round, since a held lock skips it and silently ends the rest of the chain.
Every release spends an attempt. The worker counts the attempt when it picks the job up, before any middleware runs. A job that keeps meeting a held WithoutOverlapping lock or an exhausted limiter is released again and again, and can reach its $tries and fail without handle() having run once. Give limited jobs a retryUntil() deadline rather than a small number of tries. We cover the limiter options in depth in rate-limited APIs and Laravel queues, the locking ones in ShouldBeUnique, WithoutOverlapping and their gotchas, and how releases and retryUntil() bend a job's backoff in Laravel job retries and backoff with unique locks.
Popular job middleware packages
The core classes cover most needs, so there are few job middleware packages with real usage.
| Package | What it adds |
|---|---|
| spatie/laravel-rate-limited-job-middleware | A Redis-backed RateLimited with a fluent allow(30)->everySeconds(60) API. It predates core's RateLimited, which now overlaps with it. |
| harris21/laravel-fuse | CircuitBreakerMiddleware, a circuit breaker with closed, open and half-open states. While open it releases jobs rather than failing them, and its failure classifier keeps 429s and auth errors from tripping it. Still pre-1.0. |
Multi-tenancy packages make jobs tenant-aware too, but not through job middleware. spatie/laravel-multitenancy uses a TenantAware interface and stancl/tenancy a queue bootstrapper, so the tenant is restored before your middleware runs.
We maintain a small one ourselves. Unique Job Middleware checks a ShouldBeUnique job's lock again when a worker picks it up, because Laravel only checks it at dispatch and chains, batches and retries go around that check.
HTTP client middleware
Laravel's Http facade builds a Guzzle client for each request, and lets you add Guzzle middleware to it. There are three ways in, each available per request or for the whole application:
use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; // change the outgoing request Http::withRequestMiddleware( fn (RequestInterface $request) => $request->withHeader('X-Trace-Id', Context::get('request_id')) )->get('https://api.example.com/orders'); // look at or change the response Http::globalResponseMiddleware(function (ResponseInterface $response) { Log::debug('vendor responded', ['status' => $response->getStatusCode()]); return $response; }); // a full Guzzle middleware, which sees the request, the response and the failure Http::globalMiddleware(new LogOutgoingRequests); The first two are shortcuts for Guzzle's Middleware::mapRequest() and mapResponse(). The third takes any Guzzle middleware, which is what you need for anything that has to see a request that never got a response, such as a timeout. A full one looks like this:
class SignWithHmac { public function<...
