RFC-010: Laminas MVC to Mezzio Migration
Summary
Replace the discontinued Laminas MVC framework (and its satellite packages) across the three VOL applications with Mezzio + PSR-15 middleware, delivered incrementally as a strangler-fig migration: each app runs a dual-stack bootstrap with per-route feature toggles, so routes move one batch at a time and rollback is a toggle flip. The actively maintained standalone Laminas components (servicemanager, form, view, validator, filter, router) are retained — only the MVC glue is replaced.
Problem
The Laminas Technical Steering Committee has discontinued the Laminas MVC framework and its satellites. Five packages in the VOL estate are directly affected:
laminas/laminas-mvclaminas/laminas-mvc-i18n(already removed)laminas/laminas-mvc-plugin-prg(already removed)laminas/laminas-mvc-plugin-flashmessengerlm-commons/lmc-rbac-mvc
Security patches continue until 2028-12-31 (aligned with PHP 8.4 EOL) and there will be no PHP 8.5 support. Doing nothing means VOL is pinned below PHP 8.5 and, from 2029, runs on an unpatched framework.
The coupling is broad:
- All three apps boot through
Laminas\Mvc\Application; cross-cutting concerns (logging, auth, CSRF, cookies, route-param hydration) hang off MVC events. - 456 frontend controllers (153 selfserve, 300 internal), of which 453 inherit
olcs-common'sAbstractOlcsController/Lva\AbstractController— both of which overrideonDispatch(MvcEvent). The shared base is the gate: nothing frontend can move until it is framework-agnostic. - The in-tree libraries
lib/olcs-common,lib/olcs-authandlib/olcs-loggingrequirelaminas-mvcdirectly;lib/olcs-commonandlib/olcs-authalso pull the flash-messenger plugin andlmc-rbac-mvc. - The API's REST surface is small (~4 controllers) and already delegates to the framework-agnostic CQRS domain layer.
Proposal
Adopt Mezzio + PSR-15 as the replacement HTTP runtime, migrated route-by-route behind feature toggles.
Why Mezzio
- Actively maintained, same ecosystem. Mezzio is the Laminas project's own middleware runtime (3.28.1 released June 2026, supporting PHP 8.2–8.5, with a 4.0 branch in development). It already clears the PHP 8.5 wall that laminas-mvc never will.
- Smallest rewrite surface. Mezzio reuses everything we keep: the ServiceManager v3 container, laminas-view, laminas-form, laminas-validator/filter, and — via
mezzio-laminasrouter— the existing route definitions verbatim. Only the dispatch glue (controllers, MVC event listeners, controller plugins) changes. - The RBAC gap has closed.
lmc-rbac-mezzioreached a stable 1.0.1 in May 2026 (PHP 8.3–8.5), giving a like-for-like replacement forlmc-rbac-mvc's route guards. Both wrap the samelm-commons/rbaccore, so roles and permissions are unchanged. - The monorepo makes it cheaper. A strangler-fig migration requires repeated lock-step changes to the shared libraries and the apps. Since the library absorption (RFC-011),
lib/olcs-common,lib/olcs-authandlib/olcs-loggingare in-tree, so every such change is a single atomic PR with library and app CI in one pipeline run — no coordinated releases, and rollback is a plain revert.
Alternatives considered
- Do nothing / fork laminas-mvc. A dead end: the 2028-12-31 patch horizon and the PHP 8.5 block both stand, and a community fork of an abandoned framework transfers the maintenance burden to us at its worst point.
- Symfony (full framework or HttpKernel). Would discard the ServiceManager container, laminas-form, laminas-view and every route definition — turning a glue swap into a multi-year platform rewrite of 456 controllers plus their forms and templates.
- Slim 4 (or another PSR-15 micro-framework). PSR-15 like Mezzio, but with no laminas-router bridge, no view/form integration and no RBAC package — the same interop work as Mezzio with fewer reused parts.
- Big-bang rewrite to Mezzio. Rejected on risk: a single cutover of three apps and 456 controllers cannot be meaningfully staged, tested in production, or rolled back.
Migration mechanism (strangler fig)
- Each app's
public/index.phpdispatches a request to either the MVC stack or the Mezzio pipeline based on a per-route feature toggle (MVC default). Both stacks share the same ServiceManager instance, so services, repositories and configuration resolve identically from either side. Rollback at any point, per route, is a toggle flip. - Both stacks share one session store (the existing
Laminas\Session\Containernamespaces), so a user's session survives switching stacks mid-journey during the interop window. - API first. The API is the pilot: its controllers are thin delegations to the CQRS command/query handler managers, so there is almost no logic to rewrite, and its identity is resolved per-request from the JWT
Authorizationheader — token-based, not session-based — so the API's authorization is independent of the frontends' and the three apps do not need to migrate together. With only ~4 controllers the API can optionally cut over big-bang behind a single app-level toggle. - Decouple the shared controller base, then batch the frontends. The shared logic in
AbstractOlcsController/Lva\AbstractControlleris extracted into framework-agnostic services (feature-toggle checks, action resolution, resource-conflict handling, section translation, flash, CSRF). Two thin delivery adapters share those services — the existing MVC base and a new PSR-15AbstractOlcsHandler— with a common interface keeping type safety while both exist. Frontend controllers then migrate in per-route batches (route prefix / feature area / complexity tier), each batch soaking in production before the next.
Key component decisions
- FlashMessenger: in-house session-backed adapter, not
mezzio-flash. The discontinued plugin's storage is justLaminas\Session\Container(maintained). An in-house adapter inlib/olcs-commonkeeps theFlashMessengerHelperServiceAPI unchanged (~1900 call sites untouched) and is readable from both stacks during interop.mezzio-flashwas rejected because it only functions inside a Mezzio pipeline and its store cannot be read by the MVC stack mid-migration; VOL's same-request "current messages" andprominent-errorsemantics aren't flash-hop semantics anyway. This lands early, on the current MVC stack, and removes a discontinued package outright. - RBAC: a stack-neutral interface,
lmc-rbac-mezziofor the frontends only. AnAuthorizationServiceInterfacedecouples the 49 FormService classes and ~106isGranted()call sites fromLmcRbacMvc; the internal app's route guards port tolmc-rbac-mezziomiddleware. The API keeps using the framework-agnosticAuthorizationServicewith JWT identity fed from the PSR-7 request — it needs no guard middleware at all. - Logging:
LogRequest/LogErrorbecome PSR-15 middleware inlib/olcs-logging, shipped alongside the MVC listeners so each stack has its own path during interop. - Auth:
lib/olcs-auth's MVC dispatch andRedirectStrategybecome authentication + redirect middleware, with the legacy MVC entry points retained (and feature-toggled) through an extended soak, since login/logout has the highest blast radius.
Sequencing
Work already landed that this plan builds on:
laminas-mvc-plugin-prgandlaminas-mvc-i18nremoved;dvsa/laminas-config-cloud-parametersno longer dev-requireslaminas-mvc(v2.1.0).- PHP 8.4 + PHPUnit 13 upgrade (VOL-6520) — the platform prerequisite for the migration window.
- Library absorption into the monorepo — enables atomic library+app changes.
doctrine-orm-module/doctrine-moduleremoval (in flight, separate workstream) — removes Doctrine's ownlaminas-mvcrequirement without any work in this plan.
Remaining work is ticketed in Jira (18 dependency-linked tickets), ordered isolation-first: the pieces that are pure refactors on the current MVC stack — the flash adapter swap, the shared-base decoupling, the RBAC interface, and the view-helper cleanups — run first and in parallel, needing no Mezzio at all. The API bootstrap + pilot follows as the first vertical slice of the dual-stack mechanism, then the frontend bootstraps, the remaining untangling (route-param listeners, controller plugins, CSRF/error middleware, sessions), the batched frontend migration, and finally teardown. The final cleanup — deleting the MVC bootstrap paths and removing laminas-mvc, lmc-rbac-mvc and the MVC-only dev tools from every composer.json — is gated on all routes running Mezzio in production for at least two weeks with zero rollbacks.
Out of scope
laminas-servicemanagerv3 → v4 (blocked bymezzio-laminasviewrenderer2.x andlmc-rbac-mezzio1.x; a later epic).- Long-term replacement of
laminas-form/laminas-view(both actively maintained standalone). - The Doctrine module replacement (its own workstream, already in flight).
Risks
- Interop parity — session and identity must behave identically across stacks; mitigated by the shared session store, per-route toggles limiting blast radius, and parity tests per migrated route.
- Auth changes (
lib/olcs-auth) touch login/logout; mitigated by feature-toggling the auth pipeline and an extended production soak. - Internal's dynamic
route.param.*listener system has no direct Mezzio analogue; it is re-modelled as router-adjacent middleware composing per-handler pipelines, with the MVC listeners untouched until cutover. - Deadline — security patches end 2028-12-31; the batched frontend migration is the long tail and its burn-down should be tracked, with batch sizes increased if it falls behind.