Skyline

Laravel Middleware: Requests, Queued Jobs and the HTTP Client

· 22 min read · Boring Observability

Verified against Laravel 13.35 · Guzzle 7.15 · Livewire 4.4 · Filament 5.10 · laravel/ai 1.2

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 RateLimited works on a queued notification exactly as it does on a job.
  • A job middleware that doesn't call $next counts as a success. Skip, a dontRelease() 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 with Http::fake().
  • On a Livewire page, route middleware mostly runs once. Component actions only re-run middleware on Livewire's persistent list, which includes auth and can but not verified, throttle or your own classes. Filament's isPersistent flag 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.

On every request

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.

In a group

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.

On a route or a route group

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.

On a controller

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:

  1. Collect. Group middleware comes first, from the outermost group inward, so web leads for anything in routes/web.php. Then the route's own ->middleware(), then the controller's: HasMiddleware entries, then attributes, with a parent class's before a child's and the class's before the method's.
  2. Expand. Aliases become class names and group names become their contents.
  3. Remove. Anything named in withoutMiddleware() or #[WithoutMiddleware] is dropped, and so is any repeat of a middleware already in the list.
  4. 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
ValidatePathEncodingRejects a path that isn't valid UTF-8 with a MalformedUrlException.
InvokeDeferredCallbacksRuns the callbacks registered with defer() after the response is sent.
TrustHostsOnly when enabled with $middleware->trustHosts(). Rejects requests for a Host you don't serve.
TrustProxiesReads the client IP, scheme and host from X-Forwarded-* headers set by proxies you trust.
HandleCorsAnswers preflight requests and adds CORS headers from config/cors.php.
PreventRequestsDuringMaintenanceServes the 503 page while php artisan down is in effect.
ValidatePostSizeRejects a body larger than PHP's post_max_size.
TrimStringsTrims whitespace from input, except password fields.
ConvertEmptyStringsToNullTurns 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
authAuthenticateRequires a logged-in user, optionally for a given guard: auth:sanctum.
auth.basicAuthenticateWithBasicAuthHTTP Basic authentication against your user provider.
auth.sessionAuthenticateSessionLogs out other sessions when the password changes.
cache.headersSetCacheHeadersSets Cache-Control and optionally an ETag: cache.headers:public;max_age=3600;etag.
canAuthorizeRuns a gate or policy ability: can:update,post.
guestRedirectIfAuthenticatedSends logged-in users away from login and register pages.
password.confirmRequirePasswordAsks for the password again before a sensitive action.
precognitiveHandlePrecognitiveRequestsRuns validation only, for live form validation with Precognition.
signedValidateSignatureRejects a URL whose signature is missing, wrong or expired.
throttleThrottleRequestsRate limits by a named limiter or inline numbers: throttle:uploads, throttle:60,1. Becomes ThrottleRequestsWithRedis after throttleWithRedis().
verifiedEnsureEmailIsVerifiedRequires 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-cspAddCspHeaders, a Content Security Policy built from preset classes, with nonces.
spatie/laravel-responsecacheCacheResponse, DoNotCacheResponse and the newer FlexibleCacheResponse, which store whole responses.
spatie/laravel-honeypotProtectAgainstSpam, which rejects forms filled in by bots.
bepsvpt/secure-headersSecureHeadersMiddleware, for HSTS, CSP, Permissions-Policy and the rest from one config file.
spatie/laravel-http-loggerHttpLogger, which logs incoming requests, by default only the ones that change data.
monicahq/laravel-cloudflareA TrustProxies that keeps Cloudflare's IP ranges up to date, so $request->ip() is the visitor's.
akaunting/laravel-firewallfirewall.ip, firewall.sqli, firewall.xss, firewall.all and a dozen more, as a basic application firewall.
square1/laravel-idempotencyIdempotencyMiddleware, 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-middlewareA Redis-backed RateLimited with a fluent allow(30)->everySeconds(60) API. It predates core's RateLimited, which now overlaps with it.
harris21/laravel-fuseCircuitBreakerMiddleware, 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 __construct(private string $secret) {}

    public function __invoke(callable $handler): callable
    {
        return function (RequestInterface $request, array $options) use ($handler) {
            $signature = hash_hmac('sha256', (string) $request->getBody(), $this->secret);

            return $handler($request->withHeader('X-Signature', $signature), $options)
                ->then(function (ResponseInterface $response) {
                    // runs once the response has arrived
                    return $response;
                });
        };
    }
}

What it is used for#

