Skip to main content
    Courses/PHP/Middleware Architecture

    Lesson 20 • Advanced

    Middleware Architecture 🔗

    By the end of this lesson you'll understand the request/response pipeline behind every modern PHP framework — the "onion" model, the PSR-7 and PSR-15 standards, and how to build and chain your own auth, logging, and CORS middleware.

    What You'll Learn in This Lesson

    • Describe the request/response pipeline as an onion of wrapping layers
    • Read the PSR-7 (messages) and PSR-15 (middleware) interfaces
    • Build middleware that runs code before and after the controller
    • Chain and dispatch middleware so pipe-order equals run-order
    • Short-circuit the pipeline to reject auth, preflight, or rate-limited requests
    • Know where Laravel, Slim, and Symfony plug middleware in

    1️⃣ The Onion: Code Before and After

    Every web app does the same core job: take an HTTP request, run your code, return an HTTP response. Middleware is a layer that wraps that code so it can run before the request reaches your controller and after the response comes back. Picture an onion: the request travels inward through each layer to the centre (your controller), then the response travels back outward through the same layers in reverse. The key piece is $next — a callback that means "hand control to the next layer in". Code before $next runs on the way in; code after it runs on the way out.

    One middleware wrapping a controller
    <?php
    // A request travels INTO the handler, then a response travels back OUT.
    // One middleware "wraps" the controller: it runs code before AND after.
    
    // The controller is the innermost layer — it produces the response.
    $controller = function (array $request): array {
        return ["status" => 200, "body" => "Hello, {$request['user']}!"];
    };
    
    // A logging middleware wraps it. $next is "the rest of the pipeline".
    $logging = function (array $request, callable $next): array {
        echo "[in ] {$request['method']} {$request['path']}\n"; // BEFORE: on the way in
        $response = $next($request);                              // hand off to $controller
        echo "[out] status {$response['status']}\n";            // AFTER: on the way out
        return $response;                                         // pass the response back
    };
    
    // Run it: the middleware calls $next, which is the controller.
    $response = $logging(
        ["method" => "GET", "path" => "/home", "user" => "Sam"],
        $controller
    );
    echo "Body: {$response['body']}\n";
    Output
    [in ] GET /home
    [out] status 200
    Body: Hello, Sam!
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Look at the output order: [in ] prints first, then control flows into the controller, then [out] prints. The same closure runs code on both sides of the single $next($request) call. That one idea scales to a whole stack.

    2️⃣ PSR-7, PSR-15 & Chaining the Stack

    Real PHP doesn't pass arrays around — it uses PSR-7, a shared standard for HTTP message objects (ServerRequestInterface and ResponseInterface), so every library agrees on what a request and response look like. On top of that, PSR-15 defines the middleware contract: a MiddlewareInterface with one method, process($request, $handler), that returns a response. Because the contract is standardised, middleware from any package can share one pipeline. Below, the request and response are kept as plain arrays so the focus stays on the pipeline — the part that chains many layers and dispatches a request through them. Notice how pipe() returns $this so calls chain, and how process() composes the stack inside-out with array_reverse so the first middleware you pipe ends up outermost.

    A PSR-15-style middleware pipeline
    <?php
    // Real frameworks use PSR-15: a middleware has a single handle($request, $next).
    // Here we model $request/$response as arrays to keep the focus on the PIPELINE.
    
    interface Middleware
    {
        // $next is a callable: "process the rest of the stack and give me a response".
        public function handle(array $request, callable $next): array;
    }
    
    final class Pipeline
    {
        /** @var Middleware[] */
        private array $stack = [];
    
        public function pipe(Middleware $mw): self
        {
            $this->stack[] = $mw;
            return $this;          // return $this so calls can be chained
        }
    
        public function process(array $request, callable $controller): array
        {
            // Compose inside-out: wrap the controller with the LAST middleware first,
            // so the FIRST one you piped ends up on the OUTSIDE and runs first.
            $next = $controller;
            foreach (array_reverse($this->stack) as $mw) {
                $current = $next;  // capture the inner layer for this closure
                $next = fn (array $req) => $mw->handle($req, $current);
            }
            return $next($request);
        }
    }
    
    final class LoggingMiddleware implements Middleware
    {
        public function handle(array $request, callable $next): array
        {
            echo "[LOG] {$request['method']} {$request['path']}\n";
            $response = $next($request);                       // continue the pipeline
            echo "[LOG] responded {$response['status']}\n";
            return $response;
        }
    }
    
    final class AuthMiddleware implements Middleware
    {
        public function handle(array $request, callable $next): array
        {
            if (empty($request["headers"]["Authorization"])) {
                echo "[AUTH] rejected — no token\n";
                return ["status" => 401, "body" => "Unauthorized"]; // short-circuit: no $next
            }
            $request["user"] = "Alice";                        // pretend we decoded the token
            echo "[AUTH] ok — {$request['user']}\n";
            return $next($request);                            // authorised: continue
        }
    }
    
    // Pipe order = run order. Logging is outermost, Auth is inside it.
    $pipeline = (new Pipeline())
        ->pipe(new LoggingMiddleware())
        ->pipe(new AuthMiddleware());
    
    $controller = fn (array $req) => ["status" => 200, "body" => "Users for {$req['user']}"];
    
    echo "--- request WITH token ---\n";
    $ok = $pipeline->process(
        ["method" => "GET", "path" => "/api/users", "headers" => ["Authorization" => "Bearer x"]],
        $controller
    );
    echo "final: {$ok['status']} {$ok['body']}\n\n";
    
    echo "--- request WITHOUT token ---\n";
    $blocked = $pipeline->process(
        ["method" => "GET", "path" => "/api/users", "headers" => []],
        $controller
    );
    echo "final: {$blocked['status']} {$blocked['body']}\n";
    Output
    --- request WITH token ---
    [LOG] GET /api/users
    [AUTH] ok — Alice
    [LOG] responded 200
    final: 200 Users for Alice
    
    --- request WITHOUT token ---
    [LOG] GET /api/users
    [AUTH] rejected — no token
    [LOG] responded 401
    final: 401 Unauthorized
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Trace the output. With a token: Logging runs (outermost), then Auth passes you through, then the controller answers, then control unwinds back out through Auth and Logging. Without a token, Auth returns a 401 and never calls $next — the controller never runs. That's a short-circuit, and it's how you cheaply reject bad requests before they touch your database. The real MiddlewareInterface looks almost identical — swap the arrays for PSR-7 objects and rename handle to process.

    3️⃣ CORS: Working on the Response

    Not all middleware works on the request — plenty of it shapes the response on the way out. CORS (Cross-Origin Resource Sharing) is the classic example: when a browser on app.example.com calls your API on a different domain, it needs Access-Control-Allow-* headers on the response or it will block the result. Browsers also send a "preflight" OPTIONS request first to ask permission — and your CORS middleware should answer that immediately, short-circuiting before the real handler runs.

    A CORS middleware that adds headers after $next
    <?php
    // CORS middleware adds cross-origin headers to the RESPONSE — work done AFTER $next.
    // It also answers a browser "preflight" (an OPTIONS request) without hitting the app.
    
    final class CorsMiddleware
    {
        public function __construct(private string $allowOrigin) {}
    
        public function handle(array $request, callable $next): array
        {
            // Browsers send OPTIONS first to ask "am I allowed?". Reply immediately.
            if ($request["method"] === "OPTIONS") {
                return ["status" => 204, "headers" => $this->corsHeaders(), "body" => ""];
            }
    
            $response = $next($request);                       // run the real handler
            // Merge CORS headers onto whatever the handler returned.
            $response["headers"] = ($response["headers"] ?? []) + $this->corsHeaders();
            return $response;
        }
    
        private function corsHeaders(): array
        {
            return [
                "Access-Control-Allow-Origin"  => $this->allowOrigin,
                "Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
            ];
        }
    }
    
    $cors = new CorsMiddleware("https://app.example.com");
    $handler = fn (array $req) => ["status" => 200, "body" => "data"];
    
    $res = $cors->handle(["method" => "GET"], $handler);
    echo "GET  -> {$res['status']} origin={$res['headers']['Access-Control-Allow-Origin']}\n";
    
    $pre = $cors->handle(["method" => "OPTIONS"], $handler);
    echo "OPTIONS preflight -> {$pre['status']} (handler never ran)\n";
    Output
    GET  -> 200 origin=https://app.example.com
    OPTIONS preflight -> 204 (handler never ran)
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    The GET flows through to the handler and then gets CORS headers merged onto its response. The OPTIONS preflight is answered with a 204 and the handler is never called. Same middleware, two paths — exactly the flexibility the onion gives you.

    4️⃣ Your Turn: Call $next

    Now you build a timing middleware. The whole trick is to do work, call $next, then do more work with the response. Fill in each ___ using the 👉 hint, run it, and check it against the Output panel.

    🎯 Your turn: a timing middleware
    <?php
    // 🎯 YOUR TURN — finish this timing middleware, then run it.
    // A timer measures how long the rest of the pipeline takes. To do that it must
    // record the time, CALL THE NEXT LAYER, then measure again.
    
    $timing = function (array $request, callable $next): array {
        $start = microtime(true);
    
        // 1) Run the rest of the pipeline and capture its response.
        $response = ___;          // 👉 call the next layer:  $next($request)
    
        $ms = round((microtime(true) - $start) * 1000, 1);
        echo "[time] {$request['path']} took {$ms}ms\n";
    
        // 2) Always hand the response back to the layer outside you.
        return ___;               // 👉 return the response you captured above
    };
    
    $controller = fn (array $req) => ["status" => 200, "body" => "ok"];
    $result = $timing(["path" => "/report"], $controller);
    echo "status: {$result['status']}\n";
    
    // ✅ Expected output (the ms number will vary):
    //    [time] /report took 0ms
    //    status: 200
    
    Output
    [time] /report took 0ms
    status: 200
    Replace the two ___ blanks: capture $next($request) into $response, then return $response. The millisecond figure will vary; the status should be 200.

    One more, and this one is about order. An error handler can only catch what's inside it, so it must wrap the layer that throws. Fill the blank so the error handler runs first and the throwing handler is its $next.

    🎯 Your turn: wrap a throwing handler
    <?php
    // 🎯 YOUR TURN — fix the ORDER so errors are caught.
    // The controller throws. Only the ErrorHandler can turn that into a 500 response,
    // so it MUST sit on the OUTSIDE of the layer that throws.
    
    final class ErrorHandler
    {
        public function handle(array $request, callable $next): array
        {
            try {
                return $next($request);
            } catch (\Throwable $e) {
                echo "[error] caught: {$e->getMessage()}\n";
                return ["status" => 500, "body" => "Server Error"];
            }
        }
    }
    
    $throws = function (array $req): array {
        throw new \RuntimeException("database is down");
    };
    
    $error = new ErrorHandler();
    
    // 👉 Wrap the throwing handler with the error handler.
    //    Fill the blank so $error->handle runs FIRST and $throws is its $next.
    $response = ___;   // 👉 e.g.  $error->handle(["path" => "/users"], $throws)
    
    echo "final: {$response['status']} {$response['body']}\n";
    
    // ✅ Expected output:
    //    [error] caught: database is down
    //    final: 500 Server Error
    
    Output
    [error] caught: database is down
    final: 500 Server Error
    Replace the ___ with a call to $error->handle(...), passing the request and $throws as its next handler. The exception should be caught and turned into a 500.

    Common Errors (and the fix)

    • The request reaches the controller but the response is empty / the page hangs — a middleware forgot to call $next (or forgot to return what it returned). If a layer doesn't call and return $next($request), the pipeline stops dead there. Only skip $next when you mean to short-circuit — and then you must return a real response.
    • Exceptions escape as a 500 with a stack trace, or auth runs on unvalidated data — your middleware is in the wrong order. The error handler must be outermost (piped first) so it wraps everything; authentication must come before authorization; CORS must be able to add headers even when an inner layer short-circuits. Remember: first piped = outermost = runs first on the way in.
    • "Cannot modify header information — headers already sent by ..." — you tried to add a header (or change the response) after output was already flushed. In real PHP, once any echo or whitespace before <?php has been sent, headers are locked. Do all response/header work in middleware before the body is emitted, and never leave blank lines after a closing ?>.
    • Errors silently vanish — a request "succeeds" but nothing happened — a middleware swallowed an exception with an empty catch and returned a fake success. Catch only what you can handle, log the rest, and re-throw or return a proper error status. A swallowed error is far harder to debug than one that's reported.

    Pro Tips

    • 💡 Implement PSR-15 for real apps. A class that implements Psr\Http\Server\MiddlewareInterface drops straight into Slim, Mezzio, or any PSR-15 dispatcher — no glue code.
    • 💡 Keep middleware thin. Don't run heavy database queries in middleware that applies to static assets. Use route groups so a layer only runs where it's needed.
    • 💡 Treat requests as immutable. PSR-7 objects are immutable: methods like withAttribute() return a copy. Always pass the new request into $next, not the original.

    📋 Quick Reference — Middleware

    ConceptIn codeWhat it does
    $next$next($request)Pass control to the next layer in
    beforecode above $nextRuns on the way in (e.g. auth check)
    aftercode below $nextRuns on the way out (e.g. add headers)
    short-circuitreturn $resp;Return without calling $next (401, 204, 429)
    PSR-7ResponseInterfaceStandard HTTP message objects
    PSR-15MiddlewareInterfaceStandard middleware: process($req, $handler)
    pipe orderfirst = outermostFirst piped runs first on the way in

    Frequently Asked Questions

    Q: What exactly is middleware in PHP?

    Middleware is a layer of code that sits between the incoming HTTP request and your application's controller. Each piece of middleware receives the request and a $next callback; it can read or change the request, call $next to pass control deeper into the stack, inspect or change the response on the way back out, or return a response early to stop the request before it reaches the controller. Authentication, logging, CORS, and rate limiting are all classic middleware jobs because they apply to many routes and are easiest to handle in one shared place.

    Q: What are PSR-7 and PSR-15?

    They are PHP-FIG standards that let middleware from different libraries work together. PSR-7 defines immutable interfaces for HTTP messages (ServerRequestInterface and ResponseInterface) so a request or response object looks the same everywhere. PSR-15 builds on it with two tiny interfaces: MiddlewareInterface, whose process(ServerRequestInterface $request, RequestHandlerInterface $handler) method returns a ResponseInterface, and RequestHandlerInterface, which is the $handler you delegate to. Because the contract is standardised, you can mix middleware from any PSR-15 package in the same pipeline.

    Q: Why does the order of middleware matter so much?

    Because middleware forms an onion: the first one you add is the outermost layer, so it runs first on the way in and last on the way out. An error handler must be outermost so it can catch exceptions thrown anywhere inside. Authentication must run before authorization, because you can't check a user's permissions before you know who they are. CORS headers must be added on the response even when an inner layer short-circuits. Put the layers in the wrong order and you get exceptions that escape, valid requests rejected by the browser, or auth checks that run on data that was never validated.

    Q: What does it mean to short-circuit a pipeline?

    It means a middleware returns a response without calling $next, so the rest of the pipeline and the controller never run. Auth middleware short-circuits with a 401 when there's no valid token; a CORS middleware short-circuits an OPTIONS preflight with a 204; a rate limiter short-circuits with a 429. Short-circuiting is a feature, not a bug — it's how you reject bad requests cheaply, before they reach expensive database work.

    Q: Where do real frameworks use middleware?

    Everywhere a request enters the app. Laravel runs global middleware plus named groups like 'web' and 'api', and lets you attach middleware to individual routes or route groups. Slim and Mezzio are built directly on PSR-15 pipelines. Symfony uses a closely related idea called HttpKernel event listeners. Under the hood they all do what the examples in this lesson do: wrap the controller in a stack of layers and dispatch the request through it.

    Mini-Challenge: Rate Limiter

    No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. A rate limiter is real middleware you'll meet on every public API.

    🎯 Mini-Challenge: build a rate-limiting middleware
    <?php
    // 🎯 MINI-CHALLENGE: a rate-limiting middleware.
    // No code is filled in — work from the steps, then run it.
    //
    // 1. Keep a static counter of how many requests an IP has made
    //    (a simple  static array $hits = [];  inside the method works).
    // 2. In handle($request, $next):
    //    - read $request["ip"], increment $hits[$ip]
    //    - if the count is > 3, return ["status" => 429, "body" => "Too Many Requests"]
    //    - otherwise call $next($request) and return its response
    // 3. Send 5 requests from the same IP through it and print each status.
    //
    // Tip: a 429 means "rate limited". The first 3 should pass, the rest should fail.
    //
    // ✅ Expected output (handler returns 200):
    //    request 1 -> 200
    //    request 2 -> 200
    //    request 3 -> 200
    //    request 4 -> 429
    //    request 5 -> 429
    
    // your code here
    
    Count requests per IP, short-circuit with a 429 once the limit is passed, otherwise call $next. Send 5 requests from one IP; the first 3 should pass, the rest should be limited.

    🎉 Lesson Complete!

    • ✅ Middleware wraps your controller in an onion: requests flow in, responses flow back out through the same layers
    • $next($request) hands control to the next layer; code before it runs in, code after it runs out
    • PSR-7 standardises HTTP messages; PSR-15 standardises the middleware contract so layers from any package compose
    • ✅ A pipeline composes the stack inside-out — the first piece you pipe is outermost and runs first
    • Short-circuit by returning a response without calling $next (auth 401, CORS preflight 204, rate-limit 429)
    • Order matters: error handler outermost, auth before authorization, and never modify the response after it's been sent
    • Next lesson: Advanced PDO — transactions, prepared statements, and stored procedures for safe, fast database work

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service