Media Storage
Keep the WordPress media library on any Laravel disk (S3, R2, Scaleway, MinIO) in Pollora, declare image sizes in PHP, serve them on demand or from Bunny or imgproxy, and sign private files.
Pollora can keep the WordPress media library on any Laravel disk — local, S3, R2, Scaleway, MinIO — with two settings: the disk and the public URL. WordPress goes on using its own file functions, so Gutenberg, ACF, Meta Box, WooCommerce and imports follow without any adaptation.
The storage is the package pollora/media, which the framework requires. The framework keeps the Laravel side: the connection to your disks, config/media.php, the Artisan commands and the pollora:doctor check.
Principles
Section titled “Principles”- Two settings, no more: the disk (
media.disk) and the public URL (media.url). The rest is derived. - A media URL is built, never asked: the public URL and the relative path of the file, such as
https://media.client.fr/2026/10/photo.jpg. No call to the disk happens while a public page renders. - The core is the integration point: the
upload_dirfilter and apollora://stream wrapper. Nothing is specific to a plugin. - Off by default: without
media.disk, WordPress keeps its own folder,public/content/uploads, and nothing changes.
Declare the disk like any Laravel disk, in config/filesystems.php:
'disks' => [ 'media' => [ 'driver' => 's3', 'key' => env('MEDIA_KEY'), 'secret' => env('MEDIA_SECRET'), 'region' => env('MEDIA_REGION', 'eu-west-3'), 'bucket' => env('MEDIA_BUCKET'), 'endpoint' => env('MEDIA_ENDPOINT'), // R2, Scaleway, MinIO… 'use_path_style_endpoint' => env('MEDIA_PATH_STYLE', false), 'url' => env('MEDIA_URL'), ],],Install the S3 driver of Flysystem when you use S3 or a compatible service (a local disk needs nothing):
composer require league/flysystem-aws-s3-v3Then point the module at it, in .env:
MEDIA_DISK=mediaMEDIA_URL=https://media.client.frMEDIA_URL is the address the library is served from, a domain or a CDN in front of the bucket. Left empty, it is the url of the disk. Publish config/media.php to change the other options:
php artisan vendor:publish --tag=media-config| Key | Default | Meaning |
|---|---|---|
disk | null | The disk of config/filesystems.php that holds the library. null: the module is off |
url | null | Public URL of the library. null: the url of the disk |
legacy_urls | [] | Old base URLs of the library, replaced by url in the content and in rendered blocks during a transition |
cache_control | public, max-age=31536000, immutable | Cache-Control set when a file is written |
private.expiry | +6 hours | Life of a signed URL |
async_thumbnails | false | Generate the image sizes after the upload |
What follows without any code
Section titled “What follows without any code”WordPress reads the upload directory through wp_upload_dir(), which the module points at the wrapper and the public URL. Everything built on it follows: the media library, wp_get_attachment_url(), image sizes and srcset, Pollora\Models\Attachment, and the fields of ACF and Meta Box, which store an attachment ID. The date folders (2026/10) and, in a network, the folder of each site are kept.
A plugin that writes under WP_CONTENT_DIR . '/uploads' by hand ignores upload_dir, and its files stay local. A plugin that uses ZipArchive or a C library on a path fails on a stream wrapper. WooCommerce writes its logs under uploads/wc-logs: keep them on a local path.
Changing the storage
Section titled “Changing the storage”The URL of a media stays valid when you change the disk, as long as media.url does not move: point a domain at the new storage and nothing in the content changes. Otherwise, WordPress stores relative paths in the database, so only the absolute URLs written in the content break.
To keep old URLs working during a transition, list them in legacy_urls; they are replaced by media.url with a plain string replacement, which is undone by removing the setting.
To move the files, copy them first:
php artisan pollora:media:migrate --from=uploads --to=media # dry run: what would be copiedphp artisan pollora:media:migrate --from=uploads --to=media --execute # copy the filesWithout --from, the source is the folder WordPress uses without the module. A file already on the target with the same size is skipped, so the command can run again after an interruption, and the source is never changed. Add --replace-urls (and --old-url= when it is not APP_URL/content/uploads) to replace the old base URL in the database: post content and excerpts, post, term and user meta, and options, serialized values and the JSON of blocks included. The guid is left alone. It only writes with --execute.
Each file keeps the visibility it has on the source and is written with the cache_control of the configuration.
Some folders of the uploads directory hold files that must not be public: protected WooCommerce downloads, logs, form attachments, backups. The web server protects them where they are; a public disk would serve them to anyone who knows a URL. Leave them out with --exclude, once per pattern (* matches across folders, so woocommerce_uploads/* leaves out the whole folder):
php artisan pollora:media:migrate --from=uploads --to=media --exclude='woocommerce_uploads/*' --exclude='wc-logs/*' --executeThe report counts the files left out.
Images
Section titled “Images”Both WordPress image editors, GD and Imagick, work on the library as they are. During a request, a file that was written or read (up to 16 MiB) is kept in a local copy, so the original of an uploaded image is not read back from the disk for its size, its EXIF data and each of its sizes: an upload of an image with nine sizes makes ten writes and a handful of existence checks.
Set async_thumbnails to true to make only thumbnail and medium during the upload and generate the other sizes afterwards, in an asynchronous action that runs on the queue, Action Scheduler or WP-Cron (see asynchronous actions). It is off by default, because the block editor sometimes waits for a size right after the upload; until the job ran, WordPress serves the nearest size it has.
The personal data export keeps working on any disk: WordPress builds its archive with ZipArchive, which cannot write to a stream wrapper, so the archive is built locally, copied to the disk, and removed from it when it expires.
Image sizes
Section titled “Image sizes”Declare the sizes your design uses with the ImageSize facade, instead of leaving WordPress to make every registered size for every image:
use Pollora\Media\Domain\Enums\Anchor;use Pollora\Support\Facades\ImageSize;
ImageSize::add('card')->cover(600, 400)->webp(75);ImageSize::add('hero')->scale(width: 1920)->webp(70)->widths([640, 1024, 1440]);ImageSize::add('thumb')->cover(150, 150)->eager();ImageSize::add('portrait')->cover(400, 500, Anchor::Top);Declare them where the application boots, for instance in a service provider. A size is then used by its name, like any WordPress size: wp_get_attachment_image($id, 'card'), the_post_thumbnail('hero'), or wp_get_attachment_image_src().
| Option | Role | Default |
|---|---|---|
cover(w, h, anchor), scale(width:, height:), contain(w, h) | How the image fits: crop to the box, resize keeping the ratio, fit inside the box | none: one is required |
webp(q), avif(q), original() | The format of the file and its quality | WebP, quality 80 |
widths([...]) | Extra widths for a srcset, each a bounded size of its own | none |
eager() | Made when the image is uploaded | no: made when first asked for |
upscale() | Allow enlarging an original smaller than the size | off |
Where a cover crops. A cover() keeps the center of the image. Give it an Anchor to keep another part, for the top of a portrait or the left of a banner: Center (the default), Top, Bottom, Left, Right, TopLeft, TopRight, BottomLeft or BottomRight, the nine positions of add_image_size(). An anchor only means something for a cover (another fit refuses it) and never changes the dimensions of the size. It is part of the signature, so changing it gives the files a new name and a service URL a new v: no cache keeps the old crop. imgproxy follows it with a gravity; Bunny does not (see below).
scale() and contain() never enlarge an original that is smaller than the size, and cover() then keeps the ratio of the box at the size the original allows. A GIF is never resized, which would stop its animation: its sizes are the original.
The URL of a size is worked out, never read. It comes from the metadata of the original (its file, width and height), whether the file of the size exists or not: photo-card-600x400-{signature}.webp. The signature is an HMAC of the original and of the parameters of the size, made with the application key and cut to 12 characters: changing a size changes its name, so no cache ever holds a stale file. The width, height and srcset WordPress writes come from the same computation, with no call to the disk.
The sizes WordPress registers itself (add_image_size(), a theme’s config/images.php, WooCommerce, the core sizes) are left as they are, with the names WordPress gives them: a theme keeps its output until it declares sizes of its own. A declared size of the same name replaces the WordPress one.
An image cropped, rotated or flipped in the media library gets the eager() sizes of its new original, as at upload; the files of the previous original stay for Restore image. The Apply changes to: thumbnail only option of the editor does not reach a declared size, which is always made from the original.
Sizes made on demand
Section titled “Sizes made on demand”A size is made at upload only if it is declared eager(). The others are made the first time their URL is asked for: the web server finds no file, sends the request to PHP, and the module makes the size at the exact path of its URL and answers with it (200, Cache-Control: public, max-age=31536000, immutable, never a redirect). The next request finds the file, and the web server or the CDN serves it without PHP. The URL never changes, so nothing has to be regenerated when a size is added or changed.
- Local disk. Nginx (
try_files $uri /index.php) and Apache (the!-frule toindex.php) already send a missing file to PHP: nothing to set up. - S3 or compatible service behind a CDN. The origin of the CDN for the library is the site: one rule at the CDN, and PHP sees each file only the first time.
- S3 without a CDN. There is no fallback: declare the sizes you use
eager()and runpollora:media:thumbs:warm. The URL stays the same.
What the URL says is not trusted. Before anything is made, without asking the disk: the name is one of a declared size, the signature is the one the site would have made, the extension is an image’s. Then, against the original: its dimensions are those the name gives (nobody can name a size of their own), it is not private, it is not a GIF. A request that fails a check gets a 404 and costs nothing.
Making a size costs CPU, so it is limited per server in config/media.php:
'thumbnails' => [ 'max_pixels' => 50_000_000, // an original above it gets no size, and stays downloadable 'max_bytes' => 30 * 1024 * 1024, 'concurrency' => 4, // sizes made at the same time on a server 'wait' => 10, // seconds a request waits for a place],Ten simultaneous requests for the same missing size make one. A request that cannot get a place within wait seconds is handed a size that already exists (the smallest that is at least as wide, the original at worst) with Cache-Control: no-store: the visitor sees an image, the CDN does not keep the stand-in, and the real size is made on a later request. pollora:doctor reports a .htaccess in the uploads folder that contains RewriteEngine On, which makes Apache ignore the rules above it and cuts the fallback; a security plugin usually adds it.
An image uploaded before a size was declared has no file for it yet. Make them before a heavy page goes live, and remove the files of a size you changed or dropped:
php artisan pollora:media:thumbs:warm --size=card --since=2026-01-01php artisan pollora:media:thumbs:prune # a dry runphp artisan pollora:media:thumbs:prune --executeprune only considers files named the way the module names its sizes, next to an original that is still there: what WordPress or a plugin made is left alone. pollora:doctor reports a declaration that cannot make a size, and a format the image editor of the server cannot write (GD and Imagick write WebP; AVIF depends on the server).
Sizes made by a service
Section titled “Sizes made by a service”For a very large library, an image service can make the sizes instead of the site. The declaration of the sizes does not change; only the shape of the URL does. Choose the driver in config/media.php:
'thumbnails' => [ 'driver' => env('MEDIA_THUMBNAILS_DRIVER', 'pollora'), // pollora | bunny | imgproxy 'bunny' => ['pull_zone' => env('BUNNY_PULL_ZONE'), 'api_key' => env('BUNNY_API_KEY')], 'imgproxy' => [ 'url' => env('IMGPROXY_URL'), 'key' => env('IMGPROXY_KEY'), // hexadecimal, as on the server 'salt' => env('IMGPROXY_SALT'), 'signature_size' => (int) env('IMGPROXY_SIGNATURE_SIZE', 32), 'source' => env('IMGPROXY_SOURCE', 'url'), // url | s3 ],],| Driver | URL of a size | Defined at the service by |
|---|---|---|
pollora (default) | photo-card-600x400-{signature}.webp, a file of the library | the module |
bunny | photo.jpg?class=card&v=3f9a1c2b, an image class of Bunny Optimizer | pollora:media:thumbs:export --driver=bunny |
imgproxy | {imgproxy}/{signature}/card/plain/{source}@webp?v=3f9a1c2b, signed, presets-only | pollora:media:thumbs:export --driver=imgproxy |
v is a short tag that changes when the size does. The service ignores it, but a CDN keys its cache on it: without it, a class or a preset you changed would keep being served as it was under the same URL (checked on a real Bunny pull zone). With a service, no file is made (neither at upload nor on demand), the fallback route is not registered and pollora:media:thumbs:warm has nothing to do. A size keeps a single URL, built from the metadata of the original, and each width of widths() becomes a class or a preset of its own (hero-640w). A private media never goes to the service: its sizes are not asked of it, WordPress serves the original, signed, as for any private media.
Bunny. media.url must be the pull zone that serves the library, with the Dynamic Image API enabled in Optimizer → Settings. Bunny accepts only letters and digits in the name of a class, so the class of hero-640w is hero640w and that of my_size is mysize; two sizes whose classes would be the same cannot both be served, and the second is refused (the export and pollora:doctor say so). Forcing classes (“Force Transformation Classes”) is up to you: when it is on, a valid class is served, an unknown class or a raw transformation gets a 403, and the original, asked with no parameter, is still served. A cover anchored away from the center is refused for Bunny, which crops around the center only: the export and pollora:doctor report it. Bunny has no cover mode, so a cover() size is made with aspect_ratio and a width; the dimensions served are the ones the module advertises, for an original that is big enough as for one that is too small, give or take one pixel: when the ratio of the original does not divide exactly, Bunny rounds the height and then takes the width back to the integer below (1199 pixels for a size of 1200). imgproxy serves exactly the advertised dimensions. Bunny encodes AVIF up to 4 megapixels and falls back to another format above that.
imgproxy (4.0.x). The URL is signed with HMAC-SHA256 and names a preset, so a visitor cannot ask for any other transformation. Set IMGPROXY_KEY, IMGPROXY_SALT and IMGPROXY_SIGNATURE_SIZE to the same values on the server. With source set to url, imgproxy fetches the original at media.url; with s3, it reads the object of the bucket of the media disk (IMGPROXY_USE_S3 on the server). imgproxy refuses a source on the loopback address by default (a media.url of localhost): for local development only, start it with IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES=true; the export warns about it. A size cannot be named default, which imgproxy applies to every image.
Declare the classes or presets from your sizes, and run it again when a size changes:
php artisan pollora:media:thumbs:export --driver=bunny # a dry run: what would be created or updatedphp artisan pollora:media:thumbs:export --driver=bunny --execute # write the classes to the pull zonephp artisan pollora:media:thumbs:export --driver=imgproxy # the configuration to give imgproxyFor Bunny, the classes are read, merged by name and written back: the ones you made are kept, nothing is deleted, and the setting that forces classes is never changed. The merge matters: Bunny replaces the whole list of classes with the one it is sent. The access key is sent in the AccessKey header and nowhere else. imgproxy has no API for presets: the command prints IMGPROXY_PRESETS, IMGPROXY_ONLY_PRESETS=true, IMGPROXY_MAX_SRC_RESOLUTION (in megapixels) and IMGPROXY_ALLOWED_SOURCES, never the key or the salt. A driver that is badly set up does not take the site down: the default driver is kept, and pollora:doctor says why.
Private media
Section titled “Private media”A media is private when its post meta _pollora_media_private is 1, or when the pollora/media/is_private filter says so. A private media is served through a signed URL that expires after media.private.expiry (+6 hours by default): its URL, the URL of each size and every source of its srcset. A public media keeps its plain URL, and the disk is never asked to tell the two apart: the flag is read with the attachment.
Nothing in the admin marks a media private. Do it from the command line, from code, or by rule:
php artisan pollora:media:visibility 42 --private # every file of the media changes on the diskphp artisan pollora:media:visibility 42 --publicphp artisan pollora:media:files 42 # the original, the sizes and the declared files// By rule: a media created private is private on the disk from the startadd_filter('pollora/media/is_private', fn (bool $private, int $id) => get_post_meta($id, 'invoice', true) !== '', 10, 2);The signature is made by the Laravel disk (temporaryUrl()): an S3 pre-signed URL for S3 and compatible services. A disk that cannot sign, such as a plain local disk, serves the plain URL of a private media instead of a broken one, and pollora:doctor says so: a private media needs an object store, or a signature of your own through the pollora/media/signed_url filter.
The module’s actions reach Laravel as events, for an audit or to purge a CDN: Pollora\Media\Domain\Events\MediaStored, MediaDeleted and MediaVisibilityChanged.
Commands
Section titled “Commands”| Command | Role |
|---|---|
pollora:media:status | The disk, the public URL, and a test write |
pollora:media:migrate | Copy the library from a disk to another; dry run unless --execute (--exclude= leaves out private folders) |
pollora:media:thumbs:warm | Make the missing files of the declared sizes for the images already uploaded (--size=, --since=) |
pollora:media:thumbs:prune | Delete the files of a size that was changed or removed; dry run unless --execute |
pollora:media:thumbs:export | Export the declared sizes as Bunny image classes or imgproxy presets (--driver=, --execute for Bunny) |
pollora:media:files {id} | The files of a media: original, sizes, declared files |
pollora:media:visibility {id} | Make a media private (--private) or public (--public) |
pollora:doctor also checks that the disk takes a file, warns of files that are still only in the local uploads folder, and warns when a folder of private files (woocommerce_uploads, wc-logs, gravity_forms, wpforms, updraft, ai1wm-backups) is on the media disk, where it would be served publicly: delete it from the disk and migrate again with --exclude.
Extending the module goes through WordPress hooks, which work without Laravel too.
| Hook | Role |
|---|---|
pollora/media/attachment_files (filter) | Files a plugin keeps next to a media (a WebP twin, a preview): a list of paths, deleted with the media |
pollora/media/is_private (filter) | (bool $private, int $attachmentId): the visibility of a media |
pollora/media/signed_url (filter) | (string $url, int $attachmentId): the final signed URL, for a CDN with its own signature |
pollora/media/stored, pollora/media/deleted, pollora/media/visibility_changed (actions) | A file is on the disk; a media and its files were deleted; a media changed visibility |
pollora/media/filesystem (filter) | Without Laravel: provides a League\Flysystem\FilesystemOperator for a service the constants cannot describe |
Without Laravel: a classic theme, a plugin or Bedrock
Section titled “Without Laravel: a classic theme, a plugin or Bedrock”pollora/media also runs in a WordPress project that is not a Pollora application. Set the library with constants in wp-config.php, then register it from a must-use plugin:
define('POLLORA_MEDIA_DRIVER', 's3');define('POLLORA_MEDIA_BUCKET', 'client-media');define('POLLORA_MEDIA_REGION', 'eu-west-3');define('POLLORA_MEDIA_URL', 'https://media.client.fr');\Pollora\Media\Media::register();The constants are POLLORA_MEDIA_URL, _LEGACY_URLS, _CACHE_CONTROL, _PRIVATE_EXPIRY, _ASYNC_THUMBNAILS, _THUMBNAILS_DRIVER (pollora, bunny or imgproxy), _BUNNY_PULL_ZONE, _BUNNY_API_KEY, _IMGPROXY_URL, _IMGPROXY_KEY, _IMGPROXY_SALT, _IMGPROXY_SIGNATURE_SIZE, _IMGPROXY_SOURCE, _DRIVER (local or s3), _ROOT (the local folder) and, for S3 and compatible services, _BUCKET, _REGION, _KEY, _SECRET, _ENDPOINT and _PATH_STYLE. The package’s README lists them all, with wp pollora media status and wp pollora media migrate, the WP-CLI twins of the Artisan commands.