laminas-mvc or Mezzio: A Decision Guide for PHP Teams
laminas-mvc or Mezzio? A decision guide comparing the module-driven MVC stack against the PSR-15 middleware runtime, with a concrete same-endpoint validation probe.
07 Jan 2026, 21:50 UTC

If you're starting a new Laminas application — or evolving an old Zend Framework codebase — the first real decision is architectural: do you build on laminas-mvc, the module-driven full-stack framework, or Mezzio, the PSR-15 middleware runtime? Both are officially supported, both share the same underlying components, and both can run the same domain code. The difference is how requests flow through your application and how much implicit behavior you're willing to debug later.
The short version: pick laminas-mvc if you depend on the module ecosystem or a conventional controller/view workflow; pick Mezzio if you're building APIs or services and want explicit, inspectable request handling. Everything below explains why, and how to validate the choice before committing.
The decision and its constraints
Both stacks share infrastructure, which is what makes this decision reversible-ish rather than permanent:
- laminas-servicemanager for dependency injection in both.
- laminas-diactoros for PSR-7 HTTP messages (Mezzio exposes it directly; laminas-mvc wraps it).
- laminas-form and laminas-inputfilter work identically in either stack, so your validation layer ports cleanly.
- Routing differs: laminas-mvc uses its own router tied to controllers; Mezzio defaults to laminas-router but supports FastRoute and others.
The real constraints are your team's background, your dependence on third-party modules, and how much convention-versus-explicitness you want. Note one caution up front: many Zend Framework-era modules are abandoned and incompatible with current PHP versions. "There's a module for that" is only an argument for laminas-mvc if the module is actually maintained — check before counting it.
Side-by-side comparison
| Aspect | laminas-mvc | Mezzio |
|---|---|---|
| Request flow | Event-driven dispatch to controllers | Linear middleware pipeline (PSR-15) |
| Structure | Modules with merged config | Explicit pipeline and route definitions |
| Templating | laminas-view built in, with view helpers | Bring your own: Plates, Twig, or laminas-view |
| Best fit | HTML apps, admin panels, legacy ZF heritage | APIs, microservices, PSR-standard teams |
| Implicit behavior | High (events, controller plugins, module merging) | Low (what you pipe is what runs) |
| Debugging | Harder: execution flow crosses event listeners | Easier: stack traces follow the pipeline |
| Decisions pushed to team | Few (conventions provided) | More (router, container, template engine) |
Trade-offs that matter in practice
laminas-mvc: convention at the cost of visibility
The module system is the main reason to choose laminas-mvc. Third-party modules hook in through laminas-modulemanager, configuration merges automatically, and controller plugins plus view helpers give you a conventional request-to-controller-to-view flow that any Zend Framework veteran recognizes. The cost is that the event-driven dispatch obscures execution flow. In a large module stack, figuring out why a listener fired — or didn't — means tracing the event manager, and performance profiling gets noisy. If your team has ever lost an afternoon to a listener attached at the wrong priority, you know the failure mode.
Mezzio: explicitness at the cost of decisions
Mezzio composes a middleware pipeline: each request passes through a sequence of PSR-15 middleware you define, ending at a request handler that returns a PSR-7 response. There's almost no magic — a route maps to a pipeline, and the pipeline runs in order. That makes debugging and testing straightforward, and it gives you per-route control (e.g., authentication middleware only on the routes that need it). The cost is that flexibility means decisions: you choose the router, the container, and the template engine, and you own the pipeline ordering. The classic Mezzio bug is piping the error handler too late, so exceptions thrown by earlier middleware escape your error handling entirely. Without team conventions, middleware ordering mistakes are common.
Migration is incremental, not a rewrite
If you're sitting on a legacy laminas-mvc (or Zend Framework) application, you don't have to choose forever. A common strangler-style approach is to wrap the legacy MVC app behind middleware and route new endpoints through Mezzio while old ones fall through to the MVC layer. Because both stacks share the service manager and inputfilter components, domain code and validation move across without changes.
Concrete validation: build the same endpoint twice
Rather than arguing abstractly, scaffold both skeletons and implement one representative endpoint in each. Run these locally with your target PHP version installed (check the skeleton's composer.json platform requirements first — Laminas component PHP support shifts over time, so confirm current maintenance status on getlaminas.org before committing):
# laminas-mvc skeleton
composer create-project laminas/laminas-mvc-skeleton mvc-probe
# Mezzio skeleton (interactive: choose router, container, template engine)
composer create-project mezzio/mezzio-skeleton mezzio-probeThen implement the same thing in both: a POST endpoint that validates input with laminas-inputfilter and performs one database call. A minimal Mezzio handler looks like this:
namespace App\Handler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\InputFilter\InputFilter;
class CreateWidgetHandler implements RequestHandlerInterface
{
public function __construct(
private InputFilter $inputFilter,
private WidgetRepository $repository,
) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->inputFilter->setData((array) $request->getParsedBody());
if (! $this->inputFilter->isValid()) {
return new JsonResponse(['errors' => $this->inputFilter->getMessages()], 422);
}
$widget = $this->repository->create($this->inputFilter->getValues());
return new JsonResponse(['id' => $widget->id], 201);
}
}The laminas-mvc equivalent puts the same logic in a controller action, with the input filter pulled from the service manager. The domain code is nearly identical — what differs is the wiring around it.
What to compare after the probe
Score both implementations on the things that actually predict long-term fit:
- Bootstrap wiring: count the lines of configuration needed before the endpoint responds. Mezzio's is explicit and greppable; laminas-mvc's is spread across module config but shorter for conventional cases.
- Dependency visibility: in Mezzio, the handler's constructor shows every dependency. In laminas-mvc, controller plugins and service locator access can hide them.
- Test setup: write one integration test per stack exercising routing, DI, and error handling. Mezzio handlers are trivially unit-testable (pass a request, assert on the response); laminas-mvc controller tests need the framework's test harness.
- Error path: trigger a validation failure and an unexpected exception in each. Confirm the error handler catches what you expect — in Mezzio, verify it's piped first in
config/pipeline.php.
Limitations and how to check your result
This comparison reflects well-established architectural behavior, but two things need current verification before you commit. First, Laminas components are maintained but release cadence and PHP version support change over time — check the laminas and mezzio GitHub repositories and the getlaminas.org blog for recent releases and security advisories on the components you'll actually use. Second, if your decision leans on a specific third-party module, verify it installs cleanly on your PHP version; don't assume Zend Framework-era packages still work.
The practical check: if both skeletons install, both probes pass their integration tests, and one clearly produced less friction for your team's working style, that's your answer. If they feel equal, default to Mezzio for API-heavy work and laminas-mvc for server-rendered applications with existing module dependencies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.