Authentication is the most common job: adding a bearer token and refreshing it when it expires, or signing the request with OAuth 1, HMAC or AWS SigV4. Logging and timing each call is next, often with a trace id passed on to the vendor. Then there is retrying on a 503 while honouring Retry-After, limiting how fast you call an API, and stopping calls to a vendor that keeps failing. Caching middleware follows the vendor's Cache-Control so an unchanged resource isn't fetched twice, and in tests a history middleware records each request to assert on.

Built-in HTTP client middleware#

Laravel doesn't ship Guzzle middleware classes of its own. What it ships is the registration API above, plus a retry() method that is implemented outside the stack: Http::retry(3, 100) sends the request through the whole stack again for each attempt, so your middleware sees every one. Guzzle itself has these, on GuzzleHttp\Middleware:

Middleware What it does
httpErrors()Throws on 4xx and 5xx responses. In the default stack, but Laravel's client turns it off with http_errors => false and leaves throwing to ->throw().
redirect()Follows redirects according to allow_redirects. In the default stack.
cookies()Sends and stores cookies through a cookie jar. In the default stack.
prepareBody()Sets Content-Length and Content-Type for the body. In the default stack.
auth()Guzzle 8 only. Handles authentication challenges, and sits in the default stack after redirect().
retry($decider, $delay)Retries when your decider callback says so, with your delay.
log($logger, $formatter)Logs requests and responses through a PSR-3 logger and a MessageFormatter.
history($container)Appends each request, response and error to an array.
tap($before, $after)Calls a function before and after each request without changing it.
mapRequest(), mapResponse()Transform the request or the response with a function.

Laravel pushes its own handlers after yours, and the one that serves Http::fake() responses is the innermost. So your middleware runs for faked requests too, and a test can assert that the signature header was added without a real server.

Http::globalMiddleware() only reaches requests made through Laravel's client. SDKs that create their own GuzzleHttp\Client, the AWS SDK among them and with it every S3 call, never see it. For those, push the same middleware onto a HandlerStack and give the SDK a client built on it. Instrumenting Guzzle in Laravel walks through that, along with why a logging middleware should record from on_stats rather than from the response.

Popular HTTP client middleware packages#

Most of these are plain Guzzle middleware, written for any PHP project, and plug into Laravel through Http::withMiddleware() or globalMiddleware(). Laravel 13 accepts Guzzle 7 or 8 and Laravel 12 only Guzzle 7. Some packages' latest major needs Guzzle 8, so on Laravel 12 you stay on their previous one.

Package What it adds
kevinrob/guzzle-cache-middlewareCacheMiddleware, HTTP caching that follows Cache-Control, with a LaravelCacheStorage adapter for your cache store.
guzzlehttp/oauth-subscriberOauth1, OAuth 1.0 request signing. The current release needs Guzzle 8.1.
caseyamcl/guzzle_retry_middlewareGuzzleRetryMiddleware, retries on 429 and 503 that honour Retry-After. Version 3 needs Guzzle 8, so use 2.x on Laravel 12.
kamermans/guzzle-oauth2-subscriberOAuth2Middleware, fetching and refreshing OAuth 2 tokens for client-credentials and other grants.
spatie/guzzle-rate-limiter-middlewareRateLimiterMiddleware::perSecond(3) or perMinute(), which delays calls to stay under a limit.
ackintosh/ganeshaGuzzleMiddleware, a circuit breaker with Redis, Memcached and APCu storage.
bilfeldt/laravel-http-client-loggerAn Http::log() macro and a LoggingMiddleware for Laravel's client.

Two logging middlewares that still appear in older answers, gmponos/guzzle_logger and rtheunissen/guzzle-log-middleware, haven't had a release since 2022 and 2023. For metrics rather than log lines, our free httptheus is a Guzzle middleware that exports request durations and failures by host to Prometheus, and Requizon uses the same hook to keep a browsable record of every call.

Livewire and Filament middleware#

Livewire doesn't add a new kind of middleware. It uses ordinary request middleware, but changes where it runs. A Livewire page is rendered by a normal route, so that route's middleware runs on the first load as usual. After that, every click, form submit and wire:model update is a POST to one shared endpoint, /livewire-{hash}/update in Livewire 4, which has only the web group on it. The page's own route middleware doesn't run on those requests unless it is persistent.

What persistent middleware is#

Persistent middleware is route middleware that Livewire runs again on each component update, as if the update were a request to the page's original route. Livewire saves the page's path and method in every component's snapshot. On an update it builds a copy of the request with that path, looks up the middleware on the matching route, and runs the ones that are also on its persistent list.

