contenir / contenir-page-cache-laminas-mvc
Laminas MVC page-cache adapter for Contenir CMS — MvcEvent page-cache listener driven by the standard pagecache config.
Package info
github.com/contenir/contenir-page-cache-laminas-mvc
pkg:composer/contenir/contenir-page-cache-laminas-mvc
Requires
- php: ~8.3.0 || ~8.4.0 || ~8.5.0
- contenir/contenir-page-cache: ^2.0
- laminas/laminas-cache: ^3.12 || ^4.0
- laminas/laminas-eventmanager: ^3.13
- laminas/laminas-http: ^2.19
- laminas/laminas-mvc: ^3.7
- laminas/laminas-servicemanager: ^3.22 || ^4.0
- laminas/laminas-stdlib: ^3.19
- psr/container: ^1.1 || ^2.0
Requires (Dev)
- contenir/contenir-qa-tools: 0.1.x-dev
- infection/infection: ^0.34.1
- laminas/laminas-authentication: ^2.16
- laminas/laminas-cache-storage-adapter-memory: ^2.3 || ^3.0
- laminas/laminas-form: ^3.20
- laminas/laminas-i18n: ^2.26
- laminas/laminas-view: ^2.35
- phpunit/phpunit: ^11.5.42
Suggests
- laminas/laminas-authentication: Optional. When a Laminas\Authentication\AuthenticationServiceInterface is registered in the container, the listener mixes the authenticated identity's role into the cache key so two roles never share a cached page.
- laminas/laminas-form: Optional. Enables the bundled FormElement delegator which fires CacheStrategy::EVENT_DISABLE whenever a Csrf element is rendered, preventing CSRF-bearing pages from being cached.
- laminas/laminas-view: Required transitively by laminas-form when the FormElement delegator is in use.
Provides
None
Conflicts
Replaces
None
- dev-main / 2.0.x-dev
- v2.0.0-RC1
- 0.x-dev
- v0.3.0
- v0.2.0
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- 0.1.0
- dev-release/2.0.0-RC1
- dev-fix/review-followups
- dev-auto/issue-9
- dev-chore/contenir-qa-tools
- dev-chore/tidy
- dev-chore/rename-package
- dev-refactor/csrf-helper-decorator
- dev-ci/infection
- dev-ci/contenir-qa-tools
- dev-v2/qa-tools
This package is auto-updated.
Last update: 2026-10-07 06:05:26 UTC
README
Formerly contenir/contenir-cache-laminas-mvc, and before that contenir/cache-laminas-mvc.
See UPGRADE-page-cache.md to move from either.
Laminas MVC page-cache adapter for Contenir CMS.
It stores pages through laminas/laminas-cache and reads its settings as a
CacheControl from
contenir/contenir-page-cache,
the same state the Mezzio adapter and the Contenir admin use.
A page-cache MvcEvent listener with the legacy cache_with_* /
make_id_with_* shape preserved, driven by the standard pagecache
config key. Admin-side toggles (pagecache.options.cache, per-route
overrides) are read from the admin's pagecache.local.php on every
request, so they apply immediately — no in-band purge signal on the
request path.
Install
composer require contenir/contenir-page-cache-laminas-mvc:^2.0@RC
Requires PHP 8.3, 8.4 or 8.5, laminas-mvc 3.7+, laminas-cache 3.12+ and
laminas-servicemanager 3.22+ or 4. The 0.x releases, which support PHP 8.1,
remain available from the 0.x branch and v0.* tags; see
UPGRADE-2.0.md.
The Module is auto-registered by laminas/laminas-component-installer.
Public API
| Class | Purpose |
|---|---|
Module |
Laminas module: getConfig() returns the ConfigProvider config; onBootstrap() attaches the listener. attachListener($events, $listener) does the attaching. |
ConfigProvider |
__invoke(), getDependencies(), getPageCacheDefaults(), getViewHelperConfig(). |
Factory\CacheStrategyFactory |
Builds the listener from config[events][CacheStrategy::class] and config[pagecache]. |
Factory\LayeredFileRepositoryFactory |
Builds the LayeredFileRepository the listener reads when the container registers no CacheControlRepositoryInterface: config[pagecache][file] over config[pagecache][options] and [routes]. DEFAULT_FILE is config/autoload/pagecache.local.php. |
Factory\FormCsrfDisableCacheFactory |
Builds View\Helper\FormCsrfDisableCache from the Application event manager and the formhidden helper. |
Listener\CacheStrategy |
The listener: attach(), detach(), onDispatch(), onFinish(), disable(), setCache(), setRepository(), setAuthenticationService(), and the EVENT_DISABLE constant. |
View\Helper\Delegator\FormElementDisableCacheDelegator |
Maps Csrf elements on the FormElement helper to View\Helper\FormCsrfDisableCache. |
View\Helper\FormCsrfDisableCache |
Fires EVENT_DISABLE, then renders a Csrf element through formhidden. |
All classes are final.
Configure
Point the listener at a Laminas cache storage service ID, declare the event-manager identifiers/events to attach to, and (optionally) defaults plus per-route overrides:
// config/autoload/pagecache.global.php use Contenir\PageCache\Laminas\Mvc\Listener\CacheStrategy; return [ 'pagecache' => [ // Service ID resolving to a Laminas\Cache\Storage\StorageInterface. 'cache' => 'cache.pagecache', // Default per-request options. The `cache` flag is the master // enable switch — admin's pagecache.local.php overrides it // without touching siblings. 'options' => [ 'cache' => true, 'cache_with_query' => true, 'cache_with_session' => false, 'ttl' => 600, ], // Optional regex => options-overrides. 'routes' => [ '/api.*' => ['cache' => false], ], // Optional: the admin's override file, when it isn't // config/autoload/pagecache.local.php under the application root. // 'file' => __DIR__ . '/pagecache.local.php', ], // Shared-event-manager attachments. The keys are SharedEventManager // identifiers (typically Application::class); the values are // event-name => priority pairs. Event names map to listener methods // via `'on' . ucwords($event)` — so 'dispatch' → onDispatch, // 'finish' → onFinish. 'events' => [ CacheStrategy::class => [ \Laminas\Mvc\Application::class => [ 'dispatch' => -100, 'finish' => 100, ], ], ], ];
The factory throws a RuntimeException when pagecache.cache is not a
non-empty service ID, or when that service is not a
Laminas\Cache\Storage\StorageInterface. Malformed entries are skipped:
event identifiers and event names that are not strings, options without a
string name, and route overrides that are not arrays. An event priority that
is not an integer falls back to the attach() priority. An event name with no
matching on*() listener method (only dispatch and finish exist) throws
an InvalidArgumentException from attach().
options.ttl may be an integer or numeric string; anything else leaves the
storage's own TTL in place.
The admin's pagecache.local.php is read on every request through
contenir-page-cache's Repository\LayeredFileRepository and laid over the
defaults above, so a change applies on the next request even with a cached
merged config:
- a setting absent from the file inherits the default here, so flipping the master toggle keeps the operator's other options;
- if the file lists
routes, they replacepagecache.routes, because the admin manages routes as one list.
The default file is config/autoload/pagecache.local.php under the working
directory. The Laminas skeleton's public/index.php changes to the
application root, so that is usually right; if your entry script does not,
set pagecache.file to an absolute path, or the admin's overrides (including
the master toggle) will never be read. The file is a small PHP array that
OPcache keeps compiled, so reading it on every request, cache hits included,
costs an include and a timestamp check. A missing or unreadable file leaves
the defaults above in force.
To supply the state another way, register a
Contenir\PageCache\CacheControlRepositoryInterface service; the factory
uses it instead.
The Module attaches the listener for you on bootstrap — there's nothing
to wire in your Site's own Application\Module.
Console and tests
The listener never caches under the cli SAPI, so console tools and your
application's functional tests always see live responses. The SAPI is the
listener's second constructor argument and defaults to PHP_SAPI; build it
yourself with another value only when you need to exercise caching from the
CLI:
$listener = (new CacheStrategy($events, sapi: 'fpm-fcgi'))->setCache($storage);
Optional: auth-aware cache keys
If the Site has authenticated frontend users and a cached page should
not be shared between roles, register a service for
Laminas\Authentication\AuthenticationServiceInterface. The factory
will pull it via setAuthenticationService() and the role identifier
will be mixed into the cache key. Without it, the role-suffix branch
silently no-ops — fine for purely-public sites. An identity that has no
getRoleId() method (for example a plain username string), or whose role is
not a scalar, makes the page uncacheable for that request, since its content
may be personal.
CSRF-aware caching
A page that renders a Laminas\Form\Element\Csrf token is per-user and
must not be cached — the token is bound to the user's session, and a
cached HTML page would replay one user's token to the next.
When laminas/laminas-form is installed, the package's
ConfigProvider registers a delegator on the FormElement view
helper that maps the Csrf element class to the package's
FormCsrfDisableCache helper. That helper fires
CacheStrategy::EVENT_DISABLE, then renders the element through
formhidden exactly as laminas-form does, so formElement(), formRow(),
formCollection() and form() all mark the page uncacheable. The delegator
keeps the FormElement instance laminas-form built, so anything that
expects one (formRow() does) still gets it. The listener attaches to that event on the same
identifier(s) it uses for dispatch/finish; on receipt it flips an
internal disabled flag, and onFinish skips storage. Pages with
forms render normally; only the caching of those pages is suppressed.
To opt out:
// config/autoload/pagecache.local.php return [ 'pagecache' => [ 'disable_on_csrf' => false, ], ];
When disable_on_csrf is false, or the container has no MVC Application
service, the delegator returns the original FormElement helper untouched
(no overhead, no event firing). A Csrf element rendered directly with
formHidden() or formInput(), bypassing FormElement, does not fire the
event.
For non-Laminas-form CSRF rendering, or any other reason a page must opt out at runtime, fire the event yourself from anywhere in the request lifecycle:
$em->trigger(\Contenir\PageCache\Laminas\Mvc\Listener\CacheStrategy::EVENT_DISABLE);
…or grab the listener service and call disable() directly.
How it works
Listener\CacheStrategy typically attaches to MvcEvent::EVENT_DISPATCH
(early) and EVENT_FINISH (late). On the inbound pass it builds the
cache key from the configured request signals, returns a stored response
when one exists, and short-circuits dispatch. On the outbound pass it
stores the final response when the active options say to cache it.
pagecache.options.cache = false disables the listener entirely for
the request — useful as an admin-controlled kill switch and as a
per-route override for endpoints that must never be cached.
CacheStrategy::EVENT_DISABLE ('pagecache.disable') lets per-render
opt-out signals reach the listener: anything in the request that knows
the response is not safe to cache (CSRF tokens, flash messages,
authenticated banners) can fire the event and the listener will
short-circuit onFinish for that request.
What's in the cache key
The key is md5() of:
- Host —
$request->getUri()->getHost(). Multi-host deployments never cross-pollute. - Path —
$request->getUri()->getPath(). Accept-Encodingrequest header value — so a gzipped response stored against agzip-accepting client is never served to a client that didn't advertise gzip support.- Authenticated role suffix —
$identity->getRoleId()when anAuthenticationServiceInterfaceservice is registered and an identity is present. No service registered ⇒ this branch no-ops (fine for purely-public sites). An identity without a usable role ⇒ the request is not cached. - Superglobal hashes —
md5(serialize($vars))for each ofquery,post,files,cookiewhosemake_id_with_*flag is true. Whose presence withcache_with_*set false short-circuits caching entirely for the request.
Anything not in this list — User-Agent, Referer, custom X-*
headers, third-party tracking cookies your app never reads — is
invisible to the cache by design. The cache assumes the response
is a pure function of the inputs above. If a controller varies its
response on something outside that set (e.g. UA-sniffing for mobile
markup) without keying on it, that's a poisoning bug in the
controller, not the cache.
What's never cached
The listener short-circuits in onDispatch for any of:
-
non-
GET/HEADrequest methods (so file-uploadPOSTs don't even buffer through the cache layer) -
Range:request header present (don't cache 206 partial responses as if they were full) -
Authorization:request header present (per-user credentials ⇒ per-user response) -
a disabled listener, a missing cache storage, or the
cliSAPI
A stored entry that is not a Laminas\Http\Response, or an application
response that is not a Laminas\Http\PhpEnvironment\Response, is treated as
a miss, and the fresh response replaces the entry.
…and in onFinish for any response with a status code other than
200 OK (catches 304, 301/302 redirects, 404/5xx errors).
These are unconditional — there is no config flag to turn them off. If you have a route that genuinely needs to cache a non-200 response, that's a different design problem than this listener is built for.
Purging
Purging is not this listener's responsibility. Admin tooling that wants to clear cached pages (or specific keys) talks to the same cache storage backend directly — the Site config tells it which adapter the listener is wrapping.
Development
The QA toolchain is contenir/contenir-qa-tools.
Mago is a standalone binary, installed
separately (brew install mago).
composer check # everything below composer cs-check # mago format --check && mago lint composer static-analysis # mago analyze composer test # unit suite: collaborators doubled, no I/O composer test-integration # integration suite: real event manager, memory cache, service and helper managers composer test-coverage # both suites, clover.xml for Codecov composer mutation-test # Infection mutation testing over both suites
License
MIT. See LICENSE.