Uploadable
#[Silverback\Uploadable] with #[Silverback\UploadableField] turns a component into a file host. Files are stored via Flysystem, served via a public URL, and optionally processed through LiipImagineBundle to generate multiple image variants (thumbnail, hero, square, etc.).
Flysystem Setup
First, install a Flysystem adapter:
# Local filesystem
composer require league/flysystem-local
# Google Cloud Storage
composer require league/flysystem-google-cloud-storage
# S3-compatible
composer require league/flysystem-aws-s3-v3
Register the adapter as a service with the CWA tag:
# config/services.yaml
services:
League\Flysystem\Local\LocalFilesystemAdapter:
arguments:
- '%kernel.project_dir%/var/storage/default'
tags:
- { name: silverback.api_components.filesystem_provider, alias: 'local' }
The alias becomes the adapter name you use in #[UploadableField].
Add to Your Entity
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Silverback\ApiComponentsBundle\Entity\Utility\UploadableTrait;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Validator\Constraints as Assert;
#[Silverback\Publishable]
#[Silverback\Uploadable]
#[ORM\Entity]
#[ApiResource(mercure: true)]
class Image extends AbstractComponent
{
use PublishableTrait;
use UploadableTrait;
#[Silverback\UploadableField(adapter: 'local', urlGenerator: 'public', imagineFilters: ['thumbnail'])]
#[Assert\File(maxSize: '5M')]
public ?File $file = null;
}
UploadableField Parameters
| Parameter | Default | Description |
|---|---|---|
adapter | — | Required. The Flysystem adapter alias |
urlGenerator | 'api' | How to generate the file URL — see below |
property | 'filename' | The property name on the media object that stores the filename |
prefix | null | Optional path prefix inside the storage (e.g. 'images/') |
imagineFilters | [] | LiipImagine filter names to apply at upload time |
urlGenerator values
'api'(default) — the file URL is served through a Symfony download route (/_/media_objects/{id}/download). Works with any adapter regardless of whether it supports direct URL generation.'public'— calls Flysystem'spublicUrl()method to return a direct CDN or storage URL. Requires the adapter to implement Flysystem'sPublicUrlGeneratorinterface (e.g.league/flysystem-aws-s3-v3,league/flysystem-google-cloud-storage). Falls back to'api'if the adapter does not support it.'temporary'— calls Flysystem'stemporaryUrl()method to return a pre-signed URL with an expiry (default:+3 days). Requires the adapter to implement Flysystem'sTemporaryUrlGeneratorinterface. Falls back to'api'if not supported. The primary use case is S3 pre-signed URLs for private/access-controlled files.
// S3 pre-signed URL — valid for 3 days, falls back to API route if adapter doesn't support it
#[Silverback\UploadableField(adapter: 's3', urlGenerator: 'temporary')]
public ?File $file = null;
// CDN public URL — direct S3/GCS link
#[Silverback\UploadableField(adapter: 'gcs', urlGenerator: 'public')]
public ?File $image = null;
Uploading a File
Option 1 — Base64 in the JSON body (preferred for REST clients):
POST /component/images
Content-Type: application/json
{
"file": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."
}
Option 2 — Multipart form upload:
POST /component/images/{id}/upload
Content-Type: multipart/form-data
file=<binary>
The _metadata Response
After upload, the resource includes a _metadata.mediaObjects map:
{
"@id": "/component/images/018e-...",
"_metadata": {
"mediaObjects": {
"file": {
"contentUrl": "https://cdn.example.com/images/018e-....jpg",
"fileSize": 245120,
"mimeType": "image/jpeg",
"width": 1920,
"height": 1080,
"thumbnail": {
"contentUrl": "https://cdn.example.com/images/018e-...thumbnail.jpg",
"width": 300,
"height": 200
}
}
}
}
}
Each entry under the field name (file) contains the original upload plus any Imagine variants as nested objects.
Multiple Files on One Resource
A single component can host several files — a poster and a thumbnail, a document plus its cover, etc. Declare one #[UploadableField] per transient File property, and give each one a distinct property: — the name of the column that stores its filename.
propertydefaults to 'filename' for every field. If two #[UploadableField]s share the same storage property, the bundle now throws UnsupportedAnnotationException at metadata load (boot) — it no longer silently lets them share one column. Give each field on a multi-file entity its own property:.use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Validator\Constraints as Assert;
#[Silverback\Uploadable]
#[ORM\Entity]
#[ApiResource(mercure: true)]
class MediaBlock extends AbstractComponent
{
#[Silverback\UploadableField(adapter: 'local', property: 'posterFilename', imagineFilters: ['hero'])] #[Assert\File(maxSize: '5M')]
public ?File $poster = null;
#[Silverback\UploadableField(adapter: 'local', property: 'thumbnailFilename', imagineFilters: ['thumbnail'])] #[Assert\File(maxSize: '2M')]
public ?File $thumbnail = null;
// Storage columns — auto-mapped by the bundle; no #[ORM\Column] needed
public ?string $posterFilename = null; public ?string $thumbnailFilename = null;}
Two things differ from the single-file setup:
- No
UploadableTrait. The trait provides a singlefilenamecolumn, so it only fits one field. For multiple files, declare your own?stringstorage property per field instead. - No
#[ORM\Column]on the storage properties. The bundle'sUploadableListenerauto-maps each configuredproperty:as a nullable string column — you just declare the?stringproperty.
Each field is then fully independent:
- Its own entry under
_metadata.mediaObjects— keyed by the transient property name (poster,thumbnail), each with its own Imagine variants. - Its own download route:
GET /{resource}/{id}/download/{property}. - Multipart uploads key each file by its transient property name (
poster,thumbnail) — the same name you'd pass as the second argument touseCwaResourceUploadon the front-end.
See Images & Media → Multiple File Fields for displaying and editing several fields in the Nuxt app.
Stored Filenames
Uploaded files are stored under a unique tokenised name derived from the original — <original-stem>-<token>.<ext> (e.g. an uploaded hero.jpg is stored as hero-3f9a2b7c.jpg). Data-URI / base64 uploads instead keep a UUID-based name.
The stored name is therefore not predictable and does not equal the uploaded filename. Read the real URL from _metadata.mediaObjects.<field>.contentUrl (above) rather than constructing it from the uploaded name.
LiipImagineBundle Integration
LiipImagine generates image variants automatically at upload time.
imagineFilters are only applied to raster images — a mime type of image/* that isn't image/svg+xml. A non-image upload (PDF, docx, SVG) to a field that declares filters simply skips Imagine and returns its primary media object; it no longer errors. This makes a mixed field, or a document uploaded to an otherwise image-ish field, safe.Install the bundle and configure it to use CWA's Flysystem data loader:
composer require liip/imagine-bundle
# config/packages/liip_imagine.yaml
liip_imagine:
data_loader: silverback.api_components.liip_imagine.binary.loader
filter_sets:
thumbnail:
quality: 80
filters:
thumbnail: { size: [300, 300], mode: outbound }
hero:
quality: 90
filters:
thumbnail: { size: [1200, 630], mode: outbound }
Configure the cache resolver to write back through Flysystem:
services:
app.imagine.cache.resolver.local:
class: Silverback\ApiComponentsBundle\Imagine\FlysystemCacheResolver
arguments:
$filesystem: '@api_components.filesystem.local'
$rootUrl: 'https://cdn.example.com'
tags:
- { name: 'liip_imagine.cache.resolver', resolver: local }
liip_imagine:
cache: local
The service api_components.filesystem.{alias} is created automatically for each registered adapter.
Dynamic Filter Selection
For cases where the required filters depend on runtime data (request context, entity state), implement ImagineFiltersInterface:
use Silverback\ApiComponentsBundle\Imagine\ImagineFiltersInterface;
use Symfony\Component\HttpFoundation\Request;
class Image extends AbstractComponent implements ImagineFiltersInterface
{
public function getImagineFilters(string $property, ?Request $request): array
{
return ['thumbnail', 'hero', 'square'];
}
}
$request is null when called during upload processing outside a web request (e.g. in a fixture).
Deleting a File
Send null for the field property in a PATCH:
PATCH /component/images/{id}
Content-Type: application/merge-patch+json
{ "file": null }
The bundle removes the file from storage and clears the media object data.
Requiring a File on Publish
On a component that is both #[Publishable] and #[Uploadable], set requiredOnPublish: true to block publishing until a file is present — either a transient upload in the request or an already-stored filename:
#[Silverback\Publishable]
#[Silverback\Uploadable]
#[ORM\Entity]
#[ApiResource(mercure: true)]
class Banner extends AbstractComponent
{
use PublishableTrait;
use UploadableTrait;
#[Silverback\UploadableField(adapter: 'local', requiredOnPublish: true)] #[Assert\File(maxSize: '5M', mimeTypes: ['image/*'])]
public ?File $file = null;
}
Attempting to publish without a file produces a validation violation on the file property, grouped under {ShortName}:published (e.g. Banner:published). The default message is:
A file must be uploaded for the `{{ property }}` field before publishing.
Override it per field with requiredOnPublishMessage. The {{ property }} placeholder is substituted for the field name:
#[Silverback\UploadableField(
adapter: 'local',
requiredOnPublish: true,
requiredOnPublishMessage: 'Please attach a banner image before publishing.',)]
public ?File $file = null;
Each flagged field validates independently, so on a multi-file entity you can require some fields and not others, each with its own message. This composes with #[Assert\File] — the file-type and size checks still run when a file is supplied. For "at least N of these fields" or other conditional rules, add an #[Assert\Callback] to the {ShortName}:published group.
RequiresUploadedFileTrait — the requirement is now declared per field on the annotation itself.On the Front-End
Use useCwaComponent with the withFile() plugin in your Vue component. It exposes the field under a files map (files.file.contentUrl, files.file.displayMedia, files.file.loaded, files.file.handleLoad) — see Images & Media for the full reference.