plank/laravel-mediable
Laravel-Mediable lets you easily upload files and attach them to Eloquent models via many-to-many polymorphic relationships, with tags for organizing media, configurable filesystem destinations, MIME/extension restrictions, and image variant support.
Install and wire it up:
composer require plank/laravel-mediable
php artisan vendor:publish --provider="Plank\Mediable\MediableServiceProvider"
php artisan migrate
Laravel 5.5+ auto-discovers MediableServiceProvider and the MediaUploader facade; only register them manually on older versions. Note two version realities before you start: 5.0+ loads migrations from the package (they are no longer copied into database/migrations), and 6.x requires PHP 8.1+ / Laravel 10–11. If you are on Laravel 8/9, pin to 5.x.
Then add a destination disk in config/filesystems.php — this is the single most important setup step, because getUrl() and temporaryUrl() depend on the disk's url / visibility configuration:
'uploads' => [
'driver' => 'local',
'root' => storage_path('app/uploads'),
'url' => env('APP_URL').'/uploads',
'visibility' => 'public',
],
Where to look first:
config/mediable.php — default disk/directory, max size, allowed aggregate types / MIME types / extensions, duplicate strategy, prefer_client_mime_type, variant and image-optimizer settings.Plank\Mediable\MediaUploader — the fluent API surface you will spend 90% of your time in.Plank\Mediable\Media — the model (getUrl(), contents(), stream(), copyToDisk(), variants).UPGRADING.md if you are coming from 4.x — the 5.0 and 6.0 changelogs contain several breaking changes.First use case — a post thumbnail:
// app/Models/Post.php
use Plank\Mediable\Mediable;
class Post extends Model
{
use Mediable;
}
// Controller
$media = MediaUploader::fromSource($request->file('thumbnail'))
->toDestination('uploads', 'blog/thumbnails')
->setAllowedAggregateTypes(['image'])
->setMaximumSize(5 * 1024 * 1024)
->makePublic()
->upload();
$post = Post::create($request->validated());
$post->attachMedia($media, 'thumbnail');
// Blade
<img src="{{ $post->getMedia('thumbnail')->first()?->url }}"
alt="{{ $post->getMedia('thumbnail')->first()?->alt }}">
Wrap the upload pipeline in one place. Rather than sprinkling MediaUploader calls through controllers, put them in a form request or a small MediaService. The uploader is a builder: fromSource() → destination → constraints → visibility/filename → duplicate policy → upload(). Constraints can come from config defaults or be overridden per call:
$media = MediaUploader::fromSource($file)
->toDisk('uploads')
->toDirectory('avatars/'.auth()->id())
->setAllowedMimetypes(['image/jpeg', 'image/png'])
->setAllowedExtensions(['jpg', 'jpeg', 'png'])
->setMaximumSize(2_000_000)
->withAltAttribute($request->input('alt'))
->useHashForFilename('sha256') // deterministic, collision-safe names
->makePrivate()
->onDuplicateIncrement()
->upload();
fromSource() accepts an UploadedFile, an absolute path, a URL string (remote fetch, cached to temp for repeated metadata reads), a PSR-7 stream, a raw string, or a data URL. That flexibility means the same code path handles admin uploads, seeded demo content, and imports.
Tags are your media slots. The morph-many-to-many pivot plus a tag string gives you a poor-man's structured media library: thumbnail, hero, gallery, download. Attach and re-attach with:
$post->attachMedia($media, ['gallery', 'featured']);
$post->attachMediaById($ids, 'gallery');
$post->syncMedia($media, 'gallery'); // replace the set for that tag
$post->updateMedia($newMedia, 'thumbnail'); // swap
$post->detachMedia($oldMedia, 'gallery');
$post->getMedia('gallery'); // MediableCollection
$post->hasMedia('thumbnail');
Retrieving with an array of tags is an AND: getMedia(['gallery', 'featured']) returns only media carrying both. Keep tag vocabulary in a class constant so you do not typo 'thumbnails' in one place and query 'thumbnail' in another.
Query instead of hydrating. For listing pages, use the trait's scopes rather than calling getMedia() per row:
Post::whereMediaMatch('thumbnail')->paginate();
Post::whereMediaMatchAll(['thumbnail', 'hero'])->with('media')->get();
Eager-load media (and filter in memory) when you need several tags on a listing; per-row getMedia() is a classic N+1.
Import and replace existing files. Files that already live on a disk (migrated legacy uploads, CDN artefacts) can be registered without re-uploading via the import path, and MediaUploader::replace() handles swapping the bytes behind an existing record — both also fire the beforeSave callback. Media::copyTo(), moveToDisk(), copyToDisk() cover reorganising storage without losing the DB record (visibility is carried over to the new disk).
Image variants. With intervention/image installed (v6 uses intervention 3), media can generate derivatives that keep a parent/child link:
$media->createImageVariant('thumb', function (ImageManipulation $image) {
$image->fit(300, 200);
});
Variant definitions can be centralised in config/mediable.php and generated synchronously or dispatched as queued jobs — the latter is essential for large galleries and requires a running worker. Image optimization via spatie/image-optimizer is configurable per variant or disabled with noOptimization().
Serving private files. For private disks, do not rely on getUrl(). Use temporaryUrl($expiry) (S3-supported since 5.0, with root-path fixes in 5.4.x) or stream through a controller with $media->stream() / $media->contents().
$media->delete() or $post->getMedia('gallery')->delete() (the MediableCollection override removes both rows and files). Add an observer or a scheduled orphan-pruning command, otherwise your disk grows forever.upload() leaves an orphan file. Wrap in a transaction and delete the file on exception, or validate before uploading using setMaximumSize() / MIME constraints rather than after.., -, _, / survive; accents are transliterated using the app locale, everything else becomes a hyphen. Long or exotic filenames will change under you — use useHashForFilename() where the name must be predictable.onDuplicateIncrement() appends a suffix, onDuplicateReplace() overwrites and deletes the old record, onDuplicateUpdate() reuses the existing row, onDuplicateError() throws. onDuplicateReplaceWithVariants() also removes orphaned variants. For hash-named files, increment is almost certainly wrong — set the policy explicitly.preferClientMimeType() or set prefer_client_mime_type in config. Aggregate types (image, audio, video, document) map to MIME/extension lists in config — extend that map when you add formats, and remember matching is case-insensitive only since 5.2.1.makePublic()/makePrivate(), the filesystem disk's visibility is used. On S3 this is the difference between a working getUrl() and a 403.schema:dump. Follow UPGRADING.md.Media (extra columns, casts) or rename the table, configure the media model in config/mediable.php — subclassing historically broke the morph relation (fixed in 4.4.1) and custom table names had their own bugs (4.4.2). Smoke-test attach/retrieve after doing this.beforeSave timing changed in 4.1: it now runs before duplicate validation and also on replace() and import(), so it is safe to use it to compute destination paths — and it will fire on paths you may not expect.Storage::fake('uploads') works because everything goes through the Filesystem manager; assert with Storage::disk('uploads')->assertExists($media->getAbsolutePath()). Just remember getUrl() needs a url key on the disk config to return anything useful in tests.upload_max_filesize / post_max_size failures surface as an empty file in the request, not a validation error. Pair setMaximumSize() with a matching Laravel validation rule.How can I help you explore Laravel packages today?