softspring/media-bundle
Softspring MediaBundle is a Symfony bundle for managing uploaded media files and images, with Doctrine-backed media entities, configurable storage and upload handling, plus helpers for integrating and rendering media assets in your applications.
This package is a Symfony bundle (softspring/media-bundle) rather than a Laravel package, so day-to-day leverage assumes a Symfony app (or a Laravel app that has deliberately bridged Symfony's DI/HttpKernel). Treat the install as a Symfony bundle first; Laravel-side usage means manually wiring the services and config.
Minimal steps:
composer require softspring/media-bundleconfig/bundles.php:
Softspring\MediaBundle\SfsMediaBundle::class => ['all' => true],
sfs_media:
resource: '@SfsMediaBundle/config/routing.php'
liip/imagine-bundle are the usual companions; local storage for dev, S3 for prod).SfsMediaBundle extension/config tree (config/config.php) — the media types and image filters you must define.MediaFilterInterface / the built-in image filter services — this is where your transformation rules live.First real use case: define one media type (e.g. post_image) with one thumbnail and one large version, upload through the admin controller (or programmatically via the media manager), then render it in a template:
{{ sfs_media_render(post.image, 'large') }}
That single flow — type definition → upload → version generation → render — is the whole package in miniature.
Define media types in config, not in code. Each type declares its allowed versions. Keep version names semantic (thumb, card, hero) rather than pixel sizes; sizes belong to the filter, not the type.
Treat MediaVersion as the unit of storage. A Media is the logical asset; each MediaVersion is a concrete generated file. This split is what makes v5.6.0's unique index meaningful — one row per (media, version name) pair — and it is the mental model you want when debugging "why is my image stale".
Render via Twig helpers, resolve URLs server-side. Use sfs_media_url(...) when you need the raw path (e.g. into a srcset you build yourself) and the render helper when you want the bundle to emit the markup. Avoid constructing storage paths by hand; URLs change shape when you swap Flysystem adapters.
Leverage the new public-URL version filter (v5.6.0). This release adds a version public URL filter — the intended pattern is to derive a version's public URL in a filter chain rather than calling the storage layer directly. Practically: use it wherever you were previously concatenating a base URL + version path, and prefer it in Twig so the URL logic stays in one place. If you have custom filters that emit absolute URLs, audit them — this filter likely supersedes that code.
Programmatic uploads follow a two-step shape. Create/persist the Media, then let the bundle generate versions (either eagerly on flush or via a queued/console step depending on your config). For bulk imports, disable eager generation and run the version-generation command afterwards — otherwise an import of 10k assets will thrash your image driver.
Integrate at the boundary, not the middle. The cleanest Laravel/Symfony integration is: your domain entity holds a Media relation or a media id; a thin service wraps the bundle; controllers and templates never call bundle internals. When the bundle changes (as it did here), only the wrapper moves.
Filter chains are composable. Register small filter services and chain them (crop → resize → optimize) rather than one monolithic filter per version. It keeps cache keys stable and makes A/B-ing a new crop cheap.
Cache invalidation is your responsibility. On version regeneration, tag your cache entries by media id so a re-upload actually invalidates rendered pages. The bundle will not do this for you.
v5.6.0 adds a unique index on MediaVersion — migrate before you upgrade loudly. If your existing database already contains duplicate version rows (easy to accumulate if any past code path re-generated versions without cleaning up), the migration will fail on the index creation. Before deploying:
SELECT media_id, version, COUNT(*) c
FROM sfs_media_version
GROUP BY media_id, version
HAVING c > 1;
Deduplicate (keep the newest, repoint any references) and only then run the migration. There is now also protection against future duplications, so the new code path is safer than the old one — but the upgrade itself is the risky moment. Plan it as a data migration, not a schema migration.
Beware the "no prior assessment" trap on the version table. Because the unique index lands in a release that also adds a feature, it is tempting to lump them together. Roll them out separately: index first (with dedupe), then the URL filter adoption.
Version regeneration is not automatic on config change. Editing a filter's dimensions does not invalidate existing MediaVersion rows. You need an explicit regeneration pass; otherwise old files persist silently and your templates look inconsistent across assets.
Storage adapter swap changes URLs, not files. Moving from local to S3 leaves the DB untouched — paths are relative. If you rendered absolute URLs anywhere by hand, that's where breakage appears. The v5.6.0 public-URL filter is the fix; adopt it rather than patching strings.
Image driver memory limits. GD is easy but memory-hungry on large originals; Imagick is faster but slower to install. Whichever you pick, set a sane memory_limit on the CLI context — console-driven bulk generation fails with an opaque blank error when it runs out.
Routing name collisions. The bundle's admin routes use fairly generic names. If you mount them alongside your own media endpoints, prefix them or you'll get duplicate-route exceptions that point at the wrong file.
Extension points worth knowing: custom MediaFilterInterface implementations, the storage adapter (any Flysystem filesystem can be injected), and the Twig extension itself (safe to decorate for project-specific helpers rather than editing templates everywhere).
Debugging checklist when a version won't render: (1) is the version name declared in the media type config? (2) does the MediaVersion row exist in the DB? (3) does the file exist on the configured filesystem? (4) is the filter registered in the container? The package fails quietly between steps 1 and 2, which is the usual source of "it just shows nothing".
Don't chase Laravel parity. There is no ServiceProvider, no facade, no config/media.php. Any php artisan vendor:publish tutorial you find for this package is fabricated. If you're in Laravel, budget time for a bridge layer and keep it isolated.
How can I help you explore Laravel packages today?