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_adapter, alias: 'local' }
The alias becomes the adapter name you use in #[UploadableField], and the tag creates an api_components.filesystem.{alias} service. The tag also accepts an optional config map, which is passed straight to the Flysystem filesystem — for example config: { public_url: 'https://cdn.example.com/' }.
Add to Your Entity
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Silverback\ApiComponentsBundle\Entity\Utility\PublishableTrait;
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 entity property (and column) that stores the file's stored path |
prefix | null | Optional path prefix inside the storage (e.g. 'images/'). It is prepended as-is, so include the trailing / |
imagineFilters | [] | LiipImagine filter names to apply at upload time |
urlGenerator values
'api'(default) — the file is served through the owning resource's own download route,GET /{resource}/{id}/download/{property}(e.g./component/images/018e-.../download/file). 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. Works when the adapter implements Flysystem'sPublicUrlGeneratorinterface (e.g.league/flysystem-aws-s3-v3,league/flysystem-google-cloud-storage) or when the adapter tag'sconfigsetspublic_url(see above), so a local adapter served from a web root works too. Falls back to'api'only if neither can generate a public URL.
urlGenerator: 'public' and only public_url configured quietly got the /download/{property} API URL. It now gets the public URL. If you relied on the API route for such a field, set urlGenerator: 'api' explicitly.'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/ld+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,
"imagineFilter": null
},
{
"contentUrl": "https://cdn.example.com/images/018e-...thumbnail.jpg",
"mimeType": "image/jpeg",
"width": 300,
"height": 200,
"imagineFilter": "thumbnail"
}
]
}
}
}
[0] is the stored original; each Imagine variant is a further element, identified by its own imagineFilter name. There is no nested object keyed by filter name.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 ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
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 an array holding the original plus 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>[0].contentUrl (above) rather than constructing it from the uploaded name.
On a component that is also #[Publishable], editing a published resource makes a draft with its own copy of the file. The copy is written beside the original, under the same field prefix: and the same tokenised naming. If the original is missing from storage, the draft keeps the original path rather than losing it.
2.0.0-alpha.2 or later, the first releases to include PR #209. 1.1.11 and 2.0.0-alpha.1 don't include it. Before it, clearing a file ({ "file": null }) was remembered by the worker after the request ended, so later publishes served by the same worker could delete their own files.Large Images and PHP Memory
Imagine builds thumbnails inside the upload request, and GD decodes the whole image to do it. That takes about 11.7 MB of memory per megapixel, whatever the file size: a 24-megapixel JPEG of a few MB needs about 280 MB. A photo too big for PHP's memory_limit makes the upload fail with a 500.
Keep these limits in step:
| Setting | Template value |
|---|---|
upload_max_filesize / post_max_size (PHP ini) | 20M / 21M |
#[Assert\File(maxSize: ...)] on the field | 20M |
memory_limit (PHP ini) | 512M |
| Largest image accepted | 40 megapixels (about 470 MB to thumbnail) |
Admin uploads resized in the browser (cwa.upload.image, a module default) | over 2560 px on the longest edge or over 20 megapixels |
To turn an oversized photo into a clear 422 instead of a 500, limit its pixel count. SVG has no pixel dimensions, and Assert\Image would reject it, so exempt it:
#[Silverback\UploadableField(adapter: 'local', imagineFilters: ['thumbnail'])]
#[Assert\File(maxSize: '20M')]
#[Assert\When( expression: 'value !== null && value.getMimeType() !== "image/svg+xml"', constraints: [ new Assert\Image( maxPixels: 40_000_000, maxPixelsMessage: 'This image is too large ({{ pixels }} pixels). Please resize it to at most {{ max_pixels }} pixels and upload it again.', ), ],)] public ?File $file = null;
If you raise memory_limit, raise the php pod's memory limit with it. See Kubernetes → Resource Requests and Limits.
Browser-side resizing
The Nuxt module resizes large JPEG, PNG and WebP images in the browser before an admin upload sends them. By default, anything over 2560 px on its longest edge or over 20 megapixels is resized to fit both. A 2560 px image is at most 6.6 megapixels, well under the 40-megapixel limit, so a resized photo needs about 77 MB to thumbnail instead of hundreds. See useCwaResourceUpload for the options.
The two sets of numbers work together. If you change one, check the other:
- If you raise the module's
maxEdgeormaxPixelsto keep larger originals, check the result still fits undermaxSizeandmaxPixelshere. - If you lower the limits here, lower the module's options too, or the API will refuse some resized admin uploads.
Keep the server limits even with browser resizing on. The browser only resizes uploads made through the admin. A direct API request, an old browser, or a field with resizing turned off still sends the original file.
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.warning: Skipped the imagine filter "…" because its source image "…" could not be loaded. The response still includes the original media object and its download URL. Before api-components-bundle#301, the whole GET returned a 500. In production, this warning means a stored file has gone missing.LiipImagineBundle ships as a dependency of the API bundle, so it is already installed — you only need to configure it to use CWA's Flysystem data loader:
# 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\Entity\Utility\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).
Filters returned from getImagineFilters() are appended to the field's imagineFilters, not a replacement for them — declare a filter in both places and it is generated twice.
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.
If you set custom validationGroups on #[Silverback\Publishable], requiredOnPublish is checked in those groups too, so the file is still required when publishing.
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.