alengo/sulu-translated-media-bundle

SEO-friendly translated media filenames for Sulu CMS

Maintainers

Package info

github.com/alengodev/SuluTranslatedMediaBundle

Homepage

Type:symfony-bundle

pkg:composer/alengo/sulu-translated-media-bundle

Statistics

Installs: 52

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

3.0.12 2026-06-09 13:23 UTC

This package is auto-updated.

Last update: 2026-06-09 13:24:05 UTC


README

SEO-friendly translated media filenames for Sulu CMS 3.x.

Serves media files under locale-specific SEO filenames (e.g. /uploads/red-shoes-de.jpg) while keeping the original file stored under its original name. Includes an "Additional Data" admin tab with locale-aware title, description, and SEO filename fields — plus optional boolean flags (verifyDownload, aiGenerated).

Features

  • Translated filenames — per-locale seoFilename, title, description in me_media_translations
  • Built-in Media entity — ready-to-use Media entity extending Sulu's base; no project entity required
  • TranslatedFormatManager — replaces Sulu's default FormatManager via compiler pass
  • Twig functionssulu_translated_media_url() / sulu_translated_media_urls() with WebP support
  • Format cache warming — pre-generates the format cache (jpg/webp/avif, x1 & x2) for all image media under their translated filenames
  • Admin tab — "Additional Data" tab auto-registered in the Sulu Media admin
  • Zero-configsulu_media.objects.media.model and sulu_admin resources are auto-configured

Requirements

  • PHP 8.2+
  • Sulu CMS ~3.0
  • Symfony 7.x

Installation

composer require alengo/sulu-translated-media-bundle

Register the bundle in config/bundles.php:

Alengo\SuluTranslatedMediaBundle\TranslatedMediaBundle::class => ['all' => true],

Import the admin API routes in config/routes/sulu_admin.yaml:

TranslatedMediaBundle:
    resource: "@TranslatedMediaBundle/Resources/config/routing_admin_api.yaml"
    prefix: /admin/api

Run a database migration or schema update to create the me_media_translations table:

bin/adminconsole doctrine:schema:update --force

That's it — no further configuration required.

Twig Usage

