The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftAnnotations

Uploadable

Add file upload support to any component with Flysystem adapters and optional Imagine image processing.

#[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

ParameterDefaultDescription
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
prefixnullOptional 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's publicUrl() method to return a direct CDN or storage URL. Works when the adapter implements Flysystem's PublicUrlGenerator interface (e.g. league/flysystem-aws-s3-v3, league/flysystem-google-cloud-storage) or when the adapter tag's config sets public_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.
Changed in api-components-bundle#269. Earlier versions only checked the adapter, so a field with 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's temporaryUrl() method to return a pre-signed URL with an expiry (default: +3 days). Requires the adapter to implement Flysystem's TemporaryUrlGenerator interface. 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"
                }
            ]
        }
    }
}
Each field maps to an array, not a single object. Element [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 single filename column, so it only fits one field. For multiple files, declare your own ?string storage property per field instead.
  • No #[ORM\Column] on the storage properties. The bundle's UploadableListener auto-maps each configured property: as a nullable string column — you just declare the ?string property.

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 to useCwaResourceUpload on 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.

Every upload becomes its own stored object. Because the stored name is unique, editing, replacing, or deleting the file on one resource can never overwrite or remove the file another resource references — even if two uploads share the same original filename.

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.

Long-running runtimes (FrankenPHP worker mode, RoadRunner): use 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:

SettingTemplate value
upload_max_filesize / post_max_size (PHP ini)20M / 21M
#[Assert\File(maxSize: ...)] on the field20M
memory_limit (PHP ini)512M
Largest image accepted40 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 maxEdge or maxPixels to keep larger originals, check the result still fits under maxSize and maxPixels here.
  • 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.
A missing source file doesn't break the resource. If a file's metadata is still cached but the file itself is missing from storage, for example after restoring production data into a local database without the files, each Imagine filter is skipped and the bundle logs a 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.

This replaces the old app-side 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.