Appearance
Storage and the media library
What it does
Every upload goes to the site's own S3 bucket. The media library lets a site browse, reuse and delete what it has uploaded.
How storage works
SiteStorage::diskFor(Site $site)builds an S3 disk namedsite-{id}at runtime, using that site's credentials.settingsFor()is the only place that knows where credentials come from. Today that isconfig('filesystems.site_disks.{slug}'), filled from.env. Later it will fetch from the existing .NET API, and nothing else changes.- If a required setting (
key,secret,region,bucket) is missing, it throws. There is deliberately no fallback to another site's bucket. SiteStorage::currentDisk()uses the current Filament tenant. Use it in forms.MediaServicetakes aSiteexplicitly. Use it in services and anywhere there is no tenant.SiteStorage::summaryFor($site)returns masked settings for the/systemSites screen.
The standard field pattern:
php
FileUpload::make('image')
->image()
->disk(fn () => SiteStorage::currentDisk())
->visibility('public')
->directory(fn ($record) => app(MediaService::class)->packageDirectory(
site: Filament::getTenant(),
packageSlug: $record?->slug,
));Folder layout inside each bucket
Everything for a site sits under cms/{site-slug}/:
| Path | Used for |
|---|---|
cms/{slug}/pages/{page-slug}/{block-type}/ | Page block images |
cms/{slug}/pages/{page-slug}/seo/ | Open Graph images |
cms/{slug}/posts/{post-slug}/ | Post images |
cms/{slug}/packages/{package-slug}/ | Package images |
cms/{slug}/branding/ | Logos and the site avatar |
cms/{slug}/media/ | Files uploaded in the media library |
The directory helpers live in MediaService (pageDirectory, pageBlockDirectory, postDirectory, packageDirectory, brandingDirectory, mediaDirectory).
The media library
- Table:
media(site_id,path,original_name,mime_type,size,width,height,alt_text,uploaded_by).unique(site_id, path). - Model:
Media. Itsdeletedevent removes the file from the site's bucket, for single and bulk deletes. - Upload: the "Upload files" header action on the Media list page.
MediaService::registerUpload()records each file, reading its dimensions. - Import:
php artisan media:import {site?}or the "Import existing files" button registers files already in a bucket undercms/{slug}/. The command is the better choice for large buckets, because it downloads each image to read its size. - In-use check:
MediaUsage::forMedia()looks for the file in page blocks (walking thedataJSON in PHP), page OG images, package images, post covers and site branding. Delete and bulk delete are cancelled if it finds a use. - Picker:
LibraryFileUploadis aFileUploadwith a "Choose from library" link. Roll it out to a block by swappingFileUpload::make(...)forLibraryFileUpload::make(...). Single-image fields only, not->multiple(). It also stops the field deleting the file from the bucket when someone removes it from a form, since the library owns the files. - Permissions:
media.view/create/update/delete. See roles and permissions. - SVG uploads are blocked on purpose. SVGs can contain scripts and would be served straight from the bucket.
Why it's built this way
- One bucket per site keeps client files physically separate, and a leaked credential only exposes one site.
- Credentials behind one method means moving to the .NET API is a one-method change.
- No fallback bucket means a misconfigured site fails loudly. It never writes into another client's storage.
MediaService::url()gives one place that turns a stored path into a public URL.
Notes
Every bucket needs a CORS rule for the CMS origin. Filament's upload preview loads stored files with a browser
fetch. Without CORS the file uploads fine, but the preview spins forever and the console shows a CORS error. Add this to each site bucket (S3 > bucket > Permissions > Cross-origin resource sharing), and add your production CMS origin when you deploy:json[ { "AllowedHeaders": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedOrigins": ["http://localhost:8000"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ]