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-storage: ^2.2
- 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)
- 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
- php-db/phpdb-qa-tools: 0.1.x-dev
- 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
This package is auto-updated.
Last update: 2026-10-06 00:49:07 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+- 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.
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.
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 |
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 php-db/phpdb-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.