Skip to content

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 named site-{id} at runtime, using that site's credentials.
  • settingsFor() is the only place that knows where credentials come from. Today that is config('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.
  • MediaService takes a Site explicitly. Use it in services and anywhere there is no tenant.
  • SiteStorage::summaryFor($site) returns masked settings for the /system Sites 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}/:

PathUsed 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. Its deleted event 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 under cms/{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 the data JSON in PHP), page OG images, package images, post covers and site branding. Delete and bulk delete are cancelled if it finds a use.
  • Picker: LibraryFileUpload is a FileUpload with a "Choose from library" link. Roll it out to a block by swapping FileUpload::make(...) for LibraryFileUpload::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
      }
    ]