alengo/sulu-translated-media-bundle
SEO-friendly translated media filenames for Sulu CMS 3.x. Serves uploads under locale-specific names like /uploads/red-shoes-de.jpg while storing originals unchanged. Adds an Additional Data admin tab, Twig URL helpers with WebP, and format cache warming; zero-config.
Check compatibility before installing. As of 3.2.0 the bundle targets Sulu 3.1 / Symfony 8.1. If your project is still on Sulu 2.x, pin to the older release line; if you are upgrading an existing install, bump Sulu/Symfony first, then the bundle.
composer require alengo/sulu-translated-media-bundle:^3.2
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
Update the schema (the bundle adds the media-translation table and joins):
bin/adminconsole doctrine:schema:update --force
First media translation
seoFilename (e.g. red-shoes-de), title, and description for a locale (e.g. de).First Twig usage
{{ sulu_translated_media_url(media, '800x', app.request.locale) }}
The rendered URL should reflect the translated filename (e.g. /uploads/red-shoes-de.jpg). If you still see the original filename, the translation was not persisted — check the Additional Data tab again before debugging anything else.
sulu_translated_media_url() and sulu_translated_media_urls() are the frontend entry points.Entity\Media and Entity\MediaTranslations define the data model; extend these for project-specific fields.TranslatedFormatManager: replaces Sulu's default FormatManager. Start here when URLs come out wrong or you need custom filename logic.composer.json of the bundle: on this release, verify the Sulu/Symfony constraints before filing any bug that looks like "service not found" or "method signature mismatch" — those usually mean a version skew, not a bundle bug.red-shoes.jpg) to Sulu.seoFilename to red-shoes-de, title to Rote Schuhe, description to Bequeme Herbstschuhe for de.<img src="{{ sulu_translated_media_url(productImage, '600x', app.request.locale) }}"
alt="{{ productImage.title[app.request.locale] }}">
German visitors get /uploads/red-shoes-de.jpg plus localized alt text; other locales fall back to the original filename.seoFilename: URL-safe filename (e.g. product-guide-fr).title: human-readable name (e.g. Guide du Produit).description: SEO description.verifyDownload (gated content), aiGenerated (metadata provenance).{# single #}
{{ sulu_translated_media_url(media, '400x', 'de') }}
{# responsive #}
{% set urls = sulu_translated_media_urls(media, '800x', app.request.locale) %}
<picture>
<source srcset="{{ urls.webp }}" type="image/webp">
<img src="{{ urls.default }}" alt="{{ media.title[app.request.locale] }}">
</picture>
TranslatedFormatManager (see below).// src/Entity/CustomMedia.php
use Alengo\SuluTranslatedMediaBundle\Model\MediaAdditionalDataTrait;
use Alengo\SuluTranslatedMediaBundle\Model\MediaTranslationsTrait;
class CustomMedia extends \Alengo\SuluTranslatedMediaBundle\Entity\Media
{
use MediaTranslationsTrait;
use MediaAdditionalDataTrait;
private ?string $customField = null;
// getters/setters...
}
# config/packages/sulu_media.yaml
sulu_media:
objects:
media:
model: App\Entity\CustomMedia
When you swap the model, re-run the schema diff — the bundle's table is joined to me_media, and a stale schema is the number one cause of "translation saved but not shown".
The Additional Data tab is auto-registered; adjust its presentation via sulu_admin navigation config rather than editing the bundle's templates.
TranslatedFormatManager// src/DependencyInjection/Compiler/TranslatedFormatManagerPass.php
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class TranslatedFormatManagerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$definition = $container->findDefinition(\Alengo\SuluTranslatedMediaBundle\Format\TranslatedFormatManager::class);
$definition->setClass(\App\Format\CustomTranslatedFormatManager::class);
}
}
Register it in Kernel::build(). Prefer a decorator (decorates: in services.yaml) over setClass() — it survives refactors of the bundle's service ID and keeps the original manager injectable.
Wire an importer and write straight to the media-translation table, reusing Sulu's Data Importer config. Validate that seoFilename values stay unique per locale — the bundle does not enforce uniqueness for you.
class CachedTranslatedMediaUrlGenerator
{
public function __construct(
private TranslatedFormatManager $manager,
private CacheInterface $cache
) {}
public function generate(string $mediaId, string $format, string $locale): string
{
return $this->cache->get(
"media_url_{$mediaId}_{$format}_{$locale}",
fn () => $this->manager->generate($mediaId, $format, $locale)
);
}
}
Expose it as sulu_cached_translated_media_url, and invalidate on save (Doctrine listener on the translation entity) — otherwise edited slugs stay stale until the cache expires.
| Pattern | Example |
|---|---|
| Locale-aware URLs | sulu_translated_media_url(media, '400x', app.request.locale) |
| Responsive images | sulu_translated_media_urls() + <picture> |
| Fallback handling | missing translations fall back to the original filename |
| Admin metadata | seoFilename / title / description per locale |
| Custom entities | extend Entity\Media for project fields |
| Cache optimization | cache generated URLs, invalidate on translation save |
Version skew after this release. 3.2.0 adds Sulu 3.1 / Symfony 8.1 support. The most common failure after a partial upgrade is a container build error or a signature mismatch in TranslatedFormatManager because the bundle resolved against a different Sulu minor. Fix: bump Sulu/Symfony and the bundle together, then bin/adminconsole cache:clear (not just the website cache).
Schema not updated after upgrade. The compiler pass replaces Sulu's FormatManager at container build time, so a stale schema surfaces as an empty translation rather than an error. Run doctrine:schema:update --force (or your migrations) after every bundle bump.
Missing locale translations. An empty seoFilename silently falls back to the original filename — nothing is logged. Fix: always set seoFilename for every target locale; audit rows where it is null/empty before going live.
seoFilename is not sanitized for you. Spaces, umlauts, or slashes produce URLs that look fine in the admin but break in <img src> or when a CDN re-encodes the path. Normalize on save (listener or admin form constraint) rather than at render time.
Asset cache and CDN invalidation. Renaming a translated file changes the URL, so old URLs die and new ones are cold. If you front Sulu's /uploads with a CDN, purge on translation save; if you don't, expect 404s on cached pages until they are re-rendered.
Route prefix collisions. The admin API route is registered under /admin/api; if another bundle claims the same path, the Additional Data tab loads but its XHRs 404. Check bin/adminconsole debug:router | grep translated first.
Twig alt/title access. media.title[app.request.locale] returns null (not the fallback) when a translation is missing — the fallback applies to URLs, not to arbitrary property access. Guard with |default(media.title.default) or a translated_media_title() helper.
Overriding the format manager. setClass() drops the original constructor arguments unless you reset them; a decorator avoids this entirely. After any override, verify both the admin preview and the frontend render — they resolve the manager through different paths.
Debugging checklist for "wrong filename". In order: (a) is the translation actually persisted (me_media_translations row for that locale)? (b) is the schema up to date? (c) is the container using your entity (sulu_media.objects.media.model)? (d) is the cache warm/stale? (e) only then look at TranslatedFormatManager.
aiGenerated on translations produced by importers/LLMs so editors can filter and review them later.sulu_translated_media_urls() over hand-building <picture> sources; format names must match Sulu's configured formats or you get an exception at render time.composer.json alongside your Sulu minor so the compatibility matrix in this section stays meaningful.How can I help you explore Laravel packages today?