roundly-consulting/media-library-for-laravel
A Laravel media library for attaching files to Eloquent models. Upload and store images or documents, generate conversions and responsive thumbnails, organize media in collections, and manage custom properties through a clean, fluent API. Supports local and cloud disks for flexible media management.
composer require <vendor>/<package>, then publish the config and migrations and run php artisan migrate. The migrations are worth reading: the media table holds a checksum-addressed original path, a disk, and a shared/reference indicator — everything else (dedupe, attach(), copy(), replace()) is a consequence of that row design.forceDelete and transaction hooks described below.$post->addMediaFromRequest('image')->toMediaCollection('images')). Upload two byte-identical files and you should see one original on disk and two rows pointing at it. That single behaviour is the whole value proposition of the package; verify it on day one before building anything else on top.replace(). Single-file collections (avatars, hero images, logos) are the second thing you'll need, and as of 1.1.4 replace() is transaction-safe, which makes it safe to call inside the same transaction as the model update.Deduplicating adds and replaces. Use the normal add path for user uploads — identical bytes collapse onto one original automatically. For collections that permit exactly one item, prefer replace() over delete-then-add: it keeps the media row (and therefore its ID and, for in-place overwrites, its URL) stable.
Sharing an existing original. attach() (same model, other collection) and same-disk copy() (other model) point a new row at an existing original rather than re-uploading. Use these for "set as thumbnail", "duplicate this product's gallery", or seeding fixtures — they're effectively free. Since 1.1.4 they take a lock on the row they share with, so they will block behind a concurrent forceDelete() rather than resurrect a deleted file.
Transaction boundaries. The package's rule of thumb is now: files follow the commit.
forceDelete() inside DB::transaction() deletes files only on commit; a rollback leaves files intact and the row restored.replace() inside a transaction keeps the old bytes aside and puts them back on rollback; an in-place replace can therefore be rolled back cleanly.Keep media transactions short. All three fixes work by holding row locks. A transaction that adds/replaces media and then does something slow (HTTP call, PDF render, queued-variant wait) will now block concurrent deletes and competing replaces on the same original. Do heavy work before opening the transaction, or dispatch it after commit.
Deletion policy. Prefer soft deletes for anything user-reversible. Reserve forceDelete() for retention jobs and hard-delete cascades, and make sure those jobs don't run inside a longer transaction that might abort — or accept that the files are only removed on commit (which is the desired behaviour).
Cleanup and reconciliation. Keep an occasional job that scans for originals with no referencing row and for rows with no file. The dedupe design means a single missing original breaks several models at once, so an integrity check is cheaper than debugging individual reports.
Variants and regeneration. Variants hang off the original's checksum. After an in-place replace() the URL stays the same, which is convenient but means regenerating variants is your responsibility — schedule or dispatch it explicitly for any replace path.
ModelNotFoundException from attach() / copy() is new and expected. If the shared row is gone by the time the lock is acquired, these throw instead of silently creating a dangling reference. Catch it and fall back to a real upload if the source is user-initiated; if it happens in a job, re-queue.attach()/copy() fail loudly. That asymmetry is deliberate — code that must never fail should use the add path.replace() locks its own row then checks other referrers with a locking read; attach()/copy() lock the row they share with. Two transactions touching overlapping media sets in different orders can still deadlock. Keep transactions single-purpose, and be prepared to retry (DB::transaction($cb, 3)).replace() on a deleted row throws ModelNotFoundException. Guard against stale form submissions and queued jobs holding a media ID that has since been force-deleted.get_class() in at least one factory test.forceDelete() call.How can I help you explore Laravel packages today?