PHPackages                             hexis/sulu-media-cdn-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. [File &amp; Storage](/categories/file-storage)
4. /
5. hexis/sulu-media-cdn-bundle

ActiveSymfony-bundle[File &amp; Storage](/categories/file-storage)

hexis/sulu-media-cdn-bundle
===========================

Pluggable Sulu media bundle: async variation generation via Messenger + CDN delivery via flysystem-backed FormatCache.

00PHP

Since May 26Pushed 1mo agoCompare

[ Source](https://github.com/hexis-hr/sulu-media-cdn-bundle)[ Packagist](https://packagist.org/packages/hexis/sulu-media-cdn-bundle)[ RSS](/packages/hexis-sulu-media-cdn-bundle/feed)WikiDiscussions master Synced 1w ago

READMEChangelogDependenciesVersions (1)Used By (0)

hexis/sulu-media-cdn-bundle
===========================

[](#hexissulu-media-cdn-bundle)

[![Latest Version](https://camo.githubusercontent.com/3fa0b04c78cb46f7203081ce06a7bfb174d40deb3dab1381386ae64b9b2b3d3c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f68657869732f73756c752d6d656469612d63646e2d62756e646c652e737667)](https://packagist.org/packages/hexis/sulu-media-cdn-bundle)[![License](https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667)](LICENSE)

Source:

Pluggable Sulu media bundle that replaces Sulu's on-the-fly image variation generation with:

- async pre-generation via Symfony Messenger
- flysystem-backed variation storage (S3, local, anything `league/flysystem` adapts)
- CDN delivery with optional SigV4 presigned URLs (private buckets supported)
- self-healing fallback that streams the original on cache miss
- bulk (re)generate console command

Every behavior is independently toggleable. With all flags off, Sulu's native behavior is preserved.

Why
---

[](#why)

Sulu's `FormatManager::returnImage()` decompresses originals into PHP-FPM memory on every cold-cache request to generate variations. Under load (e.g. an admin grid with 100+ uncached thumbnails) this OOM-kills the pod ([sulu/sulu#8735](https://github.com/sulu/sulu/issues/8735)). Variations also stay on the pod's local disk, so S3/CDN scale gains never reach the image hot path.

This bundle moves all conversion work to a Messenger worker, writes variations to an external storage backend, and serves them via CDN URLs — making the request hot path strictly a lookup-and-redirect.

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

[](#requirements)

- PHP 8.2+
- Symfony 7.3+
- Sulu 3.0+
- `league/flysystem` 3.10+ (for `FilesystemOperator::temporaryUrl()`)
- For S3 storage: `aws/aws-sdk-php` 3.x + `league/flysystem-aws-s3-v3` 3.10+ (already declared as direct dependencies)

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

[](#installation)

```
composer require hexis/sulu-media-cdn-bundle
```

Register the bundle:

```
// config/bundles.php
return [
    // ...
    Hexis\SuluMediaCdnBundle\HexisSuluMediaCdnBundle::class => ['all' => true],
];
```

Add a Messenger transport for the async generation queue:

```
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            image_variations:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=image_variations'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
                options:
                    auto_setup: false
                    table_name: messenger_image_variations

        routing:
            Hexis\SuluMediaCdnBundle\Message\GenerateVariationsMessage: image_variations
```

If you use the Doctrine messenger transport, create the queue table via a migration (sample for MySQL / MariaDB):

```
CREATE TABLE messenger_image_variations (
    id BIGINT AUTO_INCREMENT NOT NULL,
    body LONGTEXT CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`,
    headers LONGTEXT CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`,
    queue_name VARCHAR(190) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`,
    created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
    available_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
    delivered_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
    INDEX IDX_MSG_IMG_VAR_QUEUE (queue_name, available_at, delivered_at, id),
    PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
```

Start the worker:

```
bin/console messenger:consume image_variations --memory-limit=128M
```

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

[](#configuration)

Defaults shown. The bundle works with `hexis_sulu_media_cdn: ~` (all defaults).

```
hexis_sulu_media_cdn:
    # Where variations are stored and served from.
    format_cache:
        enabled: true                  # false = use Sulu's native LocalFormatCache untouched
        flysystem_service: 'default.storage'   # any flysystem.yaml storage id
        path_prefix: 'media-formats'   # path under the flysystem root
        segments: 10                   # bucketing - matches Sulu's segmenting
        visibility: null               # null | 'public' | 'private'
                                       # 'public' sets ACL=public-read on writes (only on
                                       # S3 buckets with ACLs enabled). For BucketOwnerEnforced
                                       # buckets, leave null and use a bucket policy instead.

    # How variation URLs are shaped.
    cdn:
        enabled: true                  # false = always return native proxy URLs
        base_url: ''                   # e.g. https://cdn.example.com - empty = relative proxy paths
        version_query: true            # append ?v={version}-{subVersion} cache-buster
        object_key_prefix: ''          # mirrors flysystem `prefix` so the URL matches the
                                       # actual object key when base_url points at the bucket
                                       # directly (e.g. 'media' if your S3 flysystem prefix is 'media')
        presign:
            enabled: false             # generate SigV4-signed S3 URLs
            ttl: 3600                  # signature validity in seconds (min 60)

    # What happens when a variation URL hits Symfony's origin.
    async_proxy:
        enabled: true                  # false = leave Sulu's FormatManager untouched
        miss_strategy: 'serve_original'   # 'serve_original' | 'on_the_fly' | '404'
        regenerate_on_miss: true       # dispatch a self-healing GenerateVariationsMessage

    # Auto-trigger generation on upload + new file versions.
    triggers:
        on_media_created: true
        on_media_version_added: true
        sync_admin_format: 'sulu-240x' # generated inside the upload request; null disables
        async_formats: []              # empty = all configured formats minus sync_admin_format
        excluded_formats: []           # blacklist applied to async_formats

    queue:
        transport: 'image_variations'  # Messenger transport name (must exist)
        deduplicate: true

    command:
        enabled: true
        default_batch_size: 100
```

Each `enabled` flag gates **service registration** in the container, not a runtime no-op. Disabling a feature genuinely removes the wiring.

Architecture
------------

[](#architecture)

```
Upload  ─►  MediaCreatedEvent / MediaVersionAddedEvent
              ├─ sync: generate sync_admin_format  ──►  flysystem (S3)
              └─ async: dispatch GenerateVariationsMessage
                          │
                          ▼
                   Messenger worker
                          │
                          ├─ for each configured format:
                          │     ImagineImageConverter::convert()
                          │     FormatCacheInterface::save()  ──►  flysystem (S3)

Render
         │
         ▼
FormatCacheInterface::getMediaUrl()
   ├─ presign enabled  ──►  Sulu proxy URL (signing deferred to request time)
   ├─ CDN enabled       ──►  {cdn.base_url}/{object_key_prefix}/{path_prefix}/...
   └─ otherwise          ──►  native Sulu proxy URL

CDN/origin GET
         │
         ▼
AsyncFormatManager::returnImage()  (decorates Sulu's FormatManager)
   ├─ variation exists?
   │     ├─ presign on    ──►  302 to freshly signed S3 URL
   │     ├─ absolute URL  ──►  302 to CDN URL
   │     └─ relative URL  ──►  stream variation from flysystem
   └─ miss?
         ├─ miss_strategy = serve_original  ──►  stream original + queue regen
         ├─ miss_strategy = on_the_fly      ──►  delegate to Sulu's FormatManager
         └─ miss_strategy = 404             ──►  return 404

```

Common configurations
---------------------

[](#common-configurations)

### Async generation + S3 + public CDN

[](#async-generation--s3--public-cdn)

```
hexis_sulu_media_cdn:
    format_cache:
        flysystem_service: 'aws.storage'
    cdn:
        base_url: 'https://cdn.example.com'
        object_key_prefix: 'media'   # match the flysystem `prefix` option
```

Bucket policy makes `media-formats/*` publicly readable; HTML contains absolute CDN URLs; cache misses fall back to the Symfony origin.

### Async generation + S3 + private bucket via presigned URLs

[](#async-generation--s3--private-bucket-via-presigned-urls)

```
hexis_sulu_media_cdn:
    format_cache:
        flysystem_service: 'aws.storage'
    cdn:
        presign:
            enabled: true
            ttl: 3600
```

HTML contains Sulu proxy URLs; the origin presigns the redirect target at request time. Bucket stays private.

### Async generation only, no CDN

[](#async-generation-only-no-cdn)

```
hexis_sulu_media_cdn:
    cdn:
        enabled: false
```

Variations stored remotely but served via the Symfony origin (streamed from flysystem). Useful for headless API setups.

### Emergency kill-switch

[](#emergency-kill-switch)

```
hexis_sulu_media_cdn:
    async_proxy:
        miss_strategy: 'on_the_fly'
```

Restores Sulu's native conversion path without touching code. Reapply `serve_original` once the issue is resolved.

### Local dev (originals on local disk, no CDN)

[](#local-dev-originals-on-local-disk-no-cdn)

Just don't override defaults — `format_cache.flysystem_service: default.storage` writes variations to the same place Sulu writes originals.

Console commands
----------------

[](#console-commands)

### `hexis:media:regenerate-variations`

[](#hexismediaregenerate-variations)

```
# regenerate one media synchronously
bin/console hexis:media:regenerate-variations --media=42 --sync

# regenerate one format for a collection
bin/console hexis:media:regenerate-variations --collection=7 --format=sulu-240x

# repeatable --format for a whitelist
bin/console hexis:media:regenerate-variations --format=sulu-100x100 --format=sulu-240x

# full repopulate (async; consume the queue afterwards)
bin/console hexis:media:regenerate-variations --purge-first
bin/console messenger:consume image_variations --memory-limit=128M

# scope batch size for the "all media" case
bin/console hexis:media:regenerate-variations --batch-size=50
```

Flags:

- `--media=` — comma-separated media IDs
- `--collection=` — scope to a Sulu collection
- `--format=` — repeatable; format keys whitelist (default: all configured formats)
- `--sync` — run in-process instead of dispatching to Messenger
- `--purge-first` — delete existing variations before regenerating
- `--batch-size=` — pagination size for the "all media" case (default `command.default_batch_size`)

Sulu integration points
-----------------------

[](#sulu-integration-points)

The bundle does not reimplement anything Sulu already provides:

ConcernSulu/Symfony class reusedImage conversion`Sulu\Bundle\MediaBundle\Media\ImageConverter\ImageConverterInterface`Format catalog`%sulu_media.image.formats%` parameter (loaded from `image-formats.xml`)Original file stream`Sulu\Bundle\MediaBundle\Media\Storage\StorageInterface`Media + FileVersion lookup`Sulu\Bundle\MediaBundle\Entity\MediaRepositoryInterface`Upload trigger events`MediaCreatedEvent`, `MediaVersionAddedEvent` (dispatched on the Symfony EventDispatcher by Sulu's ActivityBundle)Format cache contract`Sulu\Bundle\MediaBundle\Media\FormatCache\FormatCacheInterface` (implemented + aliased)Format manager contract`Sulu\Bundle\MediaBundle\Media\FormatManager\FormatManagerInterface` (decorated)Image proxy route`sulu_media.website.image.proxy` → `MediaStreamController::getImageAction` (untouched; consumes the decorated FormatManager)Media deletion is also handled transparently: Sulu's `MediaManager::delete()` already calls `FormatManagerInterface::purge()` per file version, which flows through `FlysystemFormatCache::purge()` and removes the variations from the storage backend. No additional subscriber is needed.

Gotchas
-------

[](#gotchas)

### S3 bucket with `BucketOwnerEnforced` ownership + `visibility: 'public'`

[](#s3-bucket-with-bucketownerenforced-ownership--visibility-public)

Modern S3 buckets (default since April 2023) disable ACLs entirely. Setting `format_cache.visibility: 'public'` will cause uploads to fail with:

```
AccessControlListNotSupported: The bucket does not allow ACLs

```

Either switch the bucket's Object Ownership to "Bucket owner preferred", or — recommended — leave `visibility: null` and grant public read via a bucket policy on the variation prefix.

### Sulu admin appends `&inline=1` to thumbnail URLs

[](#sulu-admin-appends-inline1-to-thumbnail-urls)

Sulu's `MediaFormats.js` and the `sulu_get_media_url` Twig helper blindly append `&inline=1` (or `?inline=1`) to the URL returned by `getMediaUrl()`. This is incompatible with SigV4 presigned URLs because AWS signatures cover **all** query parameters except `X-Amz-Signature`.

This bundle handles it transparently: when `cdn.presign.enabled = true`, `getMediaUrl()` returns the Sulu proxy URL (not the signed URL). Sulu's append is harmless because Symfony's origin ignores the `inline` param. The 302 target is signed at request time, after any third-party mangling. No action needed from you.

### CDN URL paths must match the actual S3 object key

[](#cdn-url-paths-must-match-the-actual-s3-object-key)

When `cdn.base_url` points directly at an S3 bucket / access point (no path-rewriting CDN), the URL must align with the object key. Set `cdn.object_key_prefix` to match the flysystem adapter's `prefix` option (e.g. `'media'` when `aws.storage` has `prefix: 'media'`). If you put CloudFront in front with a path rewrite rule, leave it empty.

### Restoring from `SuluTrashBundle`

[](#restoring-from-sulutrashbundle)

If the host project uses `SuluTrashBundle`, restoring a deleted media brings back the *original* file but not the variations (they were purged on delete). The first request to each variation URL will trigger the `serve_original` fallback + a self-healing regen — recoverable with one round of regeneration, no manual intervention needed.

### Presigning + CDN edge caching

[](#presigning--cdn-edge-caching)

Each call to `getMediaUrl()` (when presigning is on) returns a URL with a fresh `X-Amz-Date`. If you put a public CDN in front, every page render produces a new URL — the CDN treats each as a separate cache key. For presign + CDN, prefer CloudFront with OAC over public CDN + presigned URLs.

Testing
-------

[](#testing)

Unit tests live in `tests/Unit/`. Run via PHPUnit:

```
vendor/bin/phpunit packages/hexis-sulu-media-cdn-bundle/tests
```

End-to-end testing requires a running Sulu stack + an S3-compatible flysystem backend. Verify:

1. Service wiring: `bin/console debug:container sulu_media.format_cache` reports `FlysystemFormatCache`; `sulu_media.format_manager` is decorated by `AsyncFormatManager`.
2. Upload a media; confirm `sync_admin_format` exists on the storage backend immediately, and a message lands on the `image_variations` transport.
3. Consume the queue; confirm every configured format is generated and persisted.
4. Delete one variation from the backend; request its URL; expect HTTP 200 with the original bytes streamed and a new regeneration message queued.
5. With `cdn.presign.enabled = true`: confirm `getMediaUrl()` returns the proxy URL, and that the proxy 302s to a freshly-signed URL that fetches HTTP 200 from S3.

Contributing
------------

[](#contributing)

Issues and pull requests welcome at .

License
-------

[](#license)

MIT - see [LICENSE](LICENSE).

###  Health Score

19

—

LowBetter than 9% of packages

Maintenance58

Moderate activity, may be stable

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% 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.

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/1439958?v=4)[Sinisa Valentic](/maintainers/tic984)[@tic984](https://github.com/tic984)

---

Top Contributors

[![ivan-ivakic](https://avatars.githubusercontent.com/u/2583303?v=4)](https://github.com/ivan-ivakic "ivan-ivakic (3 commits)")

### Embed Badge

![Health badge](/badges/hexis-sulu-media-cdn-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/hexis-sulu-media-cdn-bundle/health.svg)](https://phpackages.com/packages/hexis-sulu-media-cdn-bundle)
```

###  Alternatives

[olivierbon/craft-squeeze

Zip one or multiple craft assets on the fly for frontend user to download.

137.5k](/packages/olivierbon-craft-squeeze)

PHPackages © 2026

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