Writing Laravel Middleware That Actually Does Something

/ Content

I've reviewed a lot of Laravel codebases over the years, and middleware is consistently the most underused feature. Most apps have the default auth middleware, maybe a custom IsAdmin check, and that's it. But middleware is one of the most powerful tools in the framework. It's your chance to intercept every request and response flowing through your application.

Let's write some middleware that actually earns its keep.

Before vs. After: It Matters More Than You Think

The first thing most tutorials gloss over is the distinction between before and after middleware. It's not just academic, it changes what you can do.

Before middleware runs before the controller. After middleware runs after. Simple enough, but the implications are huge. Here's the skeleton:

class TrackResponseTime
{
    public function handle(Request $request, Closure $next): Response
    {
        // BEFORE: runs before the controller
        $start = microtime(true);

        $response = $next($request);

        // AFTER: runs after the controller
        $duration = round((microtime(true) - $start) * 1000, 2);
        $response->headers->set('X-Response-Time', "{$duration}ms");

        return $response;
    }
}

This is an "around" middleware: it does work on both sides. That $next($request) call is the dividing line. Everything before it happens on the way in; everything after happens on the way out. I've had this running in production for years. The X-Response-Time header is super useful for debugging slow endpoints without opening your APM tool.

Request/Response Logging

This is one of the first custom middleware I write on any new project. Not every request (that would be insane) but for specific route groups where you need an audit trail.

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

        Log::channel('api')->info('API Request', [
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'status' => $response->getStatusCode(),
            'user_id' => $request->user()?->id,
            'ip' => $request->ip(),
            'duration_ms' => defined('LARAVEL_START')
                ? round((microtime(true) - LARAVEL_START) * 1000)
                : null,
        ]);

        return $response;
    }
}

Notice I'm logging after the response so I can capture the status code too. I deliberately don't log request bodies here because they might contain passwords or tokens. If you need body logging, sanitize it first. I learned that one the hard way when a client's password ended up in a log aggregator. Not a great Monday morning.

Passing Parameters to Middleware

This is the feature that unlocks middleware's real potential, and I'm constantly surprised how many developers don't know about it. You can pass arguments to middleware, which means you can write one piece of middleware that handles multiple scenarios.

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

        if (! $user || ! in_array($user->role->value, $roles)) {
            abort(403, 'Insufficient permissions.');
        }

        return $next($request);
    }
}

Register it with an alias in your bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role' => EnsureRole::class,
    ]);
})

Then use it in your routes:

Route::middleware('role:admin,editor')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
});

The colon separates the middleware name from its parameters, and commas separate multiple parameters. Those parameters land as extra arguments after $next in your handle method. The variadic ...$roles syntax is perfect when you don't know how many values you'll receive.

Content Negotiation

If you're building an API that needs to serve multiple response formats, middleware is the right place to handle it. I worked on a project where the same endpoints needed to return JSON for the SPA and XML for a legacy integration. Instead of littering every controller with format checks:

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

        if (! $response instanceof JsonResponse) {
            return $response;
        }

        $acceptable = $request->getAcceptableContentTypes();

        if (in_array('application/xml', $acceptable) && ! in_array('application/json', $acceptable)) {
            $data = $response->getData(assoc: true);

            return response(
                $this->arrayToXml($data),
                $response->getStatusCode(),
                ['Content-Type' => 'application/xml']
            );
        }

        return $response;
    }

    private function arrayToXml(array $data, string $root = 'response'): string
    {
        // Your XML conversion logic here
    }
}

Controllers stay clean. They always return JSON, and the middleware handles the negotiation.

Middleware Groups and Ordering

Something that tripped me up early on: middleware order matters. A lot. If your logging middleware runs before your auth middleware, you won't have a $request->user() to log. Think about the pipeline, each middleware wraps the next one like layers of an onion.

In bootstrap/app.php, you can define groups and control ordering:

->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('api-v2', [
        EnsureJsonResponse::class,
        LogApiRequests::class,
        TrackResponseTime::class,
    ]);
})

Then apply the whole group to a route:

Route::middleware('api-v2')->prefix('api/v2')->group(function () {
    // These routes get all three middleware
});

I like to think of groups as "profiles" for different parts of your application. Your public marketing pages need different middleware than your authenticated API endpoints. Groups make that clean and explicit.

Terminable Middleware

Here's a lesser-known one: you can add a terminate method to your middleware that runs after the response has been sent to the browser. This is perfect for expensive operations that don't need to delay the response.

class TrackAnalytics
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        Analytics::track([
            'url' => $request->fullUrl(),
            'status' => $response->getStatusCode(),
            'user_id' => $request->user()?->id,
        ]);
    }
}

The terminate method fires after the response is already on its way to the client. The user doesn't wait for your analytics to finish recording. I use this pattern for anything that's "fire and forget" but still needs access to both the request and response objects.

So Yeah, Use Middleware More

Middleware isn't just a gatekeeping mechanism. It's a pipeline that can transform, log, measure, and adapt every request flowing through your application. Response timing, audit logging, parameterized roles, content negotiation, terminable work... these are things I reach for on almost every project.

Next time you're about to add logic to a base controller or a service provider, ask yourself if middleware would be a better fit. If it's cross-cutting and request-scoped, the answer is almost always yes.