{# Single URL with translated filename #}
{{ sulu_translated_media_url(media, '800x', app.request.locale) }}

{# With explicit format override #}
{{ sulu_translated_media_url(media, '800x', 'de', 'webp') }}

{# All format URLs (default + WebP) for use in <picture> / srcset #}
{% set urls = sulu_translated_media_urls(media, '800x', app.request.locale) %}
<picture>
    <source srcset="{{ urls.webp }}" type="image/webp">
    <img src="{{ urls.default }}">
</picture>

Provided Models

Class Purpose
Entity\Media Concrete Doctrine entity (me_media) — use directly or extend
Entity\MediaTranslations Locale rows in me_media_translations
Model\MediaTranslationsAwareInterface + MediaTranslationsTrait Locale fields: title, description, seoFilename
Model\MediaAdditionalDataInterface + MediaAdditionalDataTrait Boolean flags: verifyDownload, aiGenerated

Commands

alengo:translated-media:format-cache:warm

Pre-generates ("warms") the local format cache (public/uploads/media) for every image media, so the frontend never has to generate a thumbnail on the first request. Because the TranslatedFormatManager stores each rendition under the URL filename, the command warms one cache entry per distinct base filename: the original filename (covers every locale without a SEO override) plus the slugged seoFilename of every translation. For each base filename it warms both the x1 variant (e.g. 800x600) and the x2 / retina variant (800x600@2x), each in jpg, webp and avif (intersected with the formats Sulu can actually produce for the source mime type).

Which formats are warmed: the base format keys are read from the source YAML (config/app/image-formats.yaml by default). If it defines a warm_cache_image_formats: list, only those formats are warmed — keep this to the handful your frontend actually requests so you don't waste time encoding every registered format. If that key is absent the command falls back to the full image_formats: list. Example:

# config/app/image-formats.yaml
image_formats:
    - '1920x1080'
    - '1280x800'
    - '800x600'
    # … every registered format

warm_cache_image_formats:   # the subset actually warmed
    - '1024x768'
    - '800x600'
    - '640x480'
    - '380x240'

Media scope: by default only media actually referenced by content (read from Sulu's re_references reference store, resourceKey = "media") are warmed — uploaded-but-unused media are skipped. Pass --no-referenced-only to warm every image media; an explicit --media list always wins. If the reference table is missing the command falls back to all media (with a warning), and if it is empty it warns that references may not be indexed — so it never silently warms nothing.

bin/console alengo:translated-media:format-cache:warm                       # referenced media only (default)
bin/console alengo:translated-media:format-cache:warm --no-referenced-only  # every image media
bin/console alengo:translated-media:format-cache:warm --dry-run
bin/console alengo:translated-media:format-cache:warm --skip-existing
bin/console alengo:translated-media:format-cache:warm --media=1,42,99 --extensions=webp,avif

By default existing cache files are overwritten (re-encoded). Pass --skip-existing to leave already-cached renditions untouched and only generate the missing ones — much faster for incremental re-runs.

jpeg is accepted as an alias for jpg — Sulu normalises both to the jpg cache extension, so there is no separate .jpeg cache file.

Option Default Description
--source / -s config/app/image-formats.yaml Source YAML file with the base format keys, relative to project root (reads warm_cache_image_formats:, falling back to image_formats:)
--extensions / -x jpg,webp,avif Comma-separated output extensions to warm
--media / -m (all) Restrict to a comma-separated list of media IDs (overrides --referenced-only)
--no-referenced-only Warm every image media, not only those referenced by content (default: referenced only)
--formats (all) Restrict to a comma-separated list of base format keys (a subset of the source file)
--no-2x Skip the @2x / retina variants (halves the work)
--parallel / -j 1 Number of parallel worker processes
--no-decode-once Disable the default decode-once strategy (decode the source once per rendition via the FormatManager)
--skip-existing Skip renditions that already exist in the cache (faster re-runs)
--dry-run List what would be generated without writing any file

Performance

Warming the full matrix (every media × every format × @2x × jpg/webp/avif) is CPU-heavy — AVIF encoding in particular. Several levers shorten the wall-clock time:

  • De-duplicated fan-out (always on) — the converted bytes of a rendition depend only on the source, format key and extension, not on the (translated) URL filename. Each rendition is therefore encoded once and the identical bytes are written to every translated filename (original + each SEO name). For multilingual media with several SEO filenames this alone removes the bulk of the redundant conversions.

  • --parallel=N — image conversion is single-threaded per PHP process. Spreading the media over N worker processes gives a near-linear speed-up on multi-core hosts. A good starting point is the number of CPU cores:

    bin/console alengo:translated-media:format-cache:warm --parallel=8 --skip-existing
  • Warm AVIF separately / off-peak — AVIF is 10–50× slower to encode than jpg/webp. Warm the cheap formats first so the site is fast immediately, then warm AVIF in the background:

    bin/console alengo:translated-media:format-cache:warm --extensions=jpg,webp --parallel=8
    bin/console alengo:translated-media:format-cache:warm --extensions=avif   --parallel=8 --skip-existing

    To trade a little AVIF quality for a lot of speed, lower the encoder effort in your Sulu image-format options (e.g. an avif/heic speed/quality option, depending on your ImageMagick/Imagine build).

  • --formats / --no-2x — only warm what the frontend actually requests. Restricting to the handful of formats used in your templates and dropping retina variants shrinks the matrix directly.

  • Decode-once (default, on) — each source image is decoded a single time per media and reused across all formats and extensions, instead of re-loading and re-decoding the source for every rendition. Restricted to single-layer raster images; animated GIF/WebP, SVG and any decode failure transparently fall back to Sulu's regular converter, so the cached bytes always match the live image proxy. The integration test asserts this byte-for-byte for both the gd and imagick adapters across jpg/webp/avif (see Tests). Pass --no-decode-once to decode per rendition everywhere (the previous, more conservative behaviour).

--parallel runs each worker as a separate bin/console process over a slice of the media, aggregating progress into a single bar. It has no effect on --dry-run (which does no work).

Tests

composer install
composer test          # or: vendor/bin/phpunit
  • tests/Unit — pure command logic (translated filename building, option parsing, shard partitioning, worker-summary parsing). No database or container needed.
  • tests/Integration — proves the default decode-once strategy produces byte-identical output to Sulu's ImagineImageConverter across multiple formats and extensions (jpg/webp/avif), for every locally available Imagine adapter (gd and/or imagick). Skipped automatically if neither extension is installed.