Storage

Media go through Flysystem (flysystem-bundle): local disk by default, or S3, FTP, SFTP and other remote backends.

Default Configuration (Local Storage)

Media are stored in media_dir:

pushword:
  media_dir: '%kernel.project_dir%/media'
  public_media_dir: media

Using Remote Storage (S3, FTP, etc.)

Install the Flysystem adapter, then redefine the pushword.mediaStorage storage in your app.

Example: Amazon S3 Storage

  1. Install the S3 adapter:
composer require league/flysystem-aws-s3-v3
  1. Create config/packages/flysystem.yaml in your project:
flysystem:
  storages:
    pushword.mediaStorage:
      aws:
        client: 'aws_client_service'
        bucket: 'your-bucket-name'
        prefix: 'media'
  1. Configure the AWS client service (see the flysystem-bundle docs) and declare the storage remote.

Example: FTP Storage

  1. Install the FTP adapter:
composer require league/flysystem-ftp
  1. Create config/packages/flysystem.yaml:
flysystem:
  storages:
    pushword.mediaStorage:
      ftp:
        host: 'ftp.example.com'
        username: '%env(FTP_USERNAME)%'
        password: '%env(FTP_PASSWORD)%'
        root: '/path/to/media'

Then declare the storage remote.

Cloudflare R2 originals and image cache

R2 exposes an S3-compatible API, so it uses the same Flysystem adapter. Install it first:

composer require league/flysystem-aws-s3-v3

Register the R2 client in config/services.php:

use Aws\S3\S3Client;

$services->set('app.r2_client', S3Client::class)
    ->args([[
        'version' => 'latest',
        'region' => 'auto',
        'endpoint' => '%env(R2_ENDPOINT)%',
        'credentials' => [
            'key' => '%env(R2_ACCESS_KEY_ID)%',
            'secret' => '%env(R2_SECRET_ACCESS_KEY)%',
        ],
    ]]);

Then configure both stores in config/packages/flysystem.yaml. They may use the same bucket because their prefixes do not overlap:

flysystem:
  storages:
    pushword.mediaStorage:
      aws:
        client: app.r2_client
        bucket: '%env(R2_BUCKET)%'
        prefix: originals

    pushword.mediaCacheStorage:
      aws:
        client: app.r2_client
        bucket: '%env(R2_BUCKET)%'
        prefix: cache

Tell Pushword that both stores are remote in config/packages/pushword.yaml and in config/services.php:

pushword:
  media_cache_is_local: false
  # Include the Flysystem prefix when the custom domain exposes the bucket root.
  media_cache_public_url: 'https://media.example.com/cache'
use Pushword\Core\Service\MediaStorageAdapter;

use function Symfony\Component\DependencyInjection\Loader\Configurator\service;

$services->set(MediaStorageAdapter::class)
    ->args([
        '$storage' => service('pushword.mediaStorage'),
        '$mediaDir' => '%pw.media_dir%',
        '$isLocal' => false,
    ]);

media_cache_public_url is optional. With it, rendered pages reference R2 directly and legacy /media/<filter>/<file> requests redirect there. Without it, Symfony streams cached files from R2. The R2 custom domain must be public when a public URL is configured; the S3 API endpoint and credentials remain private.

Image processing still happens on local temporary files. Pushword uploads a derivative only after its local atomic write completes, then republishes the optimized result. media_cache_dir therefore remains a local working directory even when R2 is the authoritative cache.

Custom MediaStorageAdapter {id=custom-mediastorageadapter}

A remote pushword.mediaStorage needs the MediaStorageAdapter service redefined with $isLocal: false, as in the R2 example:

  • true (default): image processing reads files directly from disk
  • false: files are downloaded to a temporary directory before processing (required for remote storage)

Available Adapters

AdapterPackage
LocalBuilt-in
Amazon S3league/flysystem-aws-s3-v3
FTPleague/flysystem-ftp
SFTPleague/flysystem-sftp-v3
Google Cloudleague/flysystem-google-cloud-storage
Azure Blobleague/flysystem-azure-blob-storage
Memoryleague/flysystem-memory

Configuration options: Flysystem documentation.

File names {id=file-names}

A media’s file is stored in media_dir under its file name. Names are reserved across current files and rename history:

  • A new upload uses the slugified name field, alt or client file name, in that order. If another media reserves the name or a file already exists there, a suffix is added: photo.jpg becomes photo-2.jpg. An upload never overwrites an existing file.
  • Renaming keeps previous names in fileNameHistory, so old references still resolve. A collision avoided during upload does not create a history entry.
  • An upload whose SHA-1 is already in the library is not stored twice. The admin form, the multi-upload, the block editor and the API return the existing media.
  • Older versions could leave two media naming one file. Renaming either is refused; pw:image:cache reports the conflict and processes neither. Removing one preserves the file for the other. To repair, compare both stored hashes with the file’s SHA-1 and remove the mismatching row.

Audit log {id=audit-log}

Media creations, renames and removals are logged at info on the pushword_media Monolog channel. Each record holds the media id, the file name (or from and to for a rename), the SHA-1 and the user. The user is null for console commands. A default production fingers_crossed handler writes info records only when an error follows, so route the channel to its own file:

# config/packages/monolog.yaml
when@prod:
    monolog:
        handlers:
            pushword_media:
                type: rotating_file
                path: '%kernel.logs_dir%/media.log'
                level: info
                max_files: 90
                channels: [pushword_media]

Notes

  • Image cache (thumbnails, optimized versions, og/ previews) uses pushword.mediaCacheStorage. It is local by default, in media_cache_dir — public/{public_media_dir}/ by default — and may be moved to remote Flysystem storage
  • Both image writers write to a .tmp file beside their target and rename it into place, so a reader never meets a half-written image. A process killed mid-write (OOM, a deploy restart) leaves that file behind; pw:image:cache deletes the ones older than an hour on each run, under media_dir and media_cache_dir, and reports how many it took and how many were empty
  • Uploads (VichUploaderBundle) go through the same Flysystem storage