contenir / contenir-asset-mezzio
Mezzio adapter for Contenir assets — keyed, profile-driven responsive image variants (incl. WebP/AVIF) backed by contenir/contenir-storage.
Requires
- php: ~8.3.0 || ~8.4.0 || ~8.5.0
- contenir/contenir-asset: ^2.0.0-RC2@RC
- contenir/contenir-storage: ^2.4
- psr/container: ^1.1 || ^2.0
- psr/http-factory: ^1.0.2
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0.2
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/console: ^6.4.10 || ^7.1.3
Requires (Dev)
- contenir/contenir-qa-tools: 0.1.x-dev
- infection/infection: ^0.34.1
- laminas/laminas-diactoros: ^3.3
- laminas/laminas-servicemanager: ^3.22
- laminas/laminas-view: ^2.33
- mezzio/mezzio: ^3.18
- mezzio/mezzio-fastroute: ^3.11
- phpunit/phpunit: ^11.5.42
- symfony/string: ^6.4.10 || ^7.1.3
Suggests
- laminas/laminas-cli: To run the storage:variants command as vendor/bin/laminas storage:variants.
- laminas/laminas-diactoros: Or any other PSR-7/PSR-17 implementation registered under the PSR-17 factory interfaces.
- mezzio/mezzio-laminasviewrenderer: To call the view services as storageSrcSet(), storageSizes(), storageSources() and storageUrl() in laminas-view templates.
Provides
None
Conflicts
None
Replaces
None
- v2.3.2
- v2.3.1
- v2.3.0
- v2.2.0
- v2.1.0
- dev-main / 2.0.x-dev
- v2.0.0
- dev-release/2.3.2
- dev-fix/vector-variants
- dev-release/2.3.0
- dev-release/2.2.0
- dev-feat/sources-sizes
- dev-feat/asset-redirects
- dev-feat/animated-originals
- dev-feature/legacy-resize-handler
- dev-ci/push-trigger
- dev-chore/contenir-qa-tools
- dev-chore/tidy
- dev-feat/initial
This package is auto-updated.
Last update: 2026-11-05 03:20:17 UTC
README
Mezzio adapter for Contenir assets: keyed, profile-driven responsive image variants (including WebP and AVIF) on top of contenir/contenir-storage. Use it on a Mezzio site that does not run the full CMS.
A template names one profile (for example 'card') and gets the whole
responsive set: srcset ladder, sizes attribute and <picture> sources in
extra formats. The profiles are the same storage.variants declarations the
CMS and contenir/contenir-storage read, so there is one source of truth.
- Template services:
StorageSrcSet,StorageSizes,StorageSourcesandStorageUrl. They are plain invokable services, so any template engine can call them, and laminas-view picks them up asstorageSrcSet()and so on. - On-demand variants: two PSR-15 handlers.
AssetVariantHandlergenerates a missing local variant on its first request and returns it.AssetVariantGenerateHandleris a secret-guarded endpoint an edge worker calls to generate a missing S3/R2 sibling. - CLI:
storage:variantsaudits a backend and can backfill it.
Requirements
- PHP 8.3, 8.4 or 8.5
contenir/contenir-storage2.2+contenir/contenir-asset2.0 (RC2 or later) for the legacy resize handler- A PSR-7/PSR-17 implementation, with its factories registered in the container as
Psr\Http\Message\ResponseFactoryInterfaceandPsr\Http\Message\StreamFactoryInterface(laminas/laminas-diactoros's ConfigProvider does this) symfony/console6.4.10+ or 7.1.3+ for the command. Installlaminas/laminas-clito run it asvendor/bin/laminas storage:variants- ImageMagick (the imagick extension or the
magick/convertCLI) for local generation - Optional:
mezzio/mezzio-laminasviewrendererto call the template services as laminas-view helpers
No class in src/ depends on a particular PSR-7 implementation, router,
template engine or laminas-mvc.
Installation
composer require contenir/contenir-asset-mezzio
With laminas/laminas-component-installer, the Contenir\Asset\Mezzio\ConfigProvider is
added to config/config.php for you. If you don't use it, add the provider yourself:
$aggregator = new ConfigAggregator([ // ... \Contenir\Asset\Mezzio\ConfigProvider::class, ]);
Configuration
Everything goes under the storage key that contenir/contenir-storage reads:
// config/autoload/storage.global.php return [ 'storage' => [ 'backend' => [ 'local' => ['type' => 'local', 'root_path' => 'public', 'public_path' => '/asset'], // or an S3/R2 primary: // 'r2' => ['type' => 's3', 'default' => true, 'publicUrl' => 'https://cdn…', 'generate_secret' => '…', …], ], 'variants' => [ 'admin-thumb' => ['width' => 180, 'height' => 180, 'fit' => 'contain'], 'card' => [ 'dimensions' => ['320x', '640x', '960x'], 'sizes' => '(min-width: 768px) 33vw, 100vw', 'formats' => ['avif', 'webp'], ], ], ], ];
The primary backend sets the URL scheme: _variant/<name>/ for a local
backend, <key>__<name>.<ext> siblings on S3/R2. The command and the generate
endpoint also need a Contenir\Storage\StorageManager service, which the
application registers. See docs/configuration.md.
Routing
Routes are not registered automatically, so the application decides which
paths are public. Add them in config/routes.php:
use Contenir\Asset\Mezzio\Handler\AssetVariantGenerateHandler; use Contenir\Asset\Mezzio\Handler\AssetVariantHandler; // Local backends: a variant missing from public/asset/…/_variant/… is generated on first request. $app->get('/asset/{path:.+}', AssetVariantHandler::class, 'asset.variant'); // S3/R2 backends: the edge worker's miss-proxy. $app->route('/asset-variant/generate', AssetVariantGenerateHandler::class, ['GET', 'POST'], 'asset.variant.generate');
AssetVariantHandler reads the folder, name and filename from the request
path itself (/asset/<folder>/_variant/<name>/<filename>). It ignores route
attributes, so any router and pattern that sends those paths to it works, and
any other path gets a 404.
A site still emitting contenir-asset's deprecated .WxH scheme through its
assetSrcSet() routes ImageResizeHandler the same way; it reads
/asset/<folder>/.<WxH>/<filename> from the path and resizes through the
core's LegacyResizer (configure asset.srcset.helper with the ImageMagick
binary, or it answers 501):
use Contenir\Asset\Mezzio\Handler\ImageResizeHandler; $app->get('/asset/{folder:.+}/.{dimensions}/{filename}', ImageResizeHandler::class, 'asset.resize');
Template services
| Service (laminas-view helper) | Returns |
|---|---|
View\StorageSrcSet (storageSrcSet) |
url 320w, url 640w… over the profile's ladder, in the source format |
View\StorageSizes (storageSizes) |
The profile's sizes value, or '' |
View\StorageSources (storageSources) |
One <source type="image/…"> per profile format, with sizes. $lazy writes data-lazysrc-srcset instead of srcset |
View\StorageUrl (storageUrl) |
The original URL, or the URL of one variant (optionally in another format) |
<picture>
<?= $this->storageSources($asset->path, 'card') ?>
<img srcset="<?= $this->escapeHtmlAttr($this->storageSrcSet($asset->path, 'card')) ?>"
sizes="<?= $this->storageSizes('card') ?>"
src="<?= $this->escapeHtmlAttr($this->storageUrl($asset->path, 'card-320')) ?>"
alt="">
</picture>
With Twig, Plates or another engine, fetch the services from the container and register them as functions. See docs/template-services.md.
The site's own assets
siteAssets() writes the site's own stylesheets, scripts, fonts and icons, from a site_assets config block, as
markup for any template engine:
'site_assets' => [ 'path' => '/asset', 'version' => '1.1.9', // /asset/css/1.1.9/app.css; leave out for /asset/css/app.css 'stylesheets' => ['css/app.css'], 'scripts' => ['js/app.js'], // preloaded in <head>, written at the end of <body> 'attributes' => ['data-router-track' => 'reload'], // on each stylesheet and script 'preload_fonts' => ['font/regular.woff2'], // at the address the stylesheet asks for, unversioned 'inline_css' => ['css/inline.css'], // read from public_directory (public/) into a <style> 'icons' => [ ['rel' => 'apple-touch-icon', 'sizes' => '180x180', 'href' => '/apple-touch-icon{theme}.png'], ['rel' => 'icon', 'type' => 'image/png', 'sizes' => '32x32', 'href' => '/favicon-32x32{theme}.png'], ['rel' => 'manifest', 'href' => '/site.webmanifest'], ], 'theme_colors' => ['default' => '#FAF8F5', 'copper' => '#B87333'], 'js_class' => true, // a "js" class on <html> before the page renders ],
<head>
<?= $this->siteAssets()->head($theme, $nonce) ?>
</head>
<body>
...
<?= $this->siteAssets()->bodyEnd($nonce) ?>
</body>
A page's theme picks its icons, with {theme} in their address as -copper (none for the default theme), and its
theme-color. The versioned paths are for the web server to map back to the files, so a new version is fetched
afresh.
Handlers and services
| Class | Role |
|---|---|
Handler\AssetVariantHandler |
Serves /asset/<folder>/_variant/<name>/<filename>, generating a missing variant first |
Handler\AssetVariantGenerateHandler |
Generates an S3/R2 sibling for an edge worker, guarded by X-Asset-Generate-Secret |
Service\ProfileProviderService |
Typed Profile/Variant views of storage.variants |
Service\VariantGenerator |
Finds the original on disk and writes the variant with an ImageResizerInterface |
Service\OnDemandVariantResolver |
Hands a sibling key to the backend that can generate it |
Service\AssetUrlBuilder |
Builds original and variant URLs, percent-encoded, for both schemes |
Security\PathGuard |
Refuses traversal in decoded request paths (see Security) |
Command\VariantsCommand |
storage:variants |
Every class is final. To swap the resizer, register your own
Contenir\Storage\Image\ImageResizerInterface service, which overrides the
default alias to ImageResizer. See
docs/variant-serving.md and docs/cli.md.
Security
-
Path traversal. The folder, variant name and filename from the request are percent-decoded once and then checked by
Security\PathGuardbefore anything touches the filesystem. The request gets an empty 404, and nothing is read or written, when a value contains:- a
..segment; - a null byte or a backslash;
- a percent-encoded dot, slash, backslash or null byte that is still there
after decoding (
%252e%252edecodes to%2e%2e, which is refused); - a separator in the name or the filename.
The generate endpoint applies the same check to
keybefore any backend sees it. - a
-
Generation secret.
AssetVariantGenerateHandleranswers 503 until the primary backend has agenerate_secret. The header is compared withhash_equals(). All its responses are sent withCache-Control: no-store.
Coming from contenir-asset-laminas-mvc
| laminas-mvc 2.2 | Mezzio 2.0 |
|---|---|
Module + ConfigProvider (router, controllers, service_manager, view_helpers, laminas-cli) |
ConfigProvider only: dependencies, view_helpers, laminas-cli |
assetvariant Regex route → Controller\AssetVariantController::indexAction() |
Handler\AssetVariantHandler, routed by the app ($app->get('/asset/{path:.+}', …)) |
assetvariant-generate Literal route → Controller\AssetVariantGenerateController::generateAction() |
Handler\AssetVariantGenerateHandler, routed by the app |
imageresize Regex route → Controller\ImageResizeController::resizeAction() (2.3) |
Handler\ImageResizeHandler (2.1), routed by the app |
Controller\Factory\* |
Handler\Factory\* (also needs the PSR-17 response and stream factories) |
View\Helper\StorageUrl, StorageSrcSet, StorageSources, StorageSizes (extend AbstractHelper) |
View\StorageUrl, StorageSrcSet, StorageSources, StorageSizes: plain invokables, same arguments and output, registered under the same helper names |
Unknown profile or variant: E_USER_WARNING |
A PSR-3 warning to the container's Psr\Log\LoggerInterface, if there is one. Mezzio's error handler would turn the warning into a 500 |
Service\*, Profile\Profile, Command\VariantsCommand, Container\Services |
The same classes and behaviour under Contenir\Asset\Mezzio\… |
VariantGenerator ../null-byte folder guard |
Security\PathGuard, extended to backslashes, encoded leftovers, and the name, filename and generate key |
Content-Type by mime_content_type() |
Content-Type by the served file's extension (no ext-fileinfo) |
Cache-Control: max-age=31536000, public |
Cache-Control: public, max-age=31536000 |
| Generate endpoint: 400 for a non-HTTP request | Not applicable: a PSR-15 handler only receives HTTP requests |
storage.asset defaults (root_path, public_path), unused since 0.5 |
Dropped. The factories read the primary backend, as before |
Config keys storage.backend.* (type, root_path, public_path, publicUrl, public_base_url, binary, generate_secret) and storage.variants |
Unchanged |
Steps:
- Replace
contenir/contenir-asset-laminas-mvcwithcontenir/contenir-asset-mezzio. - Change
Contenir\Asset\Laminas\Mvc\imports toContenir\Asset\Mezzio\(View\Helper\Xis nowView\X, andController\is nowHandler\). - Add the two routes above to
config/routes.php. - Keep
config/autoload/storage.global.phpas it is.
Documentation
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: no I/O, collaborators doubled composer test-integration # integration suite: real files, ImageMagick, a real Mezzio pipeline composer test-coverage # both suites, clover.xml for Codecov composer mutation-test # Infection over both suites (needs Xdebug or PCOV)
On a case-insensitive file system (the macOS default), the uppercase-extension
test is skipped, and two VariantGenerator case-folding mutants cannot be
killed. CI runs on Linux, which kills them.
License
MIT. See LICENSE.