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_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

ParameterDefaultDescription
adapterRequired. 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
prefixnullOptional 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's publicUrl() method to return a direct CDN or storage URL. Requires the adapter to implement Flysystem's PublicUrlGenerator interface (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'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/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 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 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 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>.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.

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.