The MediaLibrary facade
Everything goes through one facade, RoundlyConsulting\MediaLibrary\Facades\MediaLibrary. Flat add* methods start a global add, for($model) scopes adds and reads to one owner, flat verbs act on the media you hand them, variants($media) groups the variant operations, and remote() fetches user-supplied URLs through the guard addFromUrl() uses:
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;
// Owner-scoped: add, read and delete one model's media
MediaLibrary::for($user)->add($request->file('avatar'))->toBucket('avatar');
MediaLibrary::for($user)->first('avatar');
// Global (no owner)
MediaLibrary::add(storage_path('brand/logo.svg'))->toBucket('brand');
// Flat verbs act on the media you hand them
MediaLibrary::move($media, to: $otherUser, bucket: 'gallery');
MediaLibrary::replace($media, $request->file('avatar'));
MediaLibrary::delete($media);
// Variant operations for one media
MediaLibrary::variants($media)->missing();
// Guarded fetches of user-supplied URLs — nothing stored
MediaLibrary::remote()->fetch($url);Flat methods
| Method | Returns | Does |
|---|---|---|
add($file), addFromUrl(), addFromDisk(), addFromString(), addFromBase64(), addFromStream() | PendingFileAdd | Start a global add (no owner). |
draft($file) | PendingFileAdd | Start a global draft add. |
for($model) | ModelMedia | Owner-scoped handle — adds, drafts, attach, reads, clear and delete for one model. |
attach($media, to: ?Model, bucket:) | Media | Attach by reference (zero bytes copied); to: null = a global bucket. |
bindDraft($token, to: $model, bucket:) | Media | Bind a draft to an owner — the bucket’s rules, storage, single-file rule and variants apply; a token binds once. |
move($media, to: ?Model, bucket:, disk:) | Media | Re-home and/or move across disks. |
moveToDisk($media, $disk) / moveVariantsToDisk($media, $disk) | Media | Move the files, or only the variants, to another disk. |
copy($media, to: ?Model, bucket:, disk:) | Media | Duplicate into a new row (fresh uuid). |
replace($media, $file) | Media | New bytes, same id/uuid (and URL, unless the old file is shared or the new file is of another type). |
delete($media) | void | Delete the row and its files (refcount-guarded). |
variants($media) | MediaVariants | all(), generated(), missing(), regenerate(only:, force:). |
remote() | RemoteUrls | Guarded fetches of user-supplied URLs — fetch(), vet(), isPrivateAddress(); nothing is stored. |
regenerate($media, only: [], force: false) | list<string> | Re-render variants; returns the names rendered. |
pruneDrafts() | int | Delete expired, never-bound drafts. |
rulesFor(Model::class, $bucket) | list<string> | Validation rules from a bucket definition. |
bucket($bucket) / find($uuid) / clearBucket($bucket) | Builder / ?Media / int | Global reads and clear. |
The for($model) handle
MediaLibrary::for($model) returns a ModelMedia handle. Its scope is a security boundary: find() returns null and delete() throws MediaDoesNotBelongToModel for media that is global or belongs to another model, so a controller can safely resolve MediaLibrary::for($request->user())->find($uuid).
| Method | Returns | Does |
|---|---|---|
add(), addFromRequest(), addFromUrl(), addFromDisk(), addFromString(), addFromBase64(), addFromStream() | PendingFileAdd | Owner-scoped add; addFromRequest() throws FileDoesNotExist when the key has no upload. |
bindDraft($token, $bucket) | Media | Bind a draft to this model. |
attach($media, $bucket) | Media | Attach existing media by reference. |
get($bucket) / first($bucket) / has($bucket) | Collection / ?Media / bool | Read the bucket, in order. |
find($uuid) | ?Media | Only media this model owns — null otherwise. |
url($bucket, $variant) / temporaryUrl($bucket, $variant, $expiry) | string | First media's URL, else the bucket fallback, else an empty string. |
clear($bucket) | int | Delete every media in the bucket. |
delete($media) | void | Throws MediaDoesNotBelongToModel for global or foreign media. |
Moving, copying and deleting
Media and its variants can move across disks and be re-homed to another model and bucket — including model → global and back. Cross-disk moves stream the bytes to the target disk first — a write the disk refuses throws FileCannotBeWritten before the row or any source file changes — then the database update runs in a transaction, and the source files are removed only once the outermost transaction commits, so rolling back your own DB::transaction() keeps the bytes the restored row points at:
// Move the original (and same-disk variants) to another disk.
MediaLibrary::moveToDisk($media, 'cold');
// Move only the variant files to another disk; the original stays put.
MediaLibrary::moveVariantsToDisk($media, 'hot');
// Re-home to a different model and bucket (deletes the source files).
MediaLibrary::move($media, to: $otherUser, bucket: 'gallery');
// Move a model's media to global storage (no owner).
MediaLibrary::move($media, to: null, bucket: 'brand');
// Copy instead of move: a new row with a fresh UUID, source preserved.
$copy = MediaLibrary::copy($media, to: $otherUser, bucket: 'gallery', disk: 'cold');
// Permanently delete the row and its files (refcount-guarded).
MediaLibrary::delete($media);
// The same verbs on the model:
$media->moveToDisk('cold');
$media->move($otherUser, 'gallery');
$copy = $media->copy($otherUser, 'gallery', 'cold');
$media->deleteWithFiles();Moving or copying into a different owner or bucket applies the target bucket: the owner must be saved (MediaOwnerNotSaved), its acceptance rules are checked first (FileUnacceptableForBucket), its single-file rule is enforced, variants it doesn’t define are dropped and the ones it defines but the media lacks are generated. Variants both buckets define are kept — run MediaLibrary::regenerate($media, force: true) if they differ. A move keeps the media’s disk and visibility unless you pass disk:. Re-homing a draft settles it like a bind — the token stops working and pruneDrafts() leaves it alone. moveToDisk() never re-homes: it keeps the owner, bucket and variants exactly as stored, even when the owner is soft-deleted or gone. On the same disk a copy points at the source’s stored original (zero bytes copied, refcount-guarded); on another disk the original is copied. A same-disk copy is saved under a row lock on the source, so a concurrent delete of the source waits for it; a source already deleted by then throws ModelNotFoundException — no row is created and the variants copied for it are removed.
A move fires MediaHasBeenMoved; a copy fires MediaHasBeenAdded for the new row. A plain Eloquent delete() is a soft delete and keeps the files so a restore stays lossless — only MediaLibrary::delete() (or deleteWithFiles(), or a force delete) removes them and fires MediaHasBeenDeleted. Inside a transaction the files go once the outermost one commits and stay if it rolls back, so a row restored by the rollback still has its original and variants.
Drafts — upload before the model exists
A draft is an ordinary media row with no owner, a generated draft_token and a TTL (media.drafts.ttl, default 24 hours). Dimensions and placeholders are computed on upload. The bucket is only known when you bind, so binding is where it applies: its acceptance rules are checked (FileUnacceptableForBucket leaves the draft unbound), the original moves to the disk and visibility the bucket declares, a singleFile() bucket’s previous media is deleted and the bucket’s variants are generated:
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;
// Upload step — no model yet. Hand the token back to the client.
$draft = MediaLibrary::draft($request->file('avatar'))->toBucket('avatar');
$token = $draft->draft_token;
// When the form is submitted and the model is saved, bind by token:
MediaLibrary::for($user)->bindDraft($token, 'avatar'); // sets the owner, clears the token
// …or the flat verb, or the trait shorthand:
MediaLibrary::bindDraft($token, to: $user, bucket: 'avatar');
$user->attachDraftMedia($token, 'avatar');
// Delete expired, never-bound drafts (or schedule media:prune-drafts):
MediaLibrary::pruneDrafts(); // returns the countBinding throws DraftMediaNotFound (unknown, already-bound or moved token — a token binds once, even under concurrent binds), DraftMediaExpired (past the TTL), MediaOwnerNotSaved (an owner that was never saved) or FileCannotBeWritten (the disk refused the write — the draft stays as it was); a successful bind fires DraftMediaHasBeenBound. Unbound drafts are not global media: MediaLibrary::bucket(), clearBucket() and media:clear "" leave them alone, and the model’s $hidden keeps draft_token out of serialized output — read it as a property.
A draft token stays out of stack traces. It is a #[SensitiveParameter] along the whole bind path — bindDraft() on the facade and the for() handle, attachDraftMedia(), BindDraftMediaAction, the DraftMediaNotFound / DraftMediaExpired factories and the fake — and the facade uses the package toolkit’s RedactsSensitiveArguments, so even the MediaLibrary::bindDraft($token, $model) frame shows a SensitiveParameterValue while every other argument stays visible. That matters because a bind the bucket refuses leaves the draft unbound and its token still usable — with zend.exception_ignore_args off, an error tracker that collects frame arguments would otherwise show it.
Attach by reference and replace in place
attach() links existing media to a model without re-uploading — a new row sharing the same stored original, zero bytes copied, with the target bucket's variants generated fresh. The target bucket’s acceptance and single-file rules apply, and replacing the source never changes the attached row’s bytes. The new row is saved under a row lock on the source, so a concurrent delete of the source waits for it; a source already deleted by then throws ModelNotFoundException and no row is created. replace() swaps the bytes while keeping the same id and uuid; the new file must satisfy the owner’s bucket exactly like an add, metadata and variants are rebuilt and MediaHasBeenReplaced fires:
$logo = MediaLibrary::bucket('brand')->first();
MediaLibrary::for($user)->attach($logo, 'avatar'); // new row, same file, no copy
MediaLibrary::attach($logo, bucket: 'shared'); // …or into another global bucket
$user->attachMedia($logo, 'avatar'); // the trait shorthand
MediaLibrary::replace($media, $request->file('avatar')); // same id/uuid, new bytes; same URL for the same type
$media->replace($request->file('avatar')); // the model shorthandThe URL stays the same, except in three cases: when the new bytes deduplicate onto another stored file; when the old original is shared with other rows (a deduplicated upload, an attach() or a same-disk copy()) — it is then left untouched for them and the new bytes go to a path of this media’s own; and when the new file is of another type — the stored file is renamed to match, so landscape.jpg replaced with a PNG becomes landscape.png. Re-read getUrl() after a replace() instead of caching the old URL.
A replace runs in one transaction (a savepoint inside yours) that first locks the media’s row (SELECT … FOR UPDATE), then checks for other referrers with a locking read. A deduplicated add onto this media has therefore either committed — the replace sees it and writes to a free path — or waits until the replace is done; it can never end up on the new bytes. Replacing a row that is gone throws ModelNotFoundException. The checks hold at PostgreSQL’s default READ COMMITTED and on MySQL at any isolation level; a host transaction running REPEATABLE READ on PostgreSQL can still hide a sharer that committed after its snapshot.
A rollback finds the row’s old bytes where it left them: before an in-place write the old file is copied aside, put back on a rollback and dropped on commit, and a file written to a new path is deleted on a rollback. A write the disk refuses — or a copy it refuses to keep aside — throws FileCannotBeWritten and the row isn’t saved. The old original is removed only when no other row, soft-deleted ones included, still points at it, and only once the outermost transaction commits.
Validation rules from a bucket
Declare a bucket’s constraints once and reuse them in any FormRequest — change the bucket and the rules follow. Only declared constraints emit a rule (max is the exact cap in kilobytes — maxFileSize(1000) emits max:0.9765625, never rounded up); a bucket without maxFileSize() falls back to media.max_file_size:
$this->addMediaBucket('avatar')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->maxFileSize(5 * 1024 * 1024) // bytes
->minDimensions(100, 100)
->maxDimensions(4096, 4096);
// In a FormRequest:
public function rules(): array
{
return [
'avatar' => MediaLibrary::rulesFor(User::class, 'avatar'),
// ['file', 'mimetypes:image/jpeg,image/png,image/webp', 'max:5120',
// 'media_dimensions:min_width=100,min_height=100,max_width=4096,max_height=4096']
];
}Dimension limits emit the package’s own media_dimensions rule rather than Laravel’s dimensions. It measures exactly as the add does — the displayed size, EXIF orientation applied; an image whose size can’t be read (an SVG) fails and a file that is no image passes — so the rules never pass a file the add then refuses. Its message is your validation.media_dimensions line if you add one, else your validation.dimensions line. For a URL a user hands in — a link the app will fetch, or one for addFromUrl() — use the SafeRemoteUrl rule instead (see Remote URLs).
The Media model's own move(), copy(), moveToDisk(), moveVariantsToDisk(), replace() and deleteWithFiles(), and the InteractsWithMedia trait methods, are shorthands that call the same manager verbs — so everything here, including MediaLibrary::fake(), applies to them too.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.