Introduction: The Problem with Spaghettified Logic
Imagine you are building a modern PHP web application. As the project grows, you realize that every single entry point (route) requires the same repetitive tasks: checking if a user is logged in, logging the incoming request for debugging, adding security headers to the response, and catching unexpected errors.
In the “old days” of PHP development, you might have included a config.php or init.php file at the top of every script. While this worked for small sites, it quickly became a maintenance nightmare. This approach is rigid, hard to test, and violates the “Single Responsibility Principle.” If you need to change how authentication works, you might find yourself editing dozens of files.
This is where Middleware comes to the rescue. Middleware is the “glue” that sits between the incoming HTTP request and your final application logic (the controller). It allows you to intercept, inspect, and modify requests and responses in a structured, reusable way.
In this guide, we aren’t just going to use a framework’s middleware; we are going to build our own PSR-15 compliant middleware pipeline from scratch. By understanding the inner workings of this pattern, you will gain the skills to write cleaner, more modular, and highly professional PHP code that mirrors the architecture of world-class frameworks like Laravel, Symfony, and Slim.
The Theoretical Foundation: What is PSR-7 and PSR-15?
Before we write a single line of code, we must understand the standards that make modern PHP interoperable. In the PHP ecosystem, the PHP-FIG (Framework Interop Group) defines “Proposals” (PSRs) to ensure different libraries can work together seamlessly.
1. PSR-7: HTTP Message Interface
PSR-7 defines how HTTP requests and responses should be represented as objects. Instead of relying on global variables like $_GET, $_POST, or header() calls, PSR-7 gives us objects like ServerRequestInterface and ResponseInterface.
Crucially, PSR-7 objects are immutable. This means when you modify a request (e.g., adding a header), you don’t change the original object; instead, you get back a brand-new copy. This prevents “side effects” where one part of your code accidentally changes a global state that another part relies on.
2. PSR-15: HTTP Handlers and Middleware
PSR-15 builds on PSR-7. It defines how we should process those HTTP messages. It introduces two primary interfaces:
- MiddlewareInterface: A component that can process an incoming request and produce a response, often by delegating to a “handler.”
- RequestHandlerInterface: The component that ultimately handles the request and returns the response (the “core” of your app).
The “Onion” Model
Think of middleware as layers of an onion. The request starts at the outer layer, travels through each piece of middleware toward the center (the handler), and then the response travels back out through those same layers in reverse order. This allows each layer to perform actions both before and after the application logic executes.
Step 1: Setting Up the Environment
To follow this tutorial, you will need PHP 8.1 or higher and Composer installed. We will use the popular guzzlehttp/psr7 library to provide our PSR-7 implementations, as writing those from scratch is a massive undertaking beyond the scope of a middleware lesson.
# Create a new project directory
mkdir php-middleware-demo
cd php-middleware-demo
# Initialize composer
composer init --no-interaction
# Install PSR-7 and PSR-17 (Factories) implementations
composer require guzzlehttp/psr7
We also need to define our project structure. Create the following folders:
project/
├── src/
│ ├── Middleware/
│ └── Pipeline.php
├── public/
│ └── index.php
└── vendor/
Step 2: Building the Middleware Pipeline (The Dispatcher)
The “Pipeline” (or Dispatcher) is the engine that drives our middleware. Its job is to take an array of middleware and execute them in order. When a middleware calls $handler->handle($request), the pipeline must provide the next middleware in the stack.
This requires a recursive or iterative approach. Let’s build a clean, iterative Pipeline class.
<?php
declare(strict_types=1);
namespace App;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* The Pipeline class manages a stack of middleware and executes them
* in the order they were added.
*/
class Pipeline implements RequestHandlerInterface
{
/** @var MiddlewareInterface[] */
private array $middlewares = [];
/** @var RequestHandlerInterface The final handler (the "core" application) */
private RequestHandlerInterface $fallbackHandler;
/**
* @param RequestHandlerInterface $fallbackHandler The last step in the chain
*/
public function __construct(RequestHandlerInterface $fallbackHandler)
{
$this->fallbackHandler = $fallbackHandler;
}
/**
* Add a middleware to the start of the stack.
*/
public function pipe(MiddlewareInterface $middleware): self
{
$this->middlewares[] = $middleware;
return $this;
}
/**
* This handles the request by triggering the first middleware.
* It satisfies the RequestHandlerInterface.
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
// If no middleware left, call the final handler
if (empty($this->middlewares)) {
return $this->fallbackHandler->handle($request);
}
// Get the next middleware from the top of the stack
$middleware = array_shift($this->middlewares);
// We wrap the rest of the pipeline in a temporary handler
// so the current middleware can call it.
return $middleware->process($request, $this);
}
}
How the Pipeline Works
In the code above, the handle method is the magic. Because our Pipeline class implements RequestHandlerInterface, it can pass itself into the $middleware->process($request, $this) call.
When a piece of middleware calls $handler->handle($request), it is actually calling the pipeline’s handle method again, which shifts the next middleware off the stack and executes it. This continues until the stack is empty, at which point the fallbackHandler is executed.
Step 3: Creating Real-World Middleware Examples
Now that we have our engine, let’s build some functional components. This is where you see the power of reusability.
1. Logging Middleware
This middleware logs how long a request takes to process. It demonstrates the “Before” and “After” logic.
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class TimerMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$start = microtime(true);
// --- Logic BEFORE the next layer ---
$response = $handler->handle($request);
// --- Logic AFTER the next layer ---
$end = microtime(true);
$duration = round(($end - $start) * 1000, 2);
// We can even add the duration to a response header
return $response->withHeader('X-Response-Time', "{$duration}ms");
}
}
2. Authentication Middleware (The Gatekeeper)
This middleware checks for a specific API key. If it’s missing or invalid, it returns a 401 Unauthorized response immediately, stopping the request from ever reaching your controller.
<?php
declare(strict_types=1);
namespace App\Middleware;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class AuthMiddleware implements MiddlewareInterface
{
private string $validApiKey = 'secret-token-123';
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$apiKey = $request->getHeaderLine('X-API-Key');
if ($apiKey !== $this->validApiKey) {
// Short-circuit: Return a response directly
// The rest of the pipeline will NOT be executed
return new Response(401, [], json_encode(['error' => 'Unauthorized access']));
}
// Proceed to next middleware/handler
return $handler->handle($request);
}
}
3. Content-Type Middleware
This ensures all responses are returned as JSON, which is common for APIs.
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class JsonResponseMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
// Ensure the response has the JSON content type
return $response->withHeader('Content-Type', 'application/json');
}
}
Step 4: The Fallback Handler (Your Application Core)
The pipeline needs a “destination.” In a real framework, this would be your Router or Controller. For this demo, let’s create a simple handler that represents our business logic.
<?php
declare(strict_types=1);
namespace App;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class AppCore implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
// This is where your actual application logic lives.
$data = [
'message' => 'Hello from the core application!',
'user' => $request->getAttribute('authenticated_user', 'Guest')
];
return new Response(200, [], json_encode($data));
}
}
Step 5: Putting it All Together
Now, let’s wire everything up in our public/index.php file. We will create the request object, instantiate our middleware, and run the pipeline.
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Pipeline;
use App\AppCore;
use App\Middleware\TimerMiddleware;
use App\Middleware\AuthMiddleware;
use App\Middleware\JsonResponseMiddleware;
use GuzzleHttp\Psr7\ServerRequest;
use GuzzleHttp\Psr7\HttpFactory;
// 1. Capture the incoming HTTP request (PSR-7)
// Guzzle provides a helper to create this from PHP globals
$request = ServerRequest::fromGlobals();
// 2. Initialize the application core
$appCore = new AppCore();
// 3. Initialize the pipeline with the core as fallback
$pipeline = new Pipeline($appCore);
// 4. Pipe your middleware (Order matters!)
$pipeline->pipe(new TimerMiddleware()) // Outer layer
->pipe(new JsonResponseMiddleware()) // Middle layer
->pipe(new AuthMiddleware()); // Inner layer (closest to core)
// 5. Handle the request
try {
$response = $pipeline->handle($request);
} catch (\Throwable $e) {
// Basic error handling middleware usually handles this,
// but here is a simple catch-all.
$response = new \GuzzleHttp\Psr7\Response(500, [], 'Server Error');
}
// 6. Emit the response back to the browser
// In a real app, use a dedicated Emitter class
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
foreach ($values as $value) {
header(sprintf('%s: %s', $name, $value), false);
}
}
echo $response->getBody();
Advanced Concepts: Immutability and Attributes
One of the most powerful features of PSR-7 is the ability to pass data down the pipeline using attributes. Since the Request object is immutable, we use withAttribute() to create a new version of the request containing our data.
Example: Modifying AuthMiddleware to pass user data to the controller.
// Inside AuthMiddleware...
if ($apiKey === $this->validApiKey) {
// Add user data to the request attribute
$request = $request->withAttribute('user_id', 42);
$request = $request->withAttribute('user_role', 'admin');
// Pass the ENHANCED request to the next layer
return $handler->handle($request);
}
Now, in your AppCore or controller, you can access this data easily:
$userId = $request->getAttribute('user_id');. This is much cleaner than using $_SESSION or global variables.
Common Mistakes and How to Fix Them
1. Forgetting to return the response
The Mistake:
public function process($request, $handler): ResponseInterface {
$handler->handle($request); // No return statement!
}
The Fix: Always return the result of $handler->handle($request) or a new Response object. PHP will throw a type error if you don’t return a ResponseInterface.
2. Modifying the Request “In Place”
The Mistake:
$request->withHeader('X-Foo', 'Bar');
return $handler->handle($request); // The header is NOT in this request!
The Fix: Remember PSR-7 is immutable. You must assign the result of the method to a variable: $request = $request->withHeader(...);.
3. Putting Middleware in the Wrong Order
The Mistake: Putting a logging middleware after an authentication middleware that might short-circuit the request.
The Fix: Think about the “Onion.” Logging and error handling should usually be the outermost layers (piped first) so they can catch everything that happens inside.
Performance and Scalability
Does adding 10 or 20 layers of middleware slow down your application? Technically, yes, every function call has a cost. However, in PHP, the overhead of a middleware pipeline is negligible compared to database queries or file I/O.
To keep your pipeline fast:
- Lazy Loading: Don’t instantiate all middleware classes if they aren’t needed. Use a Dependency Injection (DI) container to load them only when executed.
- Early Returns: As shown in our
AuthMiddleware, return as early as possible if a request is invalid to avoid unnecessary processing in inner layers.
Testing Your Middleware
One of the greatest benefits of the PSR-15 standard is how easy it makes unit testing. You don’t need a browser or a web server to test your logic.
use PHPUnit\Framework\TestCase;
use App\Middleware\AuthMiddleware;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Server\RequestHandlerInterface;
use GuzzleHttp\Psr7\Response;
class AuthMiddlewareTest extends TestCase
{
public function testReturns401ForMissingApiKey()
{
$middleware = new AuthMiddleware();
$request = new ServerRequest('GET', '/admin');
// Mock the handler (it should never be called)
$handler = $this->createMock(RequestHandlerInterface::class);
$handler->expects($this->never())->method('handle');
$response = $middleware->process($request, $handler);
$this->assertEquals(401, $response->getStatusCode());
}
}
Summary and Key Takeaways
Building a custom middleware pipeline is a rite of passage for intermediate PHP developers. It shifts your mindset from “writing scripts” to “building architectures.”
- Decoupling: Middleware separates concerns like security, logging, and formatting from your core business logic.
- PSR Standards: Using PSR-7 and PSR-15 ensures your code is compatible with the wider PHP ecosystem.
- The Onion Model: Requests flow in, responses flow out, and each layer can act on both.
- Immutability: PSR-7 objects can’t be changed; you always work with copies, which makes your code predictable and bug-resistant.
Frequently Asked Questions (FAQ)
Can I use this pipeline in a Laravel or Symfony project?
While frameworks have their own middleware systems, they are increasingly PSR-15 compatible. Laravel uses its own implementation but offers adapters. Symfony uses a different Event Dispatcher model but can support PSR-15 via bridges. However, this custom pipeline is perfect for micro-frameworks or custom-built legacy app modernizations.
How do I pass data between two different middlewares?
Use Request Attributes. For example, Middleware A sets $request = $request->withAttribute('data', $value) and Middleware B retrieves it with $request->getAttribute('data').
Why should I use PSR-15 instead of just calling functions?
PSR-15 provides a formalized interface. This means you can download a community-made “CORS middleware” or “Rate Limiting middleware” from Packagist and it will work perfectly with the pipeline we built today without any modification.
Is there a limit to how many middlewares I can use?
There is no hard limit, but aim for clarity. If you have 30 middlewares, your stack might be doing too much. Consider grouping logic or moving some parts into the application core.
Does order really matter that much?
Absolutely. If your AuthMiddleware is after your RouteMiddleware, you might waste time calculating routes for a user who isn’t even logged in. Always put global concerns (Error handling, Profiling) on the outside and specific concerns (Validation, Authorization) on the inside.