The default list holds Authenticate (the auth alias), Authorize (can), SubstituteBindings, AuthenticateWithBasicAuth, Sanctum's EnsureFrontendRequestsAreStateful, Jetstream's AuthenticateSession, and the App\Http\Middleware versions of Authenticate and RedirectIfAuthenticated. So a user who is logged out, or whose can: ability is revoked, while a page is open is stopped at their next click. Anything else on the route, such as verified, password.confirm, throttle or your own middleware, protects the page load and none of the actions taken on the page. Add a class to the list in a service provider:

use App\Http\Middleware\EnsureTeamIsActive;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Livewire\Livewire;

public function boot(): void
{
    Livewire::addPersistentMiddleware([
        EnsureEmailIsVerified::class,
        EnsureTeamIsActive::class,
    ]);
}

For middleware that should run on every update whatever page it came from, Livewire lets you replace the update route with Livewire::setUpdateRoute() and put middleware on it. That middleware runs before any component is restored, which is why tenancy packages use it: the tenant database has to be connected before Livewire loads the component's models.

Livewire::setUpdateRoute(function ($handle, $path) {
    return Route::post($path, $handle)
        ->middleware(['web', InitializeTenancyByDomain::class]);
});

Where persistent middleware behaves differently#

None of these is a bug, but each one can leave a check out without any error.

  • It has to be on the route and on the list. Adding a class to the list does nothing for pages whose route doesn't have it, and a class on the route does nothing on updates until it is on the list.
  • The list matches class names. Livewire compares it with the route's middleware after aliases and groups are expanded, so a closure or a group name never matches. Parameters come from the route, so can:update,post works once Authorize is listed.
  • Only the "before" half counts. The middleware runs on a copy of the request, after the real web group, and the pipeline ends in an empty response that is thrown away. Headers or cookies set after $next never reach the browser. A redirect it returns is turned into an abort() with that response.
  • Livewire::test() skips it. Persistent middleware only runs on requests to the real update endpoint, so a component test passes whether or not a class is on the list. A browser test or a feature test that posts to the endpoint will catch a missing entry.
  • The endpoint moved in Livewire 4. It changed from /livewire/update to /livewire-{hash}/update, with the hash taken from your APP_KEY. CSRF exceptions, firewall rules and maintenance-mode exceptions written for livewire/* stop matching after the upgrade.
  • Locale middleware doesn't need to be listed. Livewire saves the app locale in the snapshot and restores it on each update.

Filament panels#

Filament panels are Livewire pages, and the panel provider sets their middleware in three lists. Each takes an isPersistent argument, which passes the classes on to Livewire's list:

public function panel(Panel $panel): Panel
{
    return $panel
        ->middleware([/* the panel's own stack, in place of the web group */])
        ->authMiddleware([Authenticate::class])
        ->tenantMiddleware([ApplyTenantScopes::class], isPersistent: true);
}

Filament's docs say it directly: by default, panel middleware runs when the page is first loaded and not on later Livewire requests. Their tenancy guide uses a middleware that adds global scopes for the current tenant, and that one has to be persistent. Without the flag the scopes are missing on every action taken after the page loads, and a table filter or a bulk action can query other tenants' rows.

Two other details catch people. A panel builds its own stack rather than using your app's web group, so middleware you append to web skips panel page loads, but it does run on the panel's Livewire updates, which go through web. And Filament doesn't rely on middleware to keep users out of pages and resources. It runs canAccess() again from a Livewire hook on every update, so removing a user's access to a page takes effect on their next click.

Livewire and Filament's built-in middleware#

Livewire's own middleware is small. RequireLivewireHeaders sits on the update route and returns a 404 to any request without the X-Livewire header. DisableBackButtonCacheMiddleware is pushed onto the global stack and adds no-store headers when Livewire's back-button cache feature is active for the page, so pressing Back reloads it instead of showing a stale copy. Livewire also turns off TrimStrings and ConvertEmptyStringsToNull for its own requests, and its file upload route is throttled with throttle:60,1 by default.

Filament middleware What it does Persistent
AuthenticateRequires a logged-in user who passes canAccessPanel().Yes
AuthenticateSessionFilament's version of Laravel's AuthenticateSession.Yes
SetUpPanelMakes the panel the current one. Always first in the panel stack.Yes
IdentifyTenantResolves the tenant from the URL and returns a 404 unless the user passes canAccessTenant().Yes
DispatchServingFilamentEventFires the ServingFilament event, which plugins listen to.Yes
DisableBladeIconComponentsTurns off Blade Icons' components inside the panel.Yes
Email verificationAdded per page when the panel has ->emailVerification().No
EnsureMultiFactorAuthenticationIsEnabledSends users without MFA to set it up, when the panel requires it.No

