lisowiecw / filament-media-library
A reusable media library and configurable file picker for Filament.
Package info
github.com/lisowiecw/filament-media-library
pkg:composer/lisowiecw/filament-media-library
Fund package maintenance!
Requires
- php: ^8.3
- ext-fileinfo: *
- ext-gd: *
- ext-intl: *
- ext-mbstring: *
- enshrined/svg-sanitize: ^0.22
- filament/filament: ^4.0||^5.0
- laravel/framework: ^13.0
- spatie/laravel-package-tools: ^1.93
- symfony/mime: ^7.0||^8.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-browser: ^4.0||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-livewire: ^4.0||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
- symfony/yaml: ^7.0||^8.0
README
Filament Media Library
A reusable media library and configurable file picker for Filament.
Compatibility
| Package | PHP | Laravel | Filament |
|---|---|---|---|
| 0.x | 8.3, 8.4, 8.5 | 13.x | 5.x (guaranteed), 4.x (best effort) |
This table is generated from the CI matrix in .github/workflows/tests.yml by composer compat:sync, and a test fails when the two drift apart.
PHP extensions
fileinfo, intl, mbstring and gd. GD is what generates thumbnails: without it the queued derivative job fails and every card falls back to a glyph tile. No optional binary is required anywhere.
Filament 4 support is best effort, limited to the plugin and field APIs both majors share. It rides the same Composer line as Filament 5 and is guarded by a CI job on every push, gathered behind a single required matrix check, so a red Filament 4 job blocks a release; tagging is refused outright when that check did not pass on the commit being released. A test reads every Filament symbol the source imports and asserts the installed major declares it, so the shared-API limit fails on the Filament 4 leg rather than in an application. See ADR 0008.
Installation
You can install the package via Composer:
composer require lisowiecw/filament-media-library
Then register the plugin on any Filament panel:
use Lisowiecw\MediaLibrary\MediaLibraryPlugin; public function panel(Panel $panel): Panel { return $panel ->plugin(MediaLibraryPlugin::make()); }
Publishing the Configuration File
php artisan vendor:publish --tag="media-library-config"
Publishing and Running the Migrations
The package's migrations run automatically. Publish them only if you want to edit them:
php artisan vendor:publish --tag="media-library-migrations"
php artisan migrate
Publishing the Views
php artisan vendor:publish --tag="media-library-views"
Publishing the Translations
php artisan vendor:publish --tag="media-library-translations"
Usage
A media field is a MediaPicker on a Filament form, and a host model reads its
own attachments back through the HasMedia trait. Nothing else is wired:
use Lisowiecw\MediaLibrary\Concerns\HasMedia; class Article extends Model { use HasMedia; }
use Lisowiecw\MediaLibrary\Forms\Components\MediaPicker; MediaPicker::make('cover_image') ->visibility('public') ->acceptedFileTypes(['image/*']); MediaPicker::make('gallery') ->visibility('public') ->multiple() ->reorderable() ->droppable();
The host table carries no media column: cover_image is a virtual field whose
state is an ordered list of asset identifiers, reconciled against the attachment
rows on save. See ADR 0010.
Reading it back:
$article->firstMedia('cover_image')?->url(); foreach ($article->media('gallery') as $asset) { echo $asset->url(); }
$asset->url() is the supported way to get a URL for an asset. It resolves a
public asset to its disk's own URL and a private one to the Delivery route,
which is why nothing in your templates should ever build that route by hand.
Authorization
The package registers a MediaAssetPolicy that denies everything, and two gates,
uploadMedia and attachMedia, that deny as well. Forgetting to write a policy
therefore denies rather than allows. Replace them from your own application:
use Illuminate\Support\Facades\Gate; use Lisowiecw\MediaLibrary\Models\MediaAsset; Gate::policy(MediaAsset::class, App\Policies\MediaAssetPolicy::class); Gate::define('uploadMedia', fn (User $user, Model|string|null $host, ?string $field) => $user->isEditor()); Gate::define('attachMedia', fn (User $user, Model|string|null $host, ?string $field) => $user->isEditor());
The policy abilities are viewAny, view, update, delete, forceDelete,
restore, detach, and, for the management page's bulk buttons alone,
deleteAny and restoreAny. Renaming an asset asks update and downloading
one asks view; neither has an ability of its own. view governs an asset's actual content rather than its
listing, so it is checked where bytes are delivered and never per row in a grid.
Reading a public asset asks nothing, since its content is already publicly
addressable. That exception is the plugin's rather than the policy's, because
the policy is the piece you replace: ask
Lisowiecw\MediaLibrary\Authorization\MediaAuthorization rather than the Gate
facade, and a public asset answers true without a policy ever being consulted.
Storage placement
A field's placement is the disk, directory and visibility its uploads land with.
An application that keeps public and private media in two buckets names both
disks once, in media-library.public_disk and media-library.private_disk
(MEDIA_LIBRARY_PUBLIC_DISK, MEDIA_LIBRARY_PRIVATE_DISK), and a field that
declares only its visibility lands in the matching one. A field that names a disk
of its own still wins.
Because a bucket's access is a property of the bucket rather than of the object,
a disk that cannot deliver the visibility declared on it is a configuration
error. A public placement on a disk configured with no url, or a private
placement on a disk you have declared public (as public_disk, or with
'visibility' => 'public' on the disk itself), throws PlacementMisconfigured when
the placement resolves, so the field fails on the first render rather than on the
first upload. Nothing asks the storage provider: the check reads your
configuration only.
If you deliberately serve a public disk through your own origin, set
media-library.enforce_disk_visibility (MEDIA_LIBRARY_ENFORCE_DISK_VISIBILITY)
to false, which stands both rules down.
Two buckets, one library
A Cloudflare R2 deployment that keeps public and private media apart runs two buckets, and a bucket is a Laravel disk, so the whole arrangement is two disks named once:
// config/filesystems.php 'r2-public' => [ 'driver' => 's3', // ... key, secret, region, bucket, endpoint 'url' => env('R2_PUBLIC_URL'), // the public hostname bound to this bucket ], 'r2-private' => [ 'driver' => 's3', // ... key, secret, region, bucket, endpoint // no `url`: nothing about this bucket is publicly addressable ],
MEDIA_LIBRARY_PUBLIC_DISK=r2-public MEDIA_LIBRARY_PRIVATE_DISK=r2-private
With the pair set, a field states its visibility and nothing else, and its uploads land in the matching bucket:
MediaPicker::make('gallery')->visibility('public'); // r2-public MediaPicker::make('contracts')->visibility('private'); // r2-private
No field needs a disk of its own once the pair is set. media-library.disk
narrows to the fallback for a visibility whose half of the pair is unset, which
is what a half-migrated deployment gets. See
ADR 0012.
The bucket is the enforcement
On R2 the asset's visibility column is delivery intent: it decides how the
package addresses the bytes, not who can read them. Access is a property of the
bucket. A private asset sitting in a public bucket is not private, however the
column reads: the package will route it through the Delivery route and check
view on it, while anyone who guesses the object key fetches it straight from
the bucket.
That is why the pairing is a guard rather than a convention, and why the guard above refuses those two pairings when the placement resolves. It reads your configuration only, so a bucket left public by mistake at the provider is still something only you can see. See ADR 0013.
ACLs, and why the package makes none
Laravel's S3 adapter sends an ACL parameter on every PutObject, derived from
the visibility it was handed and, where the disk names no visibility of its
own, from Laravel's default for the S3 driver, which is public. R2 implements
no ACL headers at all (x-amz-acl and x-amz-grant-* are unimplemented) and no
ACL operations (GetObjectAcl, PutObjectAcl), so that parameter is accepted
and ignored. On R2 it is neither the reason a public object is readable nor a
leak on a private one: the bucket is.
The package makes no ACL call of its own, and never reads visibility back from
the provider. Storage::getVisibility() is a GetObjectAcl behind the scenes,
which R2 does not implement, so on an R2 disk it fails rather than answering.
The stored column and your two buckets are the whole picture.
Delivery
A private asset's content reaches a browser through one signed route the plugin
registers per panel, inside that panel's middleware. Every request to it re-checks
view, so a leaked URL stops working the moment the policy says so, and no raw
presigned URL is ever handed to a browser. media-library.signed_url_ttl
(MEDIA_LIBRARY_SIGNED_URL_TTL) sets how long a signature lasts, five minutes by
default.
The route serves an asset for rendering in place only when it is not active content
and its mime type came from a stored header or a content sniff; everything else is
served for saving, and ?download=1 forces that anyway. Every response carries
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox,
and an asset that renders in place is streamed rather than redirected so the header
survives.
A redirect asks the disk to honour the S3 response-content-type and
response-content-disposition query overrides, so the earned disposition
survives the hop. Those overrides are standard on AWS S3. On R2 they are
observed to work rather than documented: Cloudflare's S3 compatibility page
says nothing about them either way, and they were observed working on 2026-08-27
against a live R2 bucket, over both an r2.dev development URL and a custom
domain. That observation is a dated manual check against a live bucket, and CI
does not re-run it: no test in this repository talks to R2, so the note ages
rather than being re-proven on every push. A disk that ignores them serves the
object's stored headers instead, which is why they are written to say the same
thing at upload (see below).
The route's URL, name and parameters are internal. They may change in any release. Do not build them by hand or hardcode them in a template.
The saved filename
A file saved to a viewer's disk is called whatever the uploader's own file was called, and the asset's Display name only where there is no original filename. One resolver overrides that for the whole application:
use Lisowiecw\MediaLibrary\Models\MediaAsset; MediaLibraryPlugin::make() ->downloadFilenameUsing(fn (MediaAsset $asset): string => $asset->display_name);
The resolver is handed the asset and nothing else, so it cannot vary the name by host model: an asset can be attached in many places, and the header baked onto the stored object is written at upload, before any attachment exists. The editable Display name is the per-asset lever to reach for instead.
Its answer is scrubbed by the same rules as an uploaded filename, so a resolver
cannot break or inject a header, and the asset's own extension is appended where
the answer is a stem without one. The same resolver names both the route's
Content-Disposition and the one written onto the object at upload, so a disk
that ignores response overrides still serves the same name.
A delivery gate of your own
An application whose downloads carry rules the plugin knows nothing about, an
order token, an expiry, a remaining-downloads count, keeps its own route and
controller and reads the storage location off the MediaAsset:
Route::get('/orders/{order}/download/{token}', function (Order $order, string $token) { abort_unless($order->downloadTokenIsValid($token), 403); abort_if($order->downloads_remaining < 1, 410); $asset = $order->product->pdf; // a MediaAsset, however your app reaches it $order->decrement('downloads_remaining'); return Storage::disk($asset->disk)->download( $asset->object_key, DownloadFilename::for($asset), ); })->name('orders.download');
Lisowiecw\MediaLibrary\Delivery\DownloadFilename is what the plugin's own
route asks, so your gate saves a file under the same name rather than deriving a
second one. download() streams the bytes through your application. To hand the transfer to
the bucket instead, sign your own URL from the same two columns:
return redirect()->away(Storage::disk($asset->disk)->temporaryUrl( $asset->object_key, now()->addMinutes(5), ));
Signing means the count is spent when the link is issued rather than when the bytes are fetched, and the signature outlives a rule you change in the next minute. Stream where the rules are strict, sign where the files are large.
What this recipe leans on is promised surface: the MediaAsset model, its
disk and object_key columns, DownloadFilename, and the policy abilities. The Delivery route's
URL, name and parameters are not, so build your own route rather than signing
or wrapping the plugin's.
The alternative is to fold the rule into the view ability instead, and let the
plugin's own route enforce it:
public function view(?User $user, MediaAsset $asset): bool { return $user !== null && Order::forUser($user)->hasUnspentDownloadOf($asset); }
Then $asset->downloadUrl() is the whole integration, and the rule is re-checked
on every hit of the route rather than once at issue. Reach for it when the rule
is a property of the viewer and the asset, since that is the question view asks.
Keep your own route when the rule belongs to something else entirely, an order,
a token, a counter to decrement, because a policy is asked whether access is
allowed, not told to spend something.
Card placeholders
A grid card whose thumbnail is not generated yet paints the asset's own BlurHash
rather than a flat tile. The package ships no JavaScript, so the
hash is decoded in PHP and painted as a handful of CSS gradients in a style
attribute. Nothing to build, and no decoder to download before a card can paint.
That painting is coarse on purpose. The card also carries the hash verbatim as
data-blurhash on the same element, so an application that already has a decoder
can render a proper one over the top:
document.querySelectorAll('[data-blurhash]').forEach((tile) => { // decode tile.dataset.blurhash however your app already does })
data-blurhash is present only where the asset has a hash and the tile is
standing in for a thumbnail; a card showing a real thumbnail never carries one.
It is emitted even where the package declined to paint from it, so a decoder of
your own still gets the value.
The package's own painting lives entirely in that element's style attribute,
so a decoder that renders over the top either paints above it or clears it with
tile.style.background = ''.
Lifecycle and cleanup
Removing a picture from a record detaches it, which touches the attachment row and nothing else. The asset, its object and its renderings stay, and so does every other place it is attached:
$article->detachMedia('cover_image', $asset);
Deleting is the separate, explicit act. It soft-deletes the record and queues
removal of the backing object and every derivative made from it, so a mistake is
recoverable for as long as the queue takes and the bucket is still cleaned
afterwards. The removal job uses the queue's own retries and lands in
failed_jobs when they are exhausted, so a bucket outage is retried with
queue:retry like anything else.
A delete is blocked while anything still references the asset, including an external reference. The refusal carries the usage list, so the caller can show it and ask again:
use Lisowiecw\MediaLibrary\Exceptions\DeleteBlocked; use Lisowiecw\MediaLibrary\Lifecycle\AssetLifecycle; try { app(AssetLifecycle::class)->delete($asset); } catch (DeleteBlocked $blocked) { // $blocked->usage is the list to review, then: app(AssetLifecycle::class)->delete($asset, force: true); }
A host model may say how it reads in that list by defining mediaUsageLabel();
without one, the list names the model and its key. Restoring a soft-deleted asset
brings back the record alone: its renderings are regenerated lazily on the next
render rather than resurrected.
These rules are package-global. A field cannot switch them off, because the asset one field deletes is the asset every other field shares.
External references
Something outside any host model can record that it uses an asset: a newsletter, an export, a scheduled campaign. It is written as an attachment with no host, so it appears in the usage list and blocks a delete exactly like a host row does, with no second mechanism behind it:
$asset->attachments()->createExternal('newsletter-2026-08', 'Campaign #412');
The identifier is your own handle on the thing making the reference and the label is what a person reviewing a delete reads. Registering the same identifier twice is the same reference stated again, so it lands on one row, enforced by a unique index rather than by the order two runs happen to land in. A label is written only when one is given, so a rerun that names the identifier alone leaves the existing wording alone. Withdraw it when the thing that made it is gone:
$asset->attachments()->revokeExternal('newsletter-2026-08');
An external reference belongs to no field context, so media() and firstMedia()
never return one and a picker never sees it. On the management page it can also be
revoked per row from the usage panel, behind the detach ability. Host rows are
listed there but not removable, and neither revoke will touch one whatever it is
handed, since detaching belongs on the host record.
Finding unused files is a report you ask for, never something that happens to you:
php artisan media:unattached-assets php artisan media:unattached-assets --days=90
It lists assets nothing has referenced for longer than the grace period
(media-library.unattached_grace_days, 30 days by default), deletes nothing, and
is not scheduled by the package. The period counts from when an asset last
stopped being referenced, recorded on media_assets.unattached_since, so an
asset detached yesterday keeps its full grace period however old it is; an asset
nothing ever referenced counts from its upload instead. Being unattached is evidence rather than proof:
a URL can live in a sent email or an export the plugin cannot see.
Rich text attachments
Filament's RichEditor uploads its own inline files straight to a disk, which
leaves the library knowing about every picked image and nothing about anything
an author dragged into a post body. Point it at the ingest seam instead and an
inline image becomes an ordinary Media Asset:
use Filament\Forms\Components\RichEditor; use Illuminate\Http\UploadedFile; use Lisowiecw\MediaLibrary\Enums\Visibility; use Lisowiecw\MediaLibrary\Ingest\IngestService; use Lisowiecw\MediaLibrary\Ingest\Placement; use Lisowiecw\MediaLibrary\Models\MediaAsset; function editorReference(MediaAsset $asset): string { return 'editor:'.$asset->ulid; } RichEditor::make('body') ->saveUploadedFileAttachmentsUsing(function (UploadedFile $file): string { $asset = app(IngestService::class)->ingest($file, Placement::resolve( directory: 'editor', visibility: Visibility::Public, )); $asset->attachments()->createExternal(editorReference($asset), 'Post body'); return $asset->url(); });
IngestService::ingest() is the promised entry point, and it is the same one
the picker and the management page use, so the whole floor applies here without
being restated: the blocked-type list, the configured accepted types, the family
mismatch refusal, SVG sanitization and its strict pass, the refusal of active
content on public placement, the stored headers, and the tenant and uploader
stamps. A refused file throws IngestRefused, which Filament surfaces to the
author. The Placement argument is optional; leave it off entirely and the
configured disk pair and default visibility apply. Resolve it rather than
constructing one, so the disk comes from your configured pair and the invariant
that a public placement needs a disk with a URL to give is checked here rather
than at the first upload.
The External reference is what makes the asset count as used. Without it nothing
records the image as used at all, so it blocks no delete and the unattached
sweep reports it for review however live the post is. The identifier names the
upload rather than the post, because the callback runs while the file is being
dropped in, which on a create form is before the post has a key; one body holds
many images and each is its own reference. Keep the identifier derivable, as
here from the asset's own ulid, so the revoking half can rebuild it.
Placement must be public here. A private asset resolves to a signed Delivery URL, and that URL goes into the saved HTML, where it rots on its own expiry: the body renders fine for whoever saved it and shows broken images to everyone reading it an hour later. A public asset resolves to the disk's own URL, which has no expiry to outlive. Editor uploads are therefore a public-placement feature, and an author dragging in Active content is refused rather than silently stored privately.
Revoking is yours to trigger. When an image is removed from the body, or the post is deleted, withdraw the reference so the asset can be reviewed and deleted:
$asset->attachments()->revokeExternal(editorReference($asset));
Which assets those are is a question only your application can answer, from the saved HTML or from a table of your own that records each upload against the post it went into. The package does not parse your saved HTML to work out that an image is gone. It never reads the body, so an asset stays referenced until your code says otherwise, which is the safe direction to fail in: a stale reference blocks a delete, while a missed one would sweep an image a live page still points at.
There is no second picker surface and no editor plugin. The seam is the whole integration: ingest the file, record the reference, return the URL.
The management page
The picker is what an editor uses. The library itself is a separate page, off by default and opted into per panel:
->plugin(MediaLibraryPlugin::make()->withLibraryManagement())
Opting in is not opening up: the page is still gated on the viewAny ability,
which the packaged policy refuses until your own policy says otherwise. The bulk
actions ask for deleteAny and restoreAny for the button, then ask about every
row individually before touching it, so a bulk action can only ever do what the
same person could have done a row at a time.
It is a table rather than the picker's grid, it lists everything the picker hides (private assets, blocked types, and the soft-deleted behind the trashed filter), and an object key pasted into the search box finds its asset. The view page shows the disk and object key as copyable fields, where the type came from, where an import came from, and the usage list that a force delete asks you to review.
What it can do is rename (name and alt text), delete, restore, force delete, download and upload. What it deliberately cannot do is replace an asset's bytes in place, change its visibility, or move it between disks or directories: a published URL is a promise, and each of those would change what an existing address serves. Renaming is offered precisely because it touches nothing in storage.
Cleanup has its own filter with a grace-period preset, and a bulk delete restricted to what that preset selects. Eligibility is recomputed at the moment of the delete rather than trusted from the filter the rows were listed under.
A health readout carries the failed, missing and stale derivative counts with a
regenerate action beside them. It queues a bounded batch, since it runs in a
request, and names media:regenerate-derivatives for whatever is left. The
importer stays a command and is never exposed here.
Tenancy
The library knows nothing about tenants until a panel tells it who the current one is:
->plugin(MediaLibraryPlugin::make()->tenantUsing(fn () => Filament::getTenant()))
A panel that already has Filament tenancy gets that resolver by default, so the call above is only needed where the tenant the library sorts by is not the panel's own. Leave the resolver unset and nothing in this section applies: a single-tenant application is untouched, byte for byte.
The tenant is stamped onto media_assets.tenant_id once, at upload, from
whoever was current. It is never reassigned, and an attempt to move an asset
from one tenant to another throws rather than writing.
Scope decides what is offered and the policy decides what is delivered, which
are two different questions. The picker and the management page query within
the current tenant, so nothing outside it is ever shown; separately, view is
refused for an asset outside the current tenant, so a route-model binding or a
guessed Delivery URL cannot sail past a boundary the query merely narrowed. A
cross-tenant Delivery request answers 404 rather than 403, because 403 would
confirm the asset exists.
An asset with no tenant belongs to no one rather than to everyone. No tenant sees it, and no tenant is delivered it. That is what makes upgrading an existing single-tenant library safe: the day a resolver is configured, the whole library goes quiet instead of appearing in every tenant at once.
Claiming is how it comes back, one way and allowed once:
php artisan media:assign-tenant acme php artisan media:assign-tenant acme --asset=01J... --dry-run
The same claim is available as a bulk action on the management page, for a
person the host application has unlocked with the viewAllTenants ability. That
ability is refused by the packaged policy and unlocks an "All tenants" toggle, a
tenant column and a tenant facet on the listing, which is the only place the
library is ever shown unscoped.
An attachment made before tenancy existed is left alone rather than broken: it still counts as usage and still blocks deletion, and its tile degrades to a dimmed glyph, since the viewer may not look at the bytes. Attaching anything new across a tenant boundary is refused.
Imports say who the adopted objects belong to, and none is a valid answer:
php artisan media:import --disk=media --prefix=legacy --source=disk --tenant=none
An import that has already run can be asked what has moved under it since:
php artisan media:import --disk=media --model="App\Models\Post" --column=cover_path --tenant=none --check-drift
That compares every already-present asset to its object and reports what no longer matches, naming the field, the recorded value and the value storage reports. It repairs nothing, and it is a flag rather than a default because it reads every object the run already holds, which is the cost the default re-run exists to avoid.
Jobs and commands are neither scoped nor policy-checked. An operator on the server is not a request inside a panel, and a claim that could only be made from inside the tenant it was claiming for could never be made at all.
Migrating an existing library
Adopting an application's existing uploads is two commands, in this order, and they are deliberately never chained.
php artisan media:import --model="App\Models\Post" --column=cover_path --disk=media --tenant=none
php artisan media:resolve-mimes --from=extension --sniff
media:importadopts the objects in place. It does not read them, so most rows land on theextensionrung of the mime ladder: the type is inferred from the filename and nothing has confirmed it.media:resolve-mimes --from=extension --sniffpays for one full read per object and rewritesmime_typeandmime_sourcetogether. A run only ever raises a row's rung, never lowers it.
media:resolve-mimes takes --from= (the rung to re-resolve: header,
sniffed, extension or unknown, defaulting to extension), --sniff,
which reads the bytes and is never implicit, and --dry-run, which reports what
would be rewritten and writes nothing.
The second step is not optional cleanup. A Disposition is earned (ADR 0004), so an asset whose type came from its filename is always served for saving. A freshly imported library is therefore served for saving until the sniff pass has run.
The two stay separate because media:import --sniff and
media:resolve-mimes --sniff are the same bill presented twice, and each is its
own decision about when to pay it. The window between them is visible as the
mime_source facet on the management page rather than as a banner, so an
operator finds the un-sniffed population by filtering for it.
Operator obligations
Two things this package depends on are properties of your deployment, which it assumes and never verifies.
A public media host must be a foreign origin
Active content is refused on public placement and a public SVG gets the Strict pass, both because a public asset resolves to the disk's own URL and never reaches the Delivery route, where the content policy and the Disposition rule live. That reasoning holds only if the disk's URL is a foreign origin.
Serve public media from a host that is not the panel's origin and shares no
cookie scope with it. A public bucket fronted by a CDN on your own domain, or
on a sibling subdomain under a shared parent cookie domain, is same-origin with
the panel session, which makes public placement more dangerous than private
rather than less. The plugin does not detect this: a reverse proxy, a CNAME and
an APP_URL that need not match the panel's real host all defeat a host
comparison in both directions, so the assumption is stated here rather than
half-enforced in code. See
ADR 0009.
A Sanitized SVG is the only Active content that can reach public placement at
all, so it is the whole exposure. Cover it at the edge as well as at the origin:
on Cloudflare R2, bind the public bucket to a custom domain (an r2.dev
development URL takes no rules and is not a deployment surface) and add a
response header transform on that hostname:
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
X-Content-Type-Options: nosniff
That is the same policy the Delivery route sets on every response, applied where the Delivery route cannot reach. The custom domain is what makes it possible.
The GPL-2.0-or-later dependency
The package hard-requires enshrined/svg-sanitize,
which is GPL-2.0-or-later. It is a Composer runtime dependency rather than
vendored code, so this package stays MIT, but your installation pulls it in and
is GPL-relevant through it. There is no build without the sanitizer: SVG
sanitizing is not optional and not switchable.
The promised surface
This is the part of the package a host application may depend on and expect to survive an upgrade.
- The plugin class
Lisowiecw\MediaLibrary\MediaLibraryPluginand its fluent configuration:make(),withLibraryManagement(),tenantUsing()anddownloadFilenameUsing(). - The field
Lisowiecw\MediaLibrary\Forms\Components\MediaPickerand its configuration:acceptedFileTypes(),disk(),directory(),visibility(),maxSize(),multiple(),reorderable(),droppable(),scopeLibrary(),thumbnailUsing(),modalWidth()anddefaultTab(). - The host trait
Lisowiecw\MediaLibrary\Concerns\HasMedia, withmedia(),firstMedia()anddetachMedia(), and the optionalmediaUsageLabel()a host model may define. - The model
Lisowiecw\MediaLibrary\Models\MediaAssetas something to read and query, withurl(),previewUrl()anddownloadUrl(), and the columns named below. - Its
attachments()relation, which is aLisowiecw\MediaLibrary\Attachments\Attachments, aHasManysubclass carrying three public writes of its own:createExternal(),revokeExternal()andrevokeExternalRow(). - Its
derivatives()relation, as something to read. - The ingest entry point
Lisowiecw\MediaLibrary\Ingest\IngestService::ingest()withLisowiecw\MediaLibrary\Ingest\Placement::resolve(), plusLisowiecw\MediaLibrary\Delivery\DownloadFilenameandLisowiecw\MediaLibrary\Authorization\MediaAuthorization. - The ability and gate names: the policy abilities
viewAny,view,update,delete,forceDelete,restore,detach,deleteAny,restoreAnyandviewAllTenants, and the gatesuploadMediaandattachMedia. - The config keys in
config/media-library.phpand their environment variables:disk,public_disk,private_disk,directory,visibility,enforce_disk_visibility,max_upload_size,blocked_types,signed_url_ttl,derivative_url_bucket,derivatives(itsprefix,quality,variants,small_originalandlazy_dispatch),search_debounce,facet_count_thresholdandunattached_grace_days. The published file is the reference: every key in it is promised. - The command signatures
media:import,media:resolve-mimes,media:regenerate-derivatives,media:assign-tenantandmedia:unattached-assets.
The asset columns you may read: id, ulid, display_name,
original_client_filename, extension, alt, mime_type, mime_source,
size, disk, object_key, visibility, source, import_source,
uploaded_by, tenant_id, blurhash, and the timestamps.
The internal surface
Everything else the package registers may change in any release, however visible it looks. In particular:
- The Delivery route: its URL (
media/{asset}inside the panel), its name (media-library.asset, generated per panel) and its parameters. Do not build it by hand, hardcode it in a template, or sign or wrap it.$asset->url()is the supported way to reach an asset's content; keep your own route where you need rules of your own. - Livewire components and view names, including
media-library::forms.components.media-pickerandmedia-library::forms.components.library-grid. Publishing the views to edit them is supported; depending on their names from your own code is not. - The derivative key layout, currently
{derivatives.prefix}/{asset ulid}/{variant}.webp. Derivative objects are the package's to place and to move. - Jobs and their queue payloads:
GenerateDerivativeandPurgeStoredObjects, their constructor arguments and their serialized shape. Do not dispatch them yourself or persist them across an upgrade. - The schema beyond the columns listed above: the
media_attachmentsandmedia_derivativestables, their columns and their indexes, and any column added tomedia_assetsthat is not in that list. - Every other class in
src/, public methods included. The list above is the promise; "public" in PHP is not.
Running the workbench
The repository ships a small Filament application you can boot, so the surface
described above can be clicked rather than only read about. It lives in
workbench/ and is a development surface: nothing in it is installed into a
host application.
composer install composer build # copies workbench/.env, migrates, seeds, links the public disk composer serve # http://127.0.0.1:8000/admin, then sign in as # test@example.com with the password "password"
composer serve runs composer build itself, so the two lines above are one
command on a first run. Run composer build on its own when you want the seed
back the way it started.
What is in it, and why:
- Articles is a host resource with two
MediaPickerfields.cover_imageis single, public, droppable and limited withacceptedFileTypes().galleryismultiple(), private,reorderable(), has dropping turned off, and narrows the modal withscopeLibrary(). Between them they exercise the config methods this README promises. - Media Library is the management page, enabled by
MediaLibraryPlugin::make()->withLibraryManagement(). - The seed is built entirely through
IngestService, so the rows are the rows an upload through the panel would have written, derivatives and the placeholder painting included. It leaves both visibilities, images and documents, two uploaders, four uploaded spans and both attached and unattached assets, which is what gives the facet sidebar and the usage list something to say. - The disk pair is
publicandlocal, set inworkbench/.env.example. The queue runs synchronously, so derivatives and the placeholder painting appear without a worker running.
Authorization in the workbench
workbench/app/Policies/MediaAssetPolicy.php and the two gates in
WorkbenchServiceProvider are permissive: any signed-in user may do anything.
That file is the piece a host application replaces. The packaged default denies
everything, which is correct for a package and would make the example look
broken, so the workbench writes the authorization a host would write. Do not
copy it into a real application.
Tenancy in the workbench
The workbench panel is deliberately untenanted. Tenancy is a property of the host application's panel rather than of the plugin, and a tenant switcher would add a login flow and a second model to an example whose job is to show the media surface. What tenancy does to the library is covered by the test suite instead, and described under Tenancy above.
Fixtures
The suite reads the workbench, not the other way round. Article lives at
Workbench\App\Models\Article and its table is created by the workbench
migration, which tests/TestCase.php loads. The example and the tests attach
media to one Article, so its schema is stated in one place.
Browser tests
The suite that drives the workbench in a real browser is its own testsuite, so an environment without a browser still has a working build:
composer install
npm install
npx playwright install --with-deps chromium
composer build # the suite opens the workbench panel
vendor/bin/testbench filament:assets
composer test:browser
It covers the parts that only exist in a browser: picking through the modal, dropping onto each drop surface, reordering from the keyboard, what a private delivery and a public disk URL each do to a request, what a card paints before its thumbnail exists, the management page, and what a refusal looks like to the person who caused it.
There are no retries. A browser test that fails intermittently is made deterministic or deleted, which is ADR 16.
One piece of scaffolding is worth knowing about. The in-process server the
runner uses rebuilds each request from its raw body and does not carry
uploaded files across, so tests/Browser/RecoverUploadedFiles.php is a
test-only middleware that puts them back before the application sees the
request. Without it no upload in the suite would arrive.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Thank you for considering contributing to Filament Media Library! Please review our contributing guide to get started.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
Filament Media Library is open-sourced software licensed under the MIT license.
SVG sanitizing is handled by enshrined/svg-sanitize, which is GPL-2.0 licensed. It is a runtime dependency pulled in by Composer, not vendored into this package, so this package stays MIT. Anyone who cannot take a GPL dependency should be aware of it before installing.