Media go through Flysystem (flysystem-bundle): local disk by default, or S3, FTP, SFTP and other remote backends.
Media are stored in media_dir:
pushword:
media_dir: '%kernel.project_dir%/media'
public_media_dir: media
Install the Flysystem adapter, then redefine the pushword.mediaStorage storage in your app.
composer require league/flysystem-aws-s3-v3
config/packages/flysystem.yaml in your project:flysystem:
storages:
pushword.mediaStorage:
aws:
client: 'aws_client_service'
bucket: 'your-bucket-name'
prefix: 'media'
composer require league/flysystem-ftp
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.
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.
A remote pushword.mediaStorage needs the MediaStorageAdapter service redefined with $isLocal: false, as in the R2 example:
| Adapter | Package |
|---|---|
| Local | Built-in |
| Amazon S3 | league/flysystem-aws-s3-v3 |
| FTP | league/flysystem-ftp |
| SFTP | league/flysystem-sftp-v3 |
| Google Cloud | league/flysystem-google-cloud-storage |
| Azure Blob | league/flysystem-azure-blob-storage |
| Memory | league/flysystem-memory |
Configuration options: Flysystem documentation.
A media’s file is stored in media_dir under its file name. Names are reserved across current files and rename history:
photo.jpg becomes photo-2.jpg. An upload never overwrites an existing file.fileNameHistory, so old references still resolve. A collision avoided during upload does not create a history entry.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.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]
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.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