Popular Livewire and Filament packages with middleware#

There are no widely used Livewire middleware packages. The most installed rate-limiting package, danharrin/livewire-rate-limiting, is a trait you call from a component action, because a throttle route middleware can't tell one Livewire action from another. Filament's own login and password-reset pages use it. Filament plugins do ship middleware, and whether they register it as persistent varies:

Package Middleware it adds Persistent
bezhansalleh/filament-shieldSyncShieldTenant, which sets spatie/laravel-permission's team id to the current tenant.Yes
jeffgreco13/filament-breezyMustTwoFactor, which sends users to the 2FA challenge, added through authMiddleware().No
bezhansalleh/filament-language-switchSwitchLanguageLocale, which applies the language the user picked.No, which is fine because Livewire keeps the locale
stephenjude/filament-two-factor-authenticationTwoFactorChallenge and ForceTwoFactorSetup.No

The two 2FA plugins check for a completed challenge on page loads only. From reading their source, a page that is already open keeps accepting actions after the 2FA session expires, until the user navigates. If 2FA guards something sensitive, add the plugin's middleware with Livewire::addPersistentMiddleware(). Inertia has none of this to think about: every Inertia visit is a normal request to its route, so the route's middleware always runs.

Laravel AI SDK agent middleware#

An agent in Laravel's AI SDK (laravel/ai) runs in steps. Each step sends the conversation to the model, and if the model asks for tools, the SDK runs them and starts another step with the results. Agent middleware wraps each step. It receives a PendingStep, which holds the model, instructions, messages and tools about to be sent, and a $next that calls the model.

This changed in v1.0.0, released on 23 September 2026. Before that, agent middleware wrapped the whole prompt once and received an AgentPrompt, so tutorials and packages written earlier in 2026 use a signature that no longer works. An agent declares its middleware by implementing HasMiddleware:

use App\Ai\Middleware\SendOnlyAfterDraft;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasMiddleware;
use Laravel\Ai\Promptable;

class SupportAgent implements Agent, HasMiddleware
{
    use Promptable;

    public function middleware(): array
    {
        return [new SendOnlyAfterDraft];
    }
}

Generate the middleware with php artisan make:agent-middleware. This one keeps the agent from emailing a customer until it has drafted the email in an earlier step:

namespace App\Ai\Middleware;

use Closure;
use Laravel\Ai\PendingStep;

class SendOnlyAfterDraft
{
    public function handle(PendingStep $step, Closure $next)
    {
        $drafted = collect($step->steps)
            ->flatMap(fn ($done) => $done->toolCalls)
            ->contains(fn ($call) => $call->name === 'DraftEmail');

        if (! $drafted) {
            $step = $step->withoutTools('SendEmail');
        }

        return $next($step);
    }
}

PendingStep is immutable. withModel(), withInstructions(), withMessages(), withTools(), onlyTools(), withoutTools(), withToolChoice(), withMaxTokens() and withProviderOptions() each return a changed copy, and the change applies to this step only. The step also carries the steps already completed and their combined token usage. Code that should run after the model answers goes in $next($step)->then(), which receives the StepResponse before any tools run, for streamed responses too. A middleware can also return a StepResponse of its own instead of calling $next, and the model isn't called for that step.

What it is used for#

Logging is the usual first one: the model, the prompt and the tokens each step used. The Laravel docs show two more. One summarises the middle of a conversation once it passes 40 messages, so a long tool-calling run stays inside the context window; the shortened history is sent for that step only and the stored conversation keeps everything. The other drops an expensive search tool after the first step. Beyond the docs, middleware can send later steps to a cheaper model with withModel(), answer repeated prompts from a cache, and act as a guardrail that rejects prompt injection or removes personal data before the messages leave your server.

Built-in agent middleware#

The SDK doesn't ship agent middleware for you to attach. The one class it has, RememberConversation, stores the conversation for agents that use RemembersConversations, and the SDK adds it itself in a separate pipeline around the whole prompt.

Agent middleware packages#

The SDK is new and so is its package ecosystem. The one package written for the per-step API is promptphp/intercept (v1.0.0 from 27 September 2026). It ships PromptInjectionGuard, PIIRedactor and ToolApprovalGuard. padosoft/laravel-ai-guardrails has input and output guardrail middleware but only installs with the SDK's 0.x releases. Outside the SDK, Prism has no middleware, and NeuronAI has its own agent and workflow middleware built from before() and after() hooks rather than $next.

