Migrating to Laminas (Zend Framework): A Practical Guide

Laminas (formerly Zend Framework) is one of the more demanding migrations in PHP. You are not just swapping a library - you are adopting PSR-4, PSR-7, PSR-11, and a middleware-first architecture at the same time. This post documents a real migration I led at Check24, covering the key decisions, practical problems, and patterns that made the difference.
If you are unfamiliar with the Laminas ecosystem: Laminas MVC (formerly Zend MVC) is the full-stack framework, and Laminas Mezzio (formerly Zend Expressive) is the micro-framework built around PSR-15 middleware. The Laminas project is now part of the Linux Foundation. For a deeper introduction to Mezzio and the middleware model, see the Laminas Framework Series.
Architecture Principle: Flat, Modular, Loosely Coupled
Before touching any code, the goal was clear: modules that bundle their own code, tests, and configuration. A module should be removable - or extractable into a library - without leaving debris behind. Cross-module dependencies are acceptable only when they go through interfaces or PSR standards, never through concrete implementations.
This principle shapes every decision below.
Routing and Middleware
The Laminas Router supports both global and route-specific middleware. Global middleware is useful for cross-cutting concerns applied to all routes - HTTP Basic Auth, request logging, security headers. Route-specific middleware handles validation of route parameters before they reach the handler.
The key shift from a traditional MVC router: in Mezzio, a route maps a URL pattern to a middleware pipeline, not directly to a controller action. The handler sits at the end of that pipeline. This makes it easy to layer in authentication, input validation, and rate limiting per route without polluting the handler.
<?php
// Route-specific middleware stack example
$app->get('/api/resource/{id}', [
AuthMiddleware::class,
ValidateIdMiddleware::class,
ResourceHandler::class,
]);
Guzzle HTTP Client: Builder Pattern for Runtime Configuration
Guzzle configuration (HTTP Basic Auth, SSL verification, base URI, timeouts) must often be assembled at runtime from config values or environment variables. A service factory resolves too early - before the runtime context is known.
The solution: a ClientBuilder class that exposes fluent configuration methods and returns a ClientInterface instance on build(). Returning ClientInterface (PSR-18) rather than the concrete Guzzle class keeps the consuming code decoupled:
<?php
$client = (new GuzzleClientBuilder())
->withBaseUri($config['api_base_url'])
->withBasicAuth($config['api_user'], $config['api_pass'])
->withSslVerification(true)
->build(); // returns Psr\Http\Client\ClientInterface
This pattern also makes testing straightforward: inject a mock ClientInterface without needing to replicate Guzzle's constructor options.
Caching ConfigProviders in Production
Every Laminas application uses ConfigProvider (Mezzio) or Module (MVC) classes to declare services, factories, and routes. On each request, the framework iterates all providers and merges their output into one large configuration array.
In production this is pure waste. The configuration does not change between requests. The laminas-config-aggregator library supports caching the merged result to a file. Enable it with two config keys:
<?php
// config/config.php
use Laminas\ConfigAggregator\ConfigAggregator;
$cacheConfig = ['config_cache_path' => 'data/cache/config-cache.php'];
$aggregator = new ConfigAggregator([
// ... your providers
], $cacheConfig['config_cache_path']);
The performance difference is measurable on applications with many modules. Clear the cache file on deployment.
Content-Type and Parameter Encoding
Both Guzzle and Laminas support multiple Content-Type formats, which creates a subtle mismatch: some legacy services POST with application/x-www-form-urlencoded, but the Laminas request handler may expect application/json.
On the sender side, Guzzle's json request option encodes the body as JSON and sets the correct Content-Type header automatically:
<?php
$client->post('/endpoint', ['json' => $payload]);
On the receiver side, ensure the middleware pipeline includes a body-parsing middleware that handles both formats. Laminas Mezzio ships Laminas\Stratigility\Middleware\BodyParamsMiddleware for this.
PSR-7 Stream Pitfall: getBody() vs getContents()
This is the most common mistake when first working with PSR-7 responses. Both Guzzle responses and Laminas responses implement PSR-7, and the stream behaviour is identical - which means the same bug occurs on both sides.
$response->getBody() returns a StreamInterface. The stream has a cursor. If anything has already read from it, the cursor is at the end, and further reads return an empty string.
<?php
// Wrong - may return empty string if stream was already read
$body = (string) $response->getBody();
// Safe - rewind first
$response->getBody()->rewind();
$body = (string) $response->getBody();
// Also safe - getContents() reads from current position to end
// but only works reliably on a fresh/rewound stream
$body = $response->getBody()->getContents();
The safest habit: always rewind before reading, or cast with (string) which implicitly calls __toString() - but only if the stream has not been consumed elsewhere.
PSR Compliance Checklist for Migration
These are the PSR standards you will touch during a Laminas migration:
- PSR-4 - autoloading. Ensure your namespace/directory structure is consistent. Laminas will not find classes that violate the mapping.
- PSR-7 - HTTP messages.
ServerRequestInterface,ResponseInterface,StreamInterface. Immutable - methods return new instances. - PSR-11 - container interface. Bind everything against
ContainerInterface, not the concrete Laminas container. Makes swapping containers possible. - PSR-15 - middleware.
MiddlewareInterfaceandRequestHandlerInterface. Every handler in Mezzio implements one of these.
Conclusion
A Laminas migration is not difficult, but it demands discipline: flat modules, PSR interfaces everywhere, no concrete class coupling across boundaries. The payoff is a codebase where any module can be removed, replaced, or tested in isolation - which is exactly the goal of the migration in the first place.
The stream pitfall, ConfigProvider caching, and the Guzzle builder pattern are the three things that cost the most time on a real project. Get them right early.
If you are planning a Laminas or Mezzio migration and want a review or implementation support, get in touch.