NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Media Library for Laravel

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, and variants($media) groups the variant operations:

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();

Flat methods

MethodReturnsDoes
add($file), addFromUrl(), addFromDisk(), addFromString(), addFromBase64(), addFromStream()PendingFileAddStart a global add (no owner).
draft($file)PendingFileAddStart a global draft add.
for($model)ModelMediaOwner-scoped handle — adds, drafts, attach, reads, clear and delete for one model.
attach($media, to: ?Model, bucket:)MediaAttach by reference (zero bytes copied); to: null = a global bucket.
bindDraft($token, to: $model, bucket:)MediaBind a draft to an owner — the bucket’s rules, storage, single-file rule and variants apply.
move($media, to: ?Model, bucket:, disk:)MediaRe-home and/or move across disks.
moveToDisk($media, $disk) / moveVariantsToDisk($media, $disk)MediaMove the files, or only the variants, to another disk.
copy($media, to: ?Model, bucket:, disk:)MediaDuplicate into a new row (fresh uuid).
replace($media, $file)MediaNew bytes, same id/uuid (and URL, unless the old file is shared).
delete($media)voidDelete the row and its files (refcount-guarded).
variants($media)MediaVariantsall(), generated(), missing(), regenerate(only:, force:).
regenerate($media, only: [], force: false)list<string>Re-render variants; returns the names rendered.
pruneDrafts()intDelete expired, never-bound drafts.
rulesFor(Model::class, $bucket)list<string>Validation rules from a bucket definition.
bucket($bucket) / find($uuid) / clearBucket($bucket)Builder / ?Media / intGlobal 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).

MethodReturnsDoes
add(), addFromRequest(), addFromUrl(), addFromDisk(), addFromString(), addFromBase64(), addFromStream()PendingFileAddOwner-scoped add; addFromRequest() throws FileDoesNotExist when the key has no upload.
bindDraft($token, $bucket)MediaBind a draft to this model.
attach($media, $bucket)MediaAttach existing media by reference.
get($bucket) / first($bucket) / has($bucket)Collection / ?Media / boolRead the bucket, in order.
find($uuid)?MediaOnly media this model owns — null otherwise.
url($bucket, $variant) / temporaryUrl($bucket, $variant, $expiry)stringFirst media's URL, else the bucket fallback, else an empty string.
clear($bucket)intDelete every media in the bucket.
delete($media)voidThrows 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, and the database update runs in a transaction with the source files removed only after it commits:

// 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: 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:. 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 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.

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 count

Binding throws DraftMediaNotFound (unknown or already-bound token) or DraftMediaExpired (past the TTL); 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.

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. 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/url, new bytes
$media->replace($request->file('avatar'));                 // the model shorthand

The URL stays the same, except in two cases: when the new bytes deduplicate onto another stored file, and 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.

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 in kilobytes); 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',
        //  'dimensions:min_width=100,min_height=100,max_width=4096,max_height=4096']
    ];
}

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 crypto

By 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.