Middleware in PHP: How Request/Response Wrapping Actually Works

Middleware wraps the request/response cycle to handle cross-cutting concerns — authentication, permission checks, logging, rate limiting — in one consistent place, rather than re-implementing them in every controller that needs them. What "middleware" actually means under the hood differs meaningfully between frameworks, though. This article breaks down how Laravel, Symfony, and our own custom framework each implement it, then builds a minimal middleware pipeline from scratch using a real permission-check example.

 

How Laravel Does It: A True Middleware Pipeline

Laravel implements middleware as a literal pipeline, using the decorator pattern: each middleware receives the request and a $next closure, does something before calling $next($request), optionally does something after, and returns the response. Middleware is registered either globally (runs on every request) or per-route:

```php
class CheckAge
{
   public function handle(Request $request, Closure $next)
   {
       if ($request->user()->age < 18) {
           return redirect('home');
       }

 

       return $next($request);
   }
}

Route::get('/profile', function () {
   // ...
})->middleware(['auth', 'check.age']);
```
The order middleware runs in is simply the order it's listed in — first in the array, first to run, wrapping every subsequent layer around the controller like layers of an onion.

 

How Symfony Does It: Events, Not Middleware

Symfony doesn't have middleware in the same sense at all — it uses the observer pattern instead, dispatching a sequence of kernel events (kernel.request, kernel.controller, kernel.response, among others) that listeners can hook into:


```php
class CheckAgeSubscriber implements EventSubscriberInterface
{
   public static function getSubscribedEvents(): array
   {
       return [KernelEvents::REQUEST => 'onKernelRequest'];
   }

 

   public function onKernelRequest(RequestEvent $event): void
   {
       $request = $event->getRequest();

       if ($request->getUser()?->getAge() < 18) {
           $event->setResponse(new RedirectResponse('/home'));
       }
   }
}
```
 

Rather than an ordered array, execution order is controlled by a numeric priority on each listener — higher priority runs first. And critically, if a listener sets a response on the event, propagation stops: lower-priority listeners simply never run. It's a fundamentally different mental model from Laravel's nested pipeline, even though both accomplish the same practical goal.

 

How Our Framework Does It

Our own framework follows Laravel's model more closely than Symfony's — an explicit middleware class checked before a controller action runs, rather than an event bus. A real example from production: gating an admin action behind a permission check.


```php
class Permission
{
   public function handle(Request $request, string $permission)
   {
       if (! Session::user()?->can($permission)) {
           throw new ForbiddenException();
       }
   }
}
```
This is deliberately simpler than Laravel's version — no $next closure, since our pipeline structure calls middleware sequentially and short-circuits on an exception rather than requiring each middleware to explicitly forward the chain. It's a smaller surface area than a full pipeline implementation, traded off against being less flexible for middleware that needs to inspect or modify the response on the way back out.

 

Building a Minimal Pipeline From Scratch

To see exactly how Laravel-style middleware chaining works mechanically, here's a minimal version built from first principles:


```php
class MiddlewarePipeline
{
   private array $middleware = [];

 

   public function through(array $middleware): self
   {
       $this->middleware = $middleware;
       return $this;
   }

   public function then(callable $destination)
   {
       $pipeline = array_reduce(
           array_reverse($this->middleware),
           function ($next, $middleware) {
               return function ($request) use ($middleware, $next) {
                   return (new $middleware())->handle($request, $next);
               };
           },
           $destination
       );

       return $pipeline;
   }
}

$pipeline = (new MiddlewarePipeline())
   ->through([AuthMiddleware::class, CheckAgeMiddleware::class])
   ->then(function ($request) {
       return $controller->handle($request);
   });

$response = $pipeline($request);
```
 

The mechanism worth understanding here is the array_reduce call: it builds the pipeline from the inside out, wrapping each middleware around the one before it, so that calling the final composed function runs the first middleware, which calls $next (the second middleware), which calls $next again, and so on until the actual controller runs at the center. This is the exact same nested-closure mechanism Laravel's own Illuminate\Pipeline component uses internally.

 

Which Approach Is Actually Better?

Neither model is strictly superior — they trade off differently. A true middleware pipeline (Laravel, our framework) is easier to reason about linearly: read the array, that's the exact order things run in, and each middleware fully controls whether and how the chain continues. An event-driven model (Symfony) is more flexible for large applications with many independent, decoupled concerns hooking into the same lifecycle point without needing to know about each other, but the priority-based ordering can be harder to trace by just reading route definitions — you often have to actually query which listeners are registered and at what priority to know what will run.

 

Frequently Asked Questions

Can middleware modify the response, not just the request?
Yes — in a true pipeline (Laravel-style), a middleware can inspect and modify the response returned by $next($request) before returning it further up the chain, since it's not just calling forward, it receives the result back. Our simplified version above, by contrast, only runs before the controller and doesn't wrap the response on the way out.


Why doesn't Symfony just add real middleware?
Symfony's architecture is deliberately built around explicit, prioritized event listeners rather than nested pipelines — it's a different design philosophy, not a missing feature. PSR-15 middleware bundles exist as an add-on for teams that specifically want that pattern inside a Symfony application.


Should custom frameworks build a full pipeline like Laravel's, or a simpler version?
It depends on whether middleware in your application ever needs to touch the outgoing response. If every use case is purely a request-time gate (permission checks, rate limiting rejections), a simpler sequential-and-short-circuit model is genuinely less code for the same practical outcome — the full pipeline only earns its complexity once you need pre- and post-processing in the same middleware.


Need a request-handling layer — middleware, routing, or the container tying it together — built or untangled? Get in touch.

Contact Pierre Miniggio →