Building Composable APIs with Laminas Mezzio 3.x and PSR-15 Middleware
Laminas Mezzio 3.x replaces MVC controllers with PSR-15 middleware pipelines wired by laminas-servicemanager. Shows route-specific middleware stacks, a worked API key auth example, and covers immutable responses and PHP 8.1+ requirement.
09 Jan 2026, 01:06 UTC

The Problem with Monolithic Controllers
Traditional MVC frameworks push business logic into controller actions that grow until they handle authentication, validation, rate limiting, and response formatting all in one method. Testing requires bootstrapping the entire application, and reusing cross-cutting concerns means inheritance or traits that couple unrelated features.
Laminas Mezzio 3.x (formerly Expressive) takes a different approach: the application is a middleware pipeline. Each request flows through a series of PSR-15 middleware — callables that receive a request, optionally delegate to the next handler, and return a response. This model, standardized in PSR-15, lets you compose features as independent, testable units wired together by laminas-servicemanager.
Pipeline Composition via Service Manager
The skeleton ships with config/pipeline.php defining the global middleware stack. Unlike a monolithic bootstrap, each entry is a service name resolved lazily from the container. A typical pipeline looks like:
return [\n 'middleware_pipeline' => [\n ['middleware' => Mezzio\\Helper\\ServerUrlMiddleware::class, 'priority' => 1000],\n ['middleware' => Mezzio\\Router\\Middleware\\RouteMiddleware::class, 'priority' => 900],\n ['middleware' => Mezzio\\Router\\Middleware\\DispatchMiddleware::class, 'priority' => 800],\n ['middleware' => Mezzio\\Handler\\NotFoundHandler::class, 'priority' => -100],\n ],\n];Each middleware is fetched on demand. Adding cross-cutting concerns — CORS, request ID propagation, structured logging — means inserting a service into this array, not editing a base controller. The container handles dependency graphs per middleware, so an authentication middleware can request a token validator while a rate limiter requests a Redis client, without either knowing about the other.
Route-Specific Middleware Stacks
Global pipelines handle concerns that apply everywhere. For route-scoped logic, laminas-router lets each route declare its own middleware pipeline. In config/routes.php:
return [\n ['name' => 'api.users.list', 'path' => '/api/users', 'middleware' => [\n App\\Middleware\\ApiAuth::class,\n App\\Middleware\\RateLimit::class,\n App\\Handler\\ListUsersHandler::class,\n ], 'allowed_methods' => ['GET']],\n ['name' => 'api.auth.login', 'path' => '/api/auth/login', 'middleware' => [\n App\\Middleware\\ValidateCredentials::class,\n App\\Handler\\LoginHandler::class,\n ], 'allowed_methods' => ['POST']],\n];The RouteMiddleware matches the request, then DispatchMiddleware executes the route's middleware stack in order. Each stack shares the global error handler and response emitter but isolates its own dependencies. This is where Mezzio shines: an admin API can require mTLS and audit logging while a public webhook endpoint only validates signatures, all in the same process.
Worked Example: Adding API Key Authentication
Create a routed middleware with the CLI tooling (run from project root, requires composer install first):
php vendor/bin/mezzio middleware:create App\\Middleware\\ApiAuth --factory --registerThis generates src/Middleware/ApiAuth.php implementing MiddlewareInterface and a factory src/Middleware/ApiAuthFactory.php. Edit the middleware to validate an X-API-Key header:
namespace App\\Middleware;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\MiddlewareInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse Laminas\\Diactoros\\Response\\JsonResponse;\n\nclass ApiAuth implements MiddlewareInterface\n{\n public function __construct(private readonly array $validKeys) {}\n\n public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n {\n $key = $request->getHeaderLine('X-API-Key');\n if (!in_array($key, $this->validKeys, true)) {\n return new JsonResponse(['error' => 'Invalid API key'], 401);\n }\n return $handler->handle($request);\n }\n}The factory reads configuration (run from project root):
namespace App\\Middleware;\n\nuse Psr\\Container\\ContainerInterface;\n\nclass ApiAuthFactory\n{\n public function __invoke(ContainerInterface $container): ApiAuth\n {\n $config = $container->get('config');\n return new ApiAuth($config['api_keys'] ?? []);\n }\n}Register the route in config/routes.php (already done by --register flag) and add keys to config/autoload/local.php:
return [\n 'api_keys' => ['dev-key-123', 'prod-key-456'],\n];Start the built-in server (requires PHP 8.1+):
php -S localhost:8080 -t publicTest with curl -H \"X-API-Key: dev-key-123\" http://localhost:8080/api/users. A missing or invalid key returns 401 with JSON. The middleware is unit-testable by instantiating it directly with a mock handler — no container needed.
Error Handling and Response Emission
Mezzio uses laminas-stratigility's FinalHandler for uncaught exceptions. For typed error handling, implement ErrorMiddlewareInterface (three-argument process receiving Throwable) and register it in the pipeline or per-route. The skeleton configures a default error handler that respects Accept headers: JSON for APIs, HTML for browsers.
Responses emit via laminas-httphandlerrunner's SapiEmitter, which handles streaming bodies and sets Content-Length or Transfer-Encoding: chunked automatically. This matters for file downloads or Server-Sent Events — returning a Psr\\Http\\Message\\StreamInterface from your handler works without buffering the entire response in memory.
Trade-offs and Limitations
PHP 8.1+ required. Mezzio 3.x drops PHP 8.0 support. Projects on older runtimes must stay on Mezzio 2.x (security-only) or upgrade PHP first. Immutable PSR-7 responses trip up developers used to mutating $response->getBody()->write(). Every modification returns a new instance: $response->withStatus(201)->withHeader('Location', $url). Forgetting to reassign loses changes. No bundled ORM or validator. You choose laminas-db, Doctrine, or something else and wire factories manually. The skeleton provides no migration tooling.
Session handling defaults to PHP's native file handler. Distributed deployments need shared storage (Redis, database) configured in config/autoload/session.global.php — a common oversight when scaling horizontally.
Verify and Extend
Confirm the stack works end-to-end:
composer create-project mezzio/mezzio-skeleton my-app && cd my-app(requires Composer 2.x)php vendor/bin/phpunit— passes if PSR-15 wiring and container configuration are correctcomposer show mezzio/mezzio | grep 'requires.*php'— verifies PHP constraint
From here, add a validation middleware using laminas-validator, wire a Doctrine entity manager via a factory, or implement a typed error handler for DomainException that returns RFC 7807 problem details. The pipeline model scales because each piece stays small, replaceable, and independently testable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.