PHPackages                             chamber-orchestra/image-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Templating &amp; Views](/categories/templating)
4. /
5. chamber-orchestra/image-bundle

ActiveSymfony-bundle[Templating &amp; Views](/categories/templating)

chamber-orchestra/image-bundle
==============================

Symfony 8 bundle for on-demand image processing, resizing, cropping, AVIF/WebP conversion, and caching. Filter pipelines with fit, fill, optimize, strip, interlace processors and avifenc, cwebp, MozJPEG, pngquant post-processors. Filesystem, stream, and S3 loaders/resolvers. HMAC-signed runtime URLs for any client. Async processing via Messenger. Twig filters and responsive &lt;picture&gt; macros. Nginx/Apache/Caddy zero-PHP cache hits.

v8.0.8(1mo ago)2464↑33.3%1MITPHPPHP ^8.5CI passing

Since Feb 13Pushed 1mo agoCompare

[ Source](https://github.com/chamber-orchestra/image-bundle)[ Packagist](https://packagist.org/packages/chamber-orchestra/image-bundle)[ Docs](https://github.com/chamber-orchestra/image-bundle)[ RSS](/packages/chamber-orchestra-image-bundle/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependencies (44)Versions (12)Used By (1)

ChamberOrchestra ImageBundle
============================

[](#chamberorchestra-imagebundle)

[![PHP Composer](https://github.com/chamber-orchestra/image-bundle/actions/workflows/php.yml/badge.svg)](https://github.com/chamber-orchestra/image-bundle/actions/workflows/php.yml)[![PHPStan](https://camo.githubusercontent.com/745eb989b9e4903dc598fe2cc63ed4226198be55b7c729001cbd1ece7676fef6/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6d61782d627269676874677265656e2e737667)](https://phpstan.org/)[![PHP-CS-Fixer](https://camo.githubusercontent.com/6ea88fbe545f6f06950dd97b31be7621fcb0a0056644de2ea36e44b7de33adc4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d5045522d2d435325323025324625323053796d666f6e792d626c75652e737667)](https://cs.symfony.com/)[![Latest Stable Version](https://camo.githubusercontent.com/2e6bebde4b1c060cdd016eb46fd5a2ea61678ab5b3f3cf06fd098c0ddeb988e1/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6368616d6265722d6f72636865737472612f696d6167652d62756e646c652e737667)](https://packagist.org/packages/chamber-orchestra/image-bundle)[![Total Downloads](https://camo.githubusercontent.com/5732005a832857bea225bde8bc57a403505ac4726ca47a96b80cd07141e47c5b/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6368616d6265722d6f72636865737472612f696d6167652d62756e646c652e737667)](https://packagist.org/packages/chamber-orchestra/image-bundle)[![License: MIT](https://camo.githubusercontent.com/08cef40a9105b6526ca22088bc514fbfdbc9aac1ddbf8d4e6c750e3a88a44dca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d626c75652e737667)](LICENSE)[![PHP 8.5+](https://camo.githubusercontent.com/2371eeb1a98f81a6894947d4d7b429326ee7f4dbeb3d8940776b4ae7b8442725/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e352532422d3737374242342e737667)](https://www.php.net/)[![Symfony 8.0](https://camo.githubusercontent.com/daaa476b3cc456701380f7d0fbdc3bbe9983e89d3267f99870daa88aa719e181/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53796d666f6e792d382e302d3030303030302e737667)](https://symfony.com/)

**On-demand image processing, resizing, AVIF/WebP conversion, and caching for Symfony 8.**

A Symfony 8 bundle for image resizing, cropping, format conversion (AVIF, WebP, PNG, JPEG), and optimisation. Process images on demand through named **filter pipelines** — chains of processors (fit, fill, strip, interlace, optimize, output) and post-processors (avifenc, cwebp, MozJPEG, pngquant). Cache results to the local filesystem or S3-compatible storage and serve them via a controller that issues a `301` redirect to the cached URL. Nginx, Apache, and Caddy can serve cached images directly from disk — zero PHP overhead on cache hits.

Built for PHP 8.5 and Symfony 8. A modern, type-safe alternative to LiipImagineBundle.

---

Table of contents
-----------------

[](#table-of-contents)

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration) — full reference with all defaults
- [Usage](#usage) — Twig filters, macros, PHP API, serializer attribute
- [Processors](#processors) — fit, fill, optimize, strip, interlace, output
- [Post-processors](#post-processors) — avifenc, cwebp, mozjpeg, pngquant
- [Loaders](#loaders) — filesystem, stream, S3
- [Resolvers](#resolvers) — web\_path, S3, PSR-6 cache decorator
- [Runtime filters](#runtime-filters) — HMAC-signed URLs, client SDKs (TypeScript, React, Next.js, Vue, Swift, Kotlin)
- [Cache invalidation](#cache-invalidation)
- [Web server configuration](#web-server-configuration) — nginx, Apache, Caddy
- [Extension points](#extension-points)
- [Testing](#testing)
- [License](#license)

---

Features
--------

[](#features)

- **On-demand image processing** — images are processed at first request and cached permanently
- **Filter pipelines** — compose processors and post-processors per named filter
- **Processors**: `fit`, `fill`, `strip`, `interlace`, `output`, `optimize` — resize, crop, strip metadata, set format
- **Post-processors**: `avifenc` (AVIF), `cwebp` (WebP), `mozjpeg` (JPEG optimisation), `pngquant` (PNG compression)
- **AVIF and WebP support** — convert images to next-gen formats via CLI post-processors or Imagick
- **Runtime filters** — pass processor options at runtime via HMAC-signed URLs
- **Runtime image URLs** — nginx-cacheable `GET /media/{pathHash}/{optionsHash}/{name}.{format}` endpoint with HMAC-validated parameters, usable from any client with the shared secret
- **Loaders**: `filesystem` (with path-traversal protection), `stream` (any PHP stream wrapper), `s3` (S3-compatible storage)
- **Resolvers**: `web_path` (filesystem + public URL), `s3` (S3/MinIO/DigitalOcean Spaces), `cache` (PSR-6 decorator)
- **Twig filters**: `image_filter`, `fit`, `fill`, `optimize`
- **Twig macros**: responsive `` elements with AVIF/WebP/fallback srcsets and CSS background helpers
- **Retina/HiDPI** — built-in pixel density multiplier for 2x and 3x output
- **Async processing** — offload image processing to Symfony Messenger workers (requires `symfony/messenger`)
- **Concurrency control** — limit parallel processing workers via distributed locks (requires `symfony/lock`)
- **Content-addressed caching** — URL changes when the image or options change; safe for `Cache-Control: immutable`
- **Auto cache invalidation** via event subscriber (integrates with `chamber-orchestra/file-bundle`)
- **Imagick, GD, Gmagick** drivers — configurable via short aliases (`gd`, `imagick`, `gmagick`) or FQCN
- **Configurable binary paths** — set paths for `pngquant`, `cjpeg`, `cwebp`, `avifenc` in bundle config
- **SSRF protection** — `StreamLoader` validates URI schemes (both `scheme://` and compact `scheme:` forms) against an allowlist (default: `file`, `data`)
- **DoS protection** — pixel budget (25 MP), density cap (4x), and post-processor timeout cap (300s) prevent resource exhaustion
- **S3 hardening** — `Cache-Control` headers, optional ACL, dangerous extension blocking on upload
- **Cache optimisation** — `resolveIfStored()` combines existence check + URL resolution in a single pass
- **Serializer attribute** — repeatable `#[ImageFilter]` generates keyed image URLs in API responses with configurable formats, densities, and cached metadata (requires `symfony/serializer`)
- **Client SDKs** — TypeScript, React, Next.js, Vue, Swift, and Kotlin signing implementations included

---

Requirements
------------

[](#requirements)

DependencyVersionPHP`^8.5`Symfony`8.0.*`imagine/imagine`^1.3`psr/cache`^3.0`ext-exif`*`ext-imagick *(recommended)*any**Optional CLI binaries:** `avifenc`, `cwebp`, `cjpeg` (MozJPEG), `pngquant`**Optional package:** `aws/aws-sdk-php` (for S3 loader/resolver)

---

Installation
------------

[](#installation)

```
composer require chamber-orchestra/image-bundle
```

### Register the bundle

[](#register-the-bundle)

```
// config/bundles.php
return [
    // ...
    ChamberOrchestra\ImageBundle\ChamberOrchestraImageBundle::class => ['all' => true],
];
```

### Import routes

[](#import-routes)

```
# config/routes/chamber_orchestra_image.yaml
_chamber_orchestra_image:
    resource: '@ChamberOrchestraImageBundle/Resources/config/routing.php'
```

---

Configuration
-------------

[](#configuration)

### Full reference (all defaults)

[](#full-reference-all-defaults)

```
# config/packages/chamber_orchestra_image.yaml
chamber_orchestra_image:

    # Imagine driver — short alias or FQCN
    # Aliases: gd | imagick | gmagick
    # FQCN:   Imagine\Imagick\Imagine | Imagine\Gd\Imagine | Imagine\Gmagick\Imagine
    driver: imagick                   # default (resolves to Imagine\Imagick\Imagine)

    # Default resolver name (must match a key under "resolvers")
    resolver: default                 # default

    # Default loader name (must match a key under "loaders")
    loader: default                   # default

    # Fallback image path when the source image cannot be loaded (null = 404)
    default_image: ~                  # default: null

    # Filesystem path where the client endpoint writes cached images
    cache_path: '%kernel.project_dir%/public/media'   # default

    # URL prefix for client endpoint cache paths
    cache_prefix: /media              # default

    # Async image processing via Symfony Messenger
    # true = always async, false = always sync, 'auto' = async when symfony/messenger is installed
    async: auto                       # default

    # Max concurrent image processing workers (0 = unlimited, requires symfony/lock)
    concurrency: 0                    # default

    # Serializer normalizer settings (requires symfony/serializer)
    serializer:
        formats: [avif, webp]         # default — output formats generated by #[ImageFilter]
        # Valid values: any of png, jpg, jpeg, webp, gif, tiff, bmp, avif

    # Paths to external CLI binaries used by post-processors
    binaries:
        pngquant: /usr/bin/pngquant           # default
        mozjpeg: /opt/mozjpeg/bin/cjpeg       # default
        cwebp: /usr/local/bin/cwebp           # default
        cwebp_lib: /usr/local/lib             # default — LD_LIBRARY_PATH for cwebp
        avifenc: /usr/local/bin/avifenc       # default

    # PSR-6 cache layer for resolver lookups (wraps resolvers with CacheResolver)
    cache:
        enabled: ~                    # default: null (auto: true in prod, false in debug)
        service: cache.app            # default — any PSR-6 CacheItemPoolInterface service
        lifetime: 3600                # default — cache TTL in seconds

    # Named loaders — retrieve source image binaries
    # When omitted, a "default" filesystem loader is registered automatically
    loaders:
        default:
            type: filesystem          # filesystem | stream | s3

            # --- filesystem loader options ---
            data_root:                # list of directories to search for source images
                - '%kernel.project_dir%/public'   # default
            locator: ChamberOrchestra\ImageBundle\Binary\Locator\FileSystemLocator   # default
            # Alternative: ChamberOrchestra\ImageBundle\Binary\Locator\FileSystemInsecureLocator

            # --- stream loader options ---
            # wrapper_prefix: ''      # prepended to path before file_get_contents()
            # context: ~              # optional PHP stream context service
            # allowed_schemes:        # SSRF protection — URI schemes the loader may access
            #     - file              # default
            #     - data              # default
            #     - https             # add explicitly to allow remote fetching

            # --- s3 loader options (requires chamber-orchestra/file-bundle) ---
            # storage: default        # name of the file-bundle storage

    # Named resolvers — store cached images and resolve browser-accessible URLs
    # When omitted, a "default" web_path resolver is registered automatically
    resolvers:
        default:
            type: web_path            # web_path | s3 | custom

            # --- web_path resolver options ---
            web_root: '%kernel.project_dir%/public'   # default
            cache_prefix: media       # default — subdirectory under web_root

            # --- s3 resolver options (requires aws/aws-sdk-php) ---
            # bucket: my-bucket       # required
            # region: us-east-1       # default
            # endpoint: ~             # optional: https://minio.example.com
            # uri_prefix: ~           # optional: https://cdn.example.com
            # cache_prefix: media     # default — S3 key prefix
            # cache_control: 'public, max-age=31536000'  # default — Cache-Control header on uploaded objects
            # acl: ~                  # optional: S3 ACL (e.g. 'public-read')

            # --- custom resolver options ---
            # service: App\MyResolver # service ID implementing ResolverInterface

    # Named filter pipelines
    filters:

        # The "default" filter is used by Twig convenience filters: fit(), fill(), optimize()
        # It must be defined if you use those filters
        default:
            output:
                quality: 80
                optimize: false
                flatten: true
            processors:
                interlace:
                    mode: partition   # best progressive rendering
                strip: {}             # remove EXIF, ICC profiles
            post_processors:
                mozjpeg:
                    quality: 80       # good JPEG compression with minimal visual loss
                pngquant:
                    quality: '65-80'  # aggressive but clean PNG quantisation
                cwebp:
                    quality: 78       # WebP sweet spot: ~30-40% smaller than JPEG at similar quality
                avifenc:
                    quality: 60       # AVIF is efficient — lower values still look great

        score_thumbnail:              # filter name (used in Twig: image_filter('score_thumbnail'))

            # Override resolver/loader for this filter (null = use global default)
            resolver: ~               # default: null (inherits from global "resolver")
            loader: ~                 # default: null (inherits from global "loader")

            # Per-filter HMAC secret (null = use kernel.secret / APP_SECRET)
            secret: ~                 # default: null

            # Expose this filter to external clients via the /media/ endpoint
            # Requires an explicit "secret" different from APP_SECRET
            exposed: false            # default

            # Override global async setting for this filter (null = use global "async")
            async: ~                  # default: null

            # Fallback image when source is missing (null = use global "default_image")
            default_image: ~          # default: null

            # Output options — control format, quality, and encoding
            output:
                quality: 75           # default — quality for all formats (1-100)
                jpeg_quality: ~       # default: null — override quality for JPEG
                png_compression_level: ~   # default: null
                png_compression_filter: ~  # default: null
                webp_quality: ~       # default: null — override quality for WebP
                avif_quality: ~       # default: null — override quality for AVIF
                format: ~             # default: null — force output format (png|jpg|webp|avif|gif|tiff|bmp)
                optimize: false       # default — enable optimisation
                flatten: true         # default — flatten alpha to background colour
                animated: false       # default — preserve animation frames

            # Processor pipeline — applied in order to the Imagine image
            processors:
                fit:                  # resize to bounding box (preserves aspect ratio, no crop)
                    width: 400
                    height: 300
                    # density: 1.0    # pixel density multiplier (2.0 = retina)
                    # filter: lanczos # resampling filter (auto "undefined" for GD)
                    # background: '#fff'
                    # alpha: 0        # background alpha (0 = opaque)

                # fill:              # resize + centre-crop to exact dimensions
                #     width: 400
                #     height: 400
                #     density: 1.0
                #     filter: lanczos
                #     background: '#fff'
                #     alpha: 0

                # optimize:          # fit without upscaling (preserves original if smaller)
                #     width: 1200
                #     height: 0
                #     density: 2

                # strip: {}          # remove EXIF, ICC profiles, metadata

                # interlace:         # progressive JPEG / interlaced PNG
                #     mode: line     # none | line | plane | partition

                # output:           # override output format/quality per-processor
                #     format: webp
                #     quality: 85

            # Post-processor pipeline — shell out to CLI binaries on encoded bytes
            post_processors:
                cwebp: {}
                # cwebp:
                #     quality: 90    # default — 0-100
                #     timeout: 30    # default — process timeout in seconds

                # avifenc:
                #     quality: 63    # default — 0-100
                #     speed: 6       # default — 0=slowest/best, 10=fastest
                #     timeout: 60    # default

                # mozjpeg:
                #     quality: 75    # default — 0-100
                #     timeout: 60    # default

                # pngquant:
                #     quality: '80-100'   # default — min-max quality range
                #     timeout: 30         # default
```

### Quick start (minimal)

[](#quick-start-minimal)

Most defaults are sensible out of the box. A minimal configuration only needs a `default` filter and optionally named filters:

```
# config/packages/chamber_orchestra_image.yaml
chamber_orchestra_image:
    filters:
        # Required for Twig fit(), fill(), optimize() filters.
        # Includes a balanced set of post-processors for good compression and quality.
        default:
            output:
                quality: 80
            processors:
                interlace: { mode: partition }
                strip: {}
            post_processors:
                mozjpeg: { quality: 80 }
                pngquant: { quality: '65-80' }
                cwebp: { quality: 78 }
                avifenc: { quality: 60 }

        # Exposed filter for external clients (TypeScript, Swift, Kotlin, etc.)
        # Secret must differ from APP_SECRET — share it with trusted clients out-of-band
        client:
            exposed: true
            secret: '%env(CLIENT_IMAGE_SECRET)%'
            output:
                quality: 80
            processors:
                interlace: { mode: partition }
                strip: {}
            post_processors:
                mozjpeg: { quality: 80 }
                pngquant: { quality: '65-80' }
                cwebp: { quality: 78 }
                avifenc: { quality: 60 }
```

This registers a filesystem loader from `%kernel.project_dir%/public`, a `web_path` resolver writing to `public/media`, Imagick as the driver, and auto-detects Symfony Messenger for async processing — all automatically. The `client` filter exposes the `/media/{pathHash}/{optionsHash}/{name}.{format}` endpoint for use from any HTTP client.

---

Usage
-----

[](#usage)

### Twig filters

[](#twig-filters)

```
{# Apply a named filter #}

{# Fit within a bounding box (aspect-ratio preserved, no crop) #}

{# Fill a fixed box (resize + centre-crop) #}

{# Optimise — scales to width 1200 at 2x density, never upscales #}

{# Runtime filter — options merged at request time (HMAC-signed URL) #}

{# Use a specific named filter instead of 'default' #}

```

> `fit`, `fill`, and `optimize` dispatch through the runtime filter mechanism — no named filter configuration needed. Each accepts an optional `filter` argument (default: `'default'`) to target a specific named filter.

### Twig macros

[](#twig-macros)

The bundle ships responsive `` macros that generate AVIF, WebP, and fallback `` elements with 1x/2x/3x srcset variants. Import and use them in any template:

```
{% import '@ChamberOrchestraImage/macro/image.html.twig' as image %}

{# Responsive  with fit (preserves aspect ratio) #}
{{ image.fit('/scores/symphony_no_5.jpg', 800, 600, {class: 'hero-img', alt: 'Symphony No. 5'}) }}

{# Responsive  with fill (crops to exact dimensions) #}
{{ image.fill('/recordings/conductor_portrait.jpg', 200, 200, {class: 'avatar', alt: 'Conductor'}) }}

{# Pass  element attributes as the 5th argument #}
{{ image.fit('/scores/moonlight_sonata.jpg', 1200, 800, {alt: 'Moonlight Sonata'}, {class: 'picture-wrapper'}) }}
```

Each macro generates three `` groups (AVIF, WebP, fallback) at 1x/2x/3x densities. The `fit` macro calls look like this under the hood:

```
{# What the fit macro generates internally: #}

```

The `fill` macro works identically but uses the `fill` filter (resize + centre-crop) instead of `fit`:

```
{# fill: same structure, different filter #}

{# ...webp and fallback sources follow the same pattern #}
```

#### CSS background macros

[](#css-background-macros)

CSS background macros output inline `style` attributes with custom properties for use with CSS `background-image`. This is useful for hero sections, banners, and other elements where CSS backgrounds are preferred:

```
{# Outputs style="--bg-url: url(...); --bg-url-avif: url(...); --bg-url-webp: url(...); ..." #}

{# Disable 2x/3x variants (1x only) #}

```

Custom properties generated: `--bg-url`, `--bg-url-avif`, `--bg-url-webp`, `--bg-url-2x`, `--bg-url-avif-2x`, `--bg-url-webp-2x`, `--bg-url-3x`, `--bg-url-avif-3x`, `--bg-url-webp-3x`, `--bg-width`, `--bg-height`.

Pair with CSS to select the best format:

```
.hero {
  background-image: var(--bg-url);
  background-size: var(--bg-width) var(--bg-height);
}
@supports (background-image: url("test.avif")) {
  .hero { background-image: var(--bg-url-avif); }
}
@media (min-resolution: 2dppx) {
  .hero { background-image: var(--bg-url-2x); }
  @supports (background-image: url("test.avif")) {
    .hero { background-image: var(--bg-url-avif-2x); }
  }
}
```

### PHP

[](#php)

```
use ChamberOrchestra\ImageBundle\Imagine\Cache\CacheManager;
use ChamberOrchestra\ImageBundle\Service\FilterService;

// Generate a URL (no processing — image processed on first browser hit)
$url = $cacheManager->getBrowserPath('/scores/moonlight_sonata.jpg', 'default', [
    'processors' => ['fit' => ['width' => 800, 'height' => 600]],
]);

// Process, cache, and return the resolved URL
$url = $filterService->getProcessedImageUrl('/scores/moonlight_sonata.jpg', 'default', [
    'processors' => ['fit' => ['width' => 800, 'height' => 600]],
]);

// Remove all cached variants for a source image
$cacheManager->remove('/scores/moonlight_sonata.jpg');
```

### Serializer attribute

[](#serializer-attribute)

The `#[ImageFilter]` attribute generates HMAC-signed image URLs during Symfony serialization — the API counterpart to the Twig macros. Requires `symfony/serializer`.

```
use ChamberOrchestra\FileBundle\Model\File;
use ChamberOrchestra\ImageBundle\Serializer\Attribute\ImageFilter;

class ConcertView
{
    public string $title = 'Moonlight Sonata';

    #[ImageFilter(filter: 'fill', width: 300, height: 300)]
    public ?File $coverArt = null;
}
```

Serializes to:

```
{
  "title": "Moonlight Sonata",
  "coverArt": {
    "default": {
      "avif": { "1x": "...", "2x": "...", "3x": "...", "4x": "..." },
      "webp": { "1x": "...", "2x": "...", "3x": "...", "4x": "..." }
    }
  }
}
```

#### Multiple attributes (repeatable)

[](#multiple-attributes-repeatable)

Use multiple attributes with distinct `key` values for different image variants:

```
#[ImageFilter(key: 'thumbnail', filter: 'fill', width: 100, height: 100)]
#[ImageFilter(key: 'hero', filter: 'fit', width: 800, height: 400)]
public ?File $poster = null;
```

Produces `"poster": { "thumbnail": { ... }, "hero": { ... } }`.

#### Parameters

[](#parameters)

ParameterTypeDefaultDescription`key``string``'default'`Key in the serialized output`filter``string``'fill'`Processor type: `fill`, `fit`, or `optimize``width``int``0`Logical width in CSS pixels`height``int``0`Logical height in CSS pixels (0 = auto)`densities``int[]``[1, 2, 3, 4]`Pixel densities to generate`preset``string``'default'`Named filter from `chamber_orchestra_image.filters.*`The normalizer is registered automatically when `symfony/serializer` is installed. Null or non-image `File` properties serialize to `null`.

#### Configuration

[](#configuration-1)

Output formats are configurable globally — no need to specify them per attribute:

```
chamber_orchestra_image:
    serializer:
        formats: [avif, webp]         # default
        # formats: [webp]             # webp only
        # formats: [avif, webp, png]  # include png fallback
```

Values are validated against `ImageFormat` at container build time.

#### Serializer context

[](#serializer-context)

The normalizer respects standard Symfony serializer context:

- `IGNORED_ATTRIBUTES` — excluded properties are skipped
- `ATTRIBUTES` — only listed properties are processed (supports both flat `['coverArt']` and nested `['coverArt' => ['default' => ['avif' => ['1x']]]]` forms; nested filters prune the output tree)

#### Metadata caching

[](#metadata-caching)

Attribute metadata is resolved via `ImageFilterMetadataFactory` with three layers:

1. **In-memory** — avoids repeated lookups within the same request
2. **PSR-6 cache** (`cache.app`) — cross-request reuse (86400s TTL in prod, 60s in dev)
3. **Reflection** — cold-start fallback

In dev mode, cache keys include a fingerprint derived from file mtimes of the class, its parents, and all traits (recursively), so attribute changes auto-invalidate.

Processors
----------

[](#processors)

All processors are configured as key-value maps under `filters..processors`.

### `fit` — Resize to bounding box

[](#fit--resize-to-bounding-box)

Scales the image to fit within the given dimensions while preserving aspect ratio. Never crops.

```
processors:
    fit:
        width: 800       # 0 = derive from height + aspect ratio
        height: 600      # 0 = derive from width + aspect ratio
        density: 1.0     # pixel density multiplier (1.0–4.0, default: 1.0)
        filter: lanczos  # resampling filter
```

### `fill` — Resize and centre-crop

[](#fill--resize-and-centre-crop)

Scales and crops to fill the exact requested dimensions. The crop is centred.

```
processors:
    fill:
        width: 400
        height: 400
        density: 1.0
```

### `optimize` — Fit without upscaling

[](#optimize--fit-without-upscaling)

Behaves like `fit` but never upscales the image. If the target dimensions are larger than the source, the original size is preserved.

```
processors:
    optimize:
        width: 1200
        height: 0        # 0 = derive from aspect ratio
        density: 2       # retina output
```

### `strip` — Remove metadata

[](#strip--remove-metadata)

Strips EXIF, ICC profiles, and other embedded metadata from the image.

```
processors:
    strip: {}
```

### `interlace` — Progressive encoding

[](#interlace--progressive-encoding)

Sets the interlacing mode for progressive JPEG or interlaced PNG output.

```
processors:
    interlace:
        mode: line    # none | line | plane | partition
```

### `output` — Output format and quality

[](#output--output-format-and-quality)

Controls the output format and quality.

```
processors:
    output:
        format: webp    # png | jpg | jpeg | webp | gif | tiff | bmp | avif
        quality: 85     # 1-100
```

### Shared processor options

[](#shared-processor-options)

`fit`, `fill`, and `optimize` share these options from `AbstractResizeProcessor`:

OptionDefaultDescription`width``0`Target width (px). `0` = compute from aspect ratio.`height``0`Target height (px). `0` = compute from aspect ratio.`density``1.0`Pixel density multiplier. Output = `size * density`.`filter``lanczos`Resampling filter (auto `undefined` for GD).`background``#fff`Canvas background colour for letterboxing.`alpha``0`Background alpha (0 = opaque).---

Post-processors
---------------

[](#post-processors)

Post-processors shell out to external CLI binaries and operate on the encoded image bytes. Configure them under `filters..post_processors`.

### `avifenc` — Convert to AVIF

[](#avifenc--convert-to-avif)

Converts JPEG, PNG, GIF, and TIFF images to AVIF format using the `avifenc` binary.

```
post_processors:
    avifenc:
        quality: 63     # 0-100 (default: 63)
        speed: 6        # 0=slowest/best, 10=fastest (default: 6)
        timeout: 60     # process timeout in seconds (capped at 300)
```

Requires the `avifenc` binary.

### `cwebp` — Convert to WebP

[](#cwebp--convert-to-webp)

Converts JPEG, PNG, GIF, and TIFF images to WebP format.

```
post_processors:
    cwebp:
        quality: 90     # 0-100 (default: 90)
        timeout: 30     # process timeout in seconds (capped at 300)
```

Requires the `cwebp` binary (e.g. `apt install webp`).

### `mozjpeg` — Optimise JPEG

[](#mozjpeg--optimise-jpeg)

Re-encodes JPEG images through MozJPEG for smaller file sizes.

```
post_processors:
    mozjpeg:
        quality: 75     # 0-100 (default: 75)
        timeout: 60     # process timeout in seconds (capped at 300)
```

Requires the `cjpeg` binary from MozJPEG.

### `pngquant` — Compress PNG

[](#pngquant--compress-png)

Compresses PNG images using lossy palette quantisation.

```
post_processors:
    pngquant:
        quality: '80-100'   # min-max quality range
        timeout: 30
```

Requires the `pngquant` binary (e.g. `apt install pngquant`).

---

Loaders
-------

[](#loaders)

Loaders retrieve the source image binary. The `default` loader is always `filesystem`.

### `filesystem`

[](#filesystem)

Loads images from one or more root directories on the local filesystem. Performs path traversal protection via `realpath()`.

```
loaders:
    default:
        type: filesystem
        data_root:
            - '%kernel.project_dir%/public/uploads'
            - '%kernel.project_dir%/public/images'
```

Named roots allow `@name:path` placeholder syntax:

```
loaders:
    default:
        type: filesystem
        data_root:
            uploads: '%kernel.project_dir%/public/uploads'
```

```
{{ '@uploads:conductors/karajan.jpg' | image_filter('conductor_portrait') }}
```

#### Locator security

[](#locator-security)

The filesystem loader uses a **locator** to resolve and validate image paths. Two locators are available:

- **`FileSystemLocator`** (default) — resolves paths with `realpath()` and verifies the resolved path falls within a configured root directory. Symlinks that resolve outside the root are rejected. This is the recommended locator.
- **`FileSystemInsecureLocator`** — more permissive with symlinked directory structures. Still rejects `..` traversal and verifies the resolved path is under the root, but accommodates setups where the `data_root` itself or files within it are symlinks that resolve outside the configured root. Useful for monorepo setups, Vagrant/Docker shared mounts, or when bundle assets are symlinked into the web root via `assets:install --symlink`.

```
loaders:
    default:
        type: filesystem
        locator: ChamberOrchestra\ImageBundle\Binary\Locator\FileSystemInsecureLocator
        data_root:
            - '%kernel.project_dir%/public'
```

### `stream`

[](#stream)

Loads images from any PHP stream wrapper (HTTP, FTP, custom wrappers, etc.). The `wrapper_prefix` is prepended to the image path before calling `file_get_contents()`. An optional stream context can be provided for authentication or SSL options.

**SSRF protection:** The stream loader validates URI schemes against a strict allowlist. Both standard (`https://`) and compact (`data:`) scheme formats are detected. Bare paths (absolute or relative) are treated as implicit `file` scheme. By default only `file` and `data` are allowed. To allow remote fetching, add the desired schemes explicitly:

```
loaders:
    remote:
        type: stream
        wrapper_prefix: 'https://cdn.example.com/uploads/'
        allowed_schemes: [file, data, https]    # default: [file, data]
```

```
$url = $filterService->getUrlOfFilteredImage('scores/violin_concerto.jpg', 'score_thumbnail');
// internally calls file_get_contents('https://cdn.example.com/uploads/scores/violin_concerto.jpg')
```

Use it in a filter pipeline:

```
filters:
    score_thumbnail:
        loader: remote
        processors:
            fit: { width: 400, height: 300 }
```

### `s3` — S3-compatible storage

[](#s3--s3-compatible-storage)

Loads images from S3-compatible storage (AWS S3, MinIO, DigitalOcean Spaces). Requires `chamber-orchestra/file-bundle`.

```
loaders:
    default:
        type: s3
        storage: default    # name of the file-bundle storage
```

### Custom loaders

[](#custom-loaders)

Implement `LoaderInterface` and register a factory:

```
use ChamberOrchestra\ImageBundle\Binary\Loader\LoaderInterface;

class MyLoader implements LoaderInterface
{
    public function find(string $path): BinaryInterface|string { /* ... */ }
    public function getName(): string { return 'my_loader'; }
}
```

```
// In your bundle's build() method:
$extension->addLoaderFactory(new MyLoaderFactory());
```

---

Resolvers
---------

[](#resolvers)

Resolvers store cached images and resolve them to browser-accessible URLs.

### `web_path`

[](#web_path)

Writes cached files to a directory under the web root and returns a root-relative URL.

```
resolvers:
    default:
        type: web_path
        web_root: '%kernel.project_dir%/public'
        cache_prefix: media/cache
```

### `s3` — S3-compatible storage

[](#s3--s3-compatible-storage-1)

Stores cached images in an S3-compatible bucket and resolves URLs via a CDN prefix or presigned URLs. Requires `aws/aws-sdk-php`.

```
resolvers:
    default:
        type: s3
        bucket: my-bucket
        region: eu-west-1
        endpoint: ~                          # optional: https://minio.example.com
        uri_prefix: https://cdn.example.com  # optional: CDN URL prefix
        cache_prefix: media                  # S3 key prefix (default: media)
```

### `cache` (PSR-6 decorator)

[](#cache-psr-6-decorator)

Wraps any resolver with a PSR-6 cache layer to avoid filesystem `is_file()` checks on repeated requests.

```
use ChamberOrchestra\ImageBundle\Imagine\Cache\Resolver\CacheResolver;
use Symfony\Component\Cache\Adapter\RedisAdapter;

$resolver = new CacheResolver(
    new RedisAdapter($redis),
    $innerWebPathResolver,
    ['lifetime' => 3600]
);
```

### Custom resolvers

[](#custom-resolvers)

Implement `ResolverInterface` and register a factory, or inject your resolver as a service:

```
resolvers:
    my_resolver:
        type: custom
        service: App\ImageResolver\MyResolver
```

---

Runtime filters
---------------

[](#runtime-filters)

Runtime filters allow processor options to be passed at request time without pre-configuring a named filter. The URL is HMAC-signed to prevent parameter tampering.

```
// Generates: /_media/cache/resolve/default/rc///symphony_no_5.jpg
$url = $cacheManager->getBrowserPath('/scores/symphony_no_5.jpg', 'default', [
    'fit' => ['width' => 800, 'height' => 0],
    'output' => ['format' => 'webp'],
]);
```

The HMAC secret defaults to the `APP_SECRET` kernel parameter (standard Symfony). You can override it per filter with the `secret` option — useful when different filters are consumed by different clients that each hold their own signing key:

```
filters:
    soloist_portrait:
        secret: '%env(MOBILE_IMAGE_SECRET)%'
        processors:
            fill: { width: 200, height: 200 }
            strip: {}

    programme_cover:
        secret: '%env(ADMIN_IMAGE_SECRET)%'
        processors:
            fit: { width: 1920, height: 0 }
```

When `secret` is set on a filter, all signing and verification for that filter (URL generation, controller hash check, cache path derivation, cache invalidation) uses the per-filter secret instead of `APP_SECRET`. Filters without a `secret` continue to use the global secret.

### Client image URLs (TypeScript / mobile clients)

[](#client-image-urls-typescript--mobile-clients)

For front-end apps, mobile clients (Swift, Kotlin), or any HTTP client, the bundle exposes an nginx-cacheable endpoint that generates processed images on demand:

```
GET /media/{pathHash}/{optionsHash}/{name}@{density}x.{format}?path=...&filter=...&type=...&width=...&height=...&quality=...

```

The filter must be marked `exposed: true` with a dedicated `secret` (must differ from `APP_SECRET`):

```
filters:
    client:
        exposed: true
        secret: '%env(CLIENT_IMAGE_SECRET)%'
        processors:
            fit: { width: 1200, height: 0 }
        post_processors:
            cwebp: {}
```

Share the `secret` with trusted clients out-of-band. The URL is HMAC-validated — clients compute the hashes using the same algorithm as the PHP `Signer`.

On a cache hit the controller returns a `301` redirect to the static file; on a miss it processes the image, caches it, and redirects. Because the URL is a deterministic GET, nginx `try_files` can serve cached images directly without hitting PHP.

Supported formats: `jpg`, `jpeg`, `png`, `webp`, `avif`.

#### TypeScript signing implementation

[](#typescript-signing-implementation)

```
/**
 * HMAC-SHA256 image URL signer — mirrors the PHP Signer class.
 */
async function hash(value: string, secret: string): Promise {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

  // ltrim '/' to match PHP behaviour
  const data = new TextEncoder().encode(value.replace(/^\/+/, ""));
  const signature = await crypto.subtle.sign("HMAC", key, data);

  // base64url encoding (RFC 4648 §5)
  return btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "")
    .slice(0, 16);
}

async function sign(
  path: string,
  secret: string,
  config: Record,
): Promise {
  // Convert all values to strings recursively
  const stringify = (obj: Record): Record =>
    Object.fromEntries(
      Object.entries(obj).map(([k, v]) => [
        k,
        v && typeof v === "object" && !Array.isArray(v)
          ? stringify(v as Record)
          : String(v ?? ""),
      ]),
    );

  // Sort keys recursively
  const sortKeys = (obj: Record): Record =>
    Object.fromEntries(
      Object.keys(obj)
        .sort()
        .map((k) => [
          k,
          obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k])
            ? sortKeys(obj[k] as Record)
            : obj[k],
        ]),
    );

  const normalized = sortKeys(stringify(config));
  const pathHash = await hash(path, secret);
  const optionsHash = await hash(JSON.stringify(normalized), secret);

  return `${pathHash}/${optionsHash}`;
}
```

#### Building the URL

[](#building-the-url)

Configure `filter`, `secret`, and `baseUrl` once at module level:

```
// lib/image-config.ts — single source of truth
const IMAGE_FILTER = "client";
const IMAGE_SECRET = process.env.NEXT_PUBLIC_IMAGE_SECRET!; // or import.meta.env.VITE_IMAGE_SECRET
const IMAGE_BASE_URL = "/media";

interface ImageUrlOptions {
  path: string; // source image path, e.g. "scores/moonlight_sonata.jpg"
  type: "fit" | "fill" | "optimize";
  width: number;
  height: number;
  density?: number; // default: 1
  quality?: number; // default: 0 (use filter default)
  format?: string; // default: source extension
}

async function buildImageUrl(options: ImageUrlOptions): Promise {
  const { path, type, width, height, density = 1, quality = 0 } = options;

  const ext = path.split(".").pop() ?? "jpg";
  const format = options.format ?? ext;
  const name = path.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "image";

  // Config must match the PHP controller's buildConfig() structure
  const config = {
    [type]: { width, height, density },
    output: { quality, format: format.toLowerCase() },
  };

  const signed = await sign(path, IMAGE_SECRET, config);
  const [pathHash, optionsHash] = signed.split("/");
  const params = new URLSearchParams({
    path,
    filter: IMAGE_FILTER,
    type,
    width: String(width),
    height: String(height),
    quality: String(quality),
  });

  return `${IMAGE_BASE_URL}/${pathHash}/${optionsHash}/${name}@${density}x.${format}?${params}`;
}
```

#### Usage

[](#usage-1)

```
const url = await buildImageUrl({
  path: "scores/moonlight_sonata.jpg",
  type: "fit",
  width: 800,
  height: 600,
  density: 2,
  quality: 85,
  format: "webp",
});
// => /media/Ab3xK9_zRt4mNp2q/Qm7pLw2dXk9Yj6Fs/moonlight_sonata@2x.webp?path=scores/moonlight_sonata.jpg&filter=client&type=fit&width=800&height=600&quality=85
```

#### Responsive `` element (React example)

[](#responsive-picture-element-react-example)

This mirrors the Twig `fit` / `fill` macros — AVIF, WebP, and fallback srcsets at 1x/2x/3x densities:

```
type PictureType = "fit" | "fill";

async function buildSrcSet(
  src: string, type: PictureType,
  width: number, height: number, quality: number,
  format?: string,
): Promise {
  const densities = [3, 2, 1];
  const parts = await Promise.all(
    densities.map(async (density) => {
      const url = await buildImageUrl({
        path: src, type, width, height, density, quality, format,
      });
      return `${url} ${density}x`;
    }),
  );
  return parts.join(", ");
}

async function buildPictureSources(
  src: string, type: PictureType,
  width: number, height: number, quality: number,
) {
  const [avifSrcSet, webpSrcSet, fallbackSrcSet, fallbackSrc] =
    await Promise.all([
      buildSrcSet(src, type, width, height, quality, "avif"),
      buildSrcSet(src, type, width, height, quality, "webp"),
      buildSrcSet(src, type, width, height, quality),
      buildImageUrl({ path: src, type, width, height, quality }),
    ]);

  return { avifSrcSet, webpSrcSet, fallbackSrcSet, fallbackSrc };
}
```

##### React component

[](#react-component)

```
// components/ResponsiveImage.tsx
import { useEffect, useState } from "react";

interface ResponsiveImageProps {
  src: string;
  type?: "fit" | "fill" | "optimize";
  width: number;
  height: number;
  quality?: number;
  alt?: string;
  className?: string;
}

interface PictureSources {
  avifSrcSet: string;
  webpSrcSet: string;
  fallbackSrcSet: string;
  fallbackSrc: string;
}

function useImageSources(
  src: string, type: "fit" | "fill" | "optimize",
  width: number, height: number, quality: number,
): PictureSources | null {
  const [sources, setSources] = useState(null);

  useEffect(() => {
    buildPictureSources(src, type, width, height, quality).then(setSources);
  }, [src, type, width, height, quality]);

  return sources;
}

export function ResponsiveImage({
  src, type = "fit", width, height, quality = 85, alt = "", className,
}: ResponsiveImageProps) {
  const sources = useImageSources(src, type, width, height, quality);
  if (!sources) return null;

  return (

  );
}
```

```

```

##### Next.js server component

[](#nextjs-server-component)

In Next.js server components the secret stays on the server — configure it in `lib/image-config.ts` and the component just calls `buildPictureSources` directly:

```
// components/ResponsiveImage.tsx

interface ResponsiveImageProps {
  src: string;
  type?: "fit" | "fill" | "optimize";
  width: number;
  height: number;
  quality?: number;
  alt?: string;
  className?: string;
}

export async function ResponsiveImage({
  src, type = "fit", width, height, quality = 85, alt = "", className,
}: ResponsiveImageProps) {
  const { avifSrcSet, webpSrcSet, fallbackSrcSet, fallbackSrc } =
    await buildPictureSources(src, type, width, height, quality);

  return (

  );
}
```

```

```

##### Vue component

[](#vue-component)

```

import { ref, watchEffect } from "vue";

const props = withDefaults(defineProps(), {
  type: "fit",
  quality: 85,
  alt: "",
});

const avifSrcSet = ref("");
const webpSrcSet = ref("");
const fallbackSrcSet = ref("");
const fallbackSrc = ref("");

watchEffect(async () => {
  const sources = await buildPictureSources(
    props.src, props.type, props.width, props.height, props.quality,
  );
  avifSrcSet.value = sources.avifSrcSet;
  webpSrcSet.value = sources.webpSrcSet;
  fallbackSrcSet.value = sources.fallbackSrcSet;
  fallbackSrc.value = sources.fallbackSrc;
});

```

```

```

##### Rendered HTML (all frameworks)

[](#rendered-html-all-frameworks)

```

```

#### Swift signing implementation

[](#swift-signing-implementation)

```
import CryptoKit
import Foundation

/// HMAC-SHA256 image URL signer — mirrors the PHP Signer class.
enum ImageSigner {

    static func hash(_ value: String, secret: String) -> String {
        let trimmed = value.drop(while: { $0 == "/" })
        let key = SymmetricKey(data: Data(secret.utf8))
        let signature = HMAC.authenticationCode(
            for: Data(String(trimmed).utf8), using: key
        )
        // base64url encoding (RFC 4648 §5)
        let base64 = Data(signature).base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .trimmingCharacters(in: CharacterSet(charactersIn: "="))
        return String(base64.prefix(16))
    }

    static func sign(path: String, secret: String, config: [String: Any]) -> String {
        let normalized = stringify(config)
        let sorted = sortKeys(normalized)
        let jsonData = try! JSONSerialization.data(
            withJSONObject: sorted, options: [.sortedKeys]
        )
        let name = String(data: jsonData, encoding: .utf8)!

        let pathHash = hash(path, secret: secret)
        let optionsHash = hash(name, secret: secret)
        return "\(pathHash)/\(optionsHash)"
    }

    // Convert all leaf values to strings recursively
    private static func stringify(_ dict: [String: Any]) -> [String: Any] {
        dict.mapValues { value in
            if let nested = value as? [String: Any] {
                return stringify(nested)
            }
            return "\(value)"
        }
    }

    // Sort dictionary keys recursively
    private static func sortKeys(_ dict: [String: Any]) -> [String: Any] {
        Dictionary(uniqueKeysWithValues: dict.sorted(by: { $0.key  String {
    let ext = (options.path as NSString).pathExtension
    let format = options.format ?? ext
    let name = ((options.path as NSString).lastPathComponent as NSString)
        .deletingPathExtension

    let config: [String: Any] = [
        options.type: [
            "width": options.width,
            "height": options.height,
            "density": options.density,
        ],
        "output": [
            "quality": options.quality,
            "format": format.lowercased(),
        ],
    ]

    let signed = ImageSigner.sign(path: options.path, secret: ImageConfig.secret, config: config)
    let parts = signed.split(separator: "/")
    let pathHash = parts[0], optionsHash = parts[1]

    var components = URLComponents()
    components.queryItems = [
        URLQueryItem(name: "path", value: options.path),
        URLQueryItem(name: "filter", value: ImageConfig.filter),
        URLQueryItem(name: "type", value: options.type),
        URLQueryItem(name: "width", value: "\(options.width)"),
        URLQueryItem(name: "height", value: "\(options.height)"),
        URLQueryItem(name: "quality", value: "\(options.quality)"),
    ]

    return "\(ImageConfig.baseUrl)/\(pathHash)/\(optionsHash)/\(name)@\(options.density)x.\(format)\(components.string ?? "")"
}
```

##### Usage

[](#usage-2)

```
let url = buildImageUrl(ImageUrlOptions(
    path: "scores/moonlight_sonata.jpg",
    type: "fit",
    width: 800,
    height: 600,
    density: 2,
    quality: 85,
    format: "webp"
))
```

##### Responsive image loading (SwiftUI)

[](#responsive-image-loading-swiftui)

Generate URLs at multiple densities for the device screen scale:

```
func buildResponsiveUrls(
    path: String, type: String, width: Int, height: Int,
    quality: Int = 85, format: String = "webp"
) -> [Int: String] {
    var urls: [Int: String] = [:]
    for density in 1...3 {
        var opts = ImageUrlOptions(
            path: path, type: type, width: width, height: height
        )
        opts.density = density
        opts.quality = quality
        opts.format = format
        urls[density] = buildImageUrl(opts)
    }
    return urls
}

// Usage — pick the URL matching the device scale
let urls = buildResponsiveUrls(
    path: "scores/symphony_no_5.jpg", type: "fit", width: 800, height: 600
)
let scale = Int(UIScreen.main.scale) // 1, 2, or 3
let url = urls[scale] ?? urls[1]!
```

#### Kotlin signing implementation

[](#kotlin-signing-implementation)

```
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import org.json.JSONObject

/**
 * HMAC-SHA256 image URL signer — mirrors the PHP Signer class.
 */
object ImageSigner {

    fun hash(value: String, secret: String): String {
        val trimmed = value.trimStart('/')
        val mac = Mac.getInstance("HmacSHA256").apply {
            init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
        }
        val signature = mac.doFinal(trimmed.toByteArray())
        // base64url encoding (RFC 4648 §5)
        val base64 = java.util.Base64.getEncoder().encodeToString(signature)
            .replace('+', '-')
            .replace('/', '_')
            .trimEnd('=')
        return base64.take(16)
    }

    fun sign(path: String, secret: String, config: Map): String {
        val normalized = stringify(config)
        val sorted = sortKeys(normalized)
        val name = JSONObject(sorted).toString()

        val pathHash = hash(path, secret)
        val optionsHash = hash(name, secret)
        return "$pathHash/$optionsHash"
    }

    // Convert all leaf values to strings recursively
    @Suppress("UNCHECKED_CAST")
    private fun stringify(map: Map): Map =
        map.mapValues { (_, value) ->
            when (value) {
                is Map -> stringify(value as Map)
                else -> value.toString()
            }
        }

    // Sort keys recursively
    @Suppress("UNCHECKED_CAST")
    private fun sortKeys(map: Map): Map =
        map.toSortedMap().mapValues { (_, value) ->
            when (value) {
                is Map -> sortKeys(value as Map)
                else -> value
            }
        }
}
```

##### Building the URL

[](#building-the-url-2)

Configure `filter`, `secret`, and `baseUrl` once at app level:

```
// ImageConfig.kt — single source of truth
object ImageConfig {
    val filter = "client"
    val secret = BuildConfig.IMAGE_SECRET
    val baseUrl = "/media"
}

data class ImageUrlOptions(
    val path: String,           // e.g. "scores/moonlight_sonata.jpg"
    val type: String,           // "fit", "fill", or "optimize"
    val width: Int,
    val height: Int,
    val density: Int = 1,
    val quality: Int = 0,
    val format: String? = null, // null = use source extension
)

fun buildImageUrl(options: ImageUrlOptions): String {
    val ext = options.path.substringAfterLast('.', "jpg")
    val format = options.format ?: ext
    val name = options.path.substringAfterLast('/').substringBeforeLast('.')

    val config = mapOf(
        options.type to mapOf(
            "width" to options.width,
            "height" to options.height,
            "density" to options.density,
        ),
        "output" to mapOf(
            "quality" to options.quality,
            "format" to format.lowercase(),
        ),
    )

    val signed = ImageSigner.sign(options.path, ImageConfig.secret, config)
    val (pathHash, optionsHash) = signed.split("/")

    val query = listOf(
        "path" to options.path,
        "filter" to ImageConfig.filter,
        "type" to options.type,
        "width" to options.width.toString(),
        "height" to options.height.toString(),
        "quality" to options.quality.toString(),
    ).joinToString("&") { (k, v) ->
        "$k=${URLEncoder.encode(v, "UTF-8")}"
    }

    return "${ImageConfig.baseUrl}/$pathHash/$optionsHash/$name@${options.density}x.$format?$query"
}
```

##### Usage

[](#usage-3)

```
val url = buildImageUrl(ImageUrlOptions(
    path = "scores/moonlight_sonata.jpg",
    type = "fit",
    width = 800,
    height = 600,
    density = 2,
    quality = 85,
    format = "webp",
))
```

##### Responsive image loading (Compose / Android)

[](#responsive-image-loading-compose--android)

```
fun buildResponsiveUrls(
    path: String, type: String, width: Int, height: Int,
    quality: Int = 85, format: String = "webp",
): Map = (1..3).associateWith { density ->
    buildImageUrl(ImageUrlOptions(
        path = path, type = type, width = width, height = height,
        density = density, quality = quality, format = format,
    ))
}

// Usage — pick URL matching device density
val urls = buildResponsiveUrls(
    path = "scores/symphony_no_5.jpg", type = "fit", width = 800, height = 600,
)
val density = resources.displayMetrics.densityDpi / 160 // 1, 2, or 3
val url = urls[density.coerceIn(1, 3)]!!
```

---

Cache invalidation
------------------

[](#cache-invalidation)

When using `chamber-orchestra/file-bundle`, cached variants are automatically removed when a source file is deleted via `FileRemoveSubscriber`, which listens to `PostRemoveEvent`.

To remove all cached variants for a path manually:

```
$cacheManager->remove('/scores/moonlight_sonata.jpg');
```

---

Web server configuration
------------------------

[](#web-server-configuration)

For best performance, configure your web server to serve cached images directly from disk without hitting PHP. The bundle writes processed images to the filesystem at deterministic paths — the web server checks if the file exists and serves it immediately. Only on a cache miss does the request fall through to the Symfony controller.

### nginx

[](#nginx)

```
server {
    listen 80;
    server_name example.com;
    root /var/www/public;

    # Serve cached images directly — bypass PHP entirely on cache hit
    location /media/ {
        # Try the static file first, fall through to Symfony on miss
        try_files $uri /index.php$is_args$args;

        # Immutable caching — the URL changes when the image changes (content-addressed)
        expires max;
        add_header Cache-Control "public, immutable";

        # Disable access log for static image hits (optional, reduces I/O)
        access_log off;
    }

    # Symfony front controller
    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/php-fpm.sock;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        internal;
    }

    location / {
        try_files $uri /index.php$is_args$args;
    }
}
```

### Apache

[](#apache)

```

    ServerName example.com
    DocumentRoot /var/www/public

    # Enable rewrite engine
    RewriteEngine On

    # Serve cached images directly — bypass PHP on cache hit
    # If the file exists on disk, serve it with immutable caching

            Header set Cache-Control "public, max-age=31536000, immutable"

            ExpiresActive On
            ExpiresDefault "access plus 1 year"

    # Fall through to Symfony when the cached file doesn't exist yet

        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteRule ^(.*)$ /index.php [QSA,L]

        SetHandler "proxy:unix:/run/php/php-fpm.sock|fcgi://localhost"

```

### Caddy

[](#caddy)

```
example.com {
    root * /var/www/public

    # Serve cached images directly with immutable caching
    @media path /media/*
    handle @media {
        header Cache-Control "public, max-age=31536000, immutable"
        try_files {path} /index.php?{query}
        file_server
    }

    # Symfony front controller
    php_fastcgi unix//run/php/php-fpm.sock {
        resolve_root_symlink
    }

    file_server
}
```

### How it works

[](#how-it-works)

1. **First request** (cache miss): the file doesn't exist on disk, so `try_files` falls through to `index.php`. The Symfony controller processes the image, stores it at `{cache_path}/{pathHash}/{optionsHash}/{name}@{density}x.{format}`, and returns a `301` redirect to the static URL.
2. **Subsequent requests** (cache hit): the web server finds the file on disk and serves it directly — PHP is never invoked. The `Cache-Control: public, immutable` header tells browsers and CDNs to cache the response indefinitely.
3. **Cache invalidation**: when the source image is deleted, `CacheManager::remove()` deletes the entire `{pathHash}/` directory, so the next request will be a cache miss and the image will be re-processed.

Because image URLs are content-addressed (the hash changes when the source or options change), you can safely use `immutable` caching — stale URLs are never reused.

---

Extension points
----------------

[](#extension-points)

- **Custom processors**: implement `ProcessorInterface`, auto-tagged `chamber_orchestra_image.filter.processor`
- **Custom post-processors**: implement `PostProcessorInterface`, auto-tagged `chamber_orchestra_image.filter.post_processor`
- **Custom loaders**: implement `LoaderFactoryInterface`, register in your bundle's `build()` method
- **Custom resolvers**: implement `ResolverFactoryInterface`, register in your bundle's `build()` method, or use `type: custom` with a service ID
- **Enums**: `ImageFormat` (png, jpg, webp, avif, ...) and `ImagineDriver` (Gd, Imagick, Gmagick) are available for type-safe configuration

---

Testing
-------

[](#testing)

```
composer install
composer test                                  # PHPUnit full suite
./vendor/bin/phpunit --filter ClassName         # single class
./vendor/bin/phpunit --filter testMethod        # single method
composer analyse                               # PHPStan (level max)
composer cs-check                              # code style check (dry-run)
composer cs-fix                                # apply code style fixes
```

---

License
-------

[](#license)

MIT. See [LICENSE](LICENSE) for details.

###  Health Score

47

—

FairBetter than 94% of packages

Maintenance89

Actively maintained with recent releases

Popularity21

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity58

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 88.9% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

10

Last Release

55d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/44037eb1c8dc2c4fa9871ac213653f33e22a9348dcec7132df07cc71933f2a2e?d=identicon)[wtorsi](/maintainers/wtorsi)

---

Top Contributors

[![wtorsi](https://avatars.githubusercontent.com/u/2115840?v=4)](https://github.com/wtorsi "wtorsi (8 commits)")[![baldrys-ed](https://avatars.githubusercontent.com/u/60212508?v=4)](https://github.com/baldrys-ed "baldrys-ed (1 commits)")

---

Tags

avifhmacimage-cacheimage-optimizationimage-processingimage-resizeimaginemozjpegon-demandphpphp8pngquantresponsive-imagess3symfonysymfony-bundlesymfony8thumbnailtwigwebpasyncsymfonythumbnails3twigimageimage processingresizecachefilterimagineimage resizecropSymfony BundlehmacpictureMessengerimage cacheResponsive Imagesphp8srcsetWebpavifFitretinapngquanton-demandfillMozJPEGimage-optimizationimage-cropcwebphidpisigned-urlliip-imagineavifencimage-conversionsymfony8imagine-bundleimage-filter

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/chamber-orchestra-image-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/chamber-orchestra-image-bundle/health.svg)](https://phpackages.com/packages/chamber-orchestra-image-bundle)
```

###  Alternatives

[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.3M152](/packages/sulu-sulu)[shopware/platform

The Shopware e-commerce core

3.3k1.5M3](/packages/shopware-platform)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.4k5.6M651](/packages/sylius-sylius)[contao/core-bundle

Contao Open Source CMS

1231.6M2.4k](/packages/contao-core-bundle)[prestashop/prestashop

PrestaShop is an Open Source e-commerce platform, committed to providing the best shopping cart experience for both merchants and customers.

9.0k15.4k](/packages/prestashop-prestashop)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

595.2M386](/packages/shopware-core)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