Other places Laravel uses middleware#

Outside the kinds above, middleware turns up in a handful of smaller places:

Where What it looks like
The command busBus::pipeThrough([...]) registers pipes that wrap every command dispatched with dispatchSync() and every queued job when it runs, inside the job's own middleware. It isn't in the docs, but it's the one place to add something to every job at once. Each call replaces the list, so two packages that both use it overwrite each other.
Pipeline itselfThe facade is available for your own multi-step processes, such as checkout or import steps, with send(), through(), then() and withinTransaction().
FolioPage middleware, declared in the page with middleware(['auth']) or for a path with Folio::path()->middleware().
Laravel MCPOrdinary route middleware on the server route, for example Mcp::web('/mcp', WeatherServer::class)->middleware('throttle:mcp'). There is no per-tool middleware; a tool's shouldRegister() method decides whether a request can see it at all.
Kafka consumersmateusjunges/laravel-kafka takes ->withMiddleware(fn ($message, $next) => $next($message)) on a consumer, wrapping each message before your handler.
BroadcastingRoute middleware on the channel authorisation endpoint, set in withBroadcasting(), for example auth:sanctum for an API.

Some places have hooks that look like middleware but can't wrap or stop anything. Queue::before() and Queue::after() are event listeners, scheduled tasks have before(), after() and when(), and Eloquent has observers and scopes. If you need to stop something from running, use one of the pipelines above.

A job dropped by middleware leaves no failed job and no exception, so it is hard to notice. In Skyline, the Locks & Limits screen counts how often each limiter released or dropped a job. Its log also records jobs dropped by dontRelease() and chain links skipped by a unique lock.

Frequently asked questions

What kinds of middleware does Laravel have?

Three main kinds. HTTP middleware wraps incoming requests and is configured in bootstrap/app.php, on routes or on controllers. Job middleware wraps each attempt at a queued job, and also runs for queued listeners, mailables, notifications and broadcast events. HTTP client middleware is Guzzle middleware added with Http::withMiddleware() or Http::globalMiddleware(). There are smaller ones too: Bus::pipeThrough() pipes for the command bus, page middleware in Folio, persistent middleware in Livewire and Filament, and agent middleware in the Laravel AI SDK, which wraps each step an agent sends to the model.

What is the difference between global, group and route middleware in Laravel?

Global middleware runs on every request, including ones that match no route, and is added with $middleware->append() or prepend(). Group middleware runs on every route in a group such as web or api, and is added with $middleware->web() or api(). Route middleware runs only where you attach it with ->middleware(), usually by an alias. withoutMiddleware() can remove route and group middleware but not global middleware.

What job middleware does Laravel include?

Nine classes in Illuminate\Queue\Middleware: RateLimited and RateLimitedWithRedis, WithoutOverlapping, ThrottlesExceptions and ThrottlesExceptionsWithRedis, Skip, Release (new in Laravel 13), FailOnException and SkipIfBatchCancelled. Return them from a job's middleware() method or attach them at dispatch time with ->through().

What happens when a Laravel job middleware does not call $next?

If the middleware does not release or fail the job, the worker deletes it as completed. Skip, SkipIfBatchCancelled and RateLimited or WithoutOverlapping with dontRelease() all work this way. In a chain, the next link is still dispatched, so a skipped job does not stop the rest of the chain.

Does Http::globalMiddleware() apply to every outgoing request?

Only to requests made through Laravel's Http facade, including Http::pool() and faked requests under Http::fake(). SDKs that build their own GuzzleHttp\Client, such as the AWS SDK behind the S3 filesystem driver, never pass through it. Push the same middleware onto a Guzzle HandlerStack and give the SDK a client built on that stack.

What is persistent middleware in Livewire?

Route middleware that Livewire runs again on component update requests. After a Livewire page loads, every action posts to one shared update endpoint, so the page route's middleware does not run on it. Livewire re-runs the middleware that is both on the original route and on its persistent list, which by default includes auth, can and SubstituteBindings but not verified, password.confirm, throttle or custom middleware. Add classes with Livewire::addPersistentMiddleware(), or in Filament pass isPersistent: true to middleware(), authMiddleware() or tenantMiddleware().

How does middleware work in the Laravel AI SDK?

An agent that implements HasMiddleware returns middleware classes with a handle(PendingStep $step, Closure $next) method. Since laravel/ai 1.0 the middleware runs once for each generation step, not once per prompt, and can change the model, messages or tools for that step, run code after the model answers with ->then(), or return its own StepResponse to skip the model call. Generate one with php artisan make:agent-middleware.