Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Mediable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. Plank\Mediable\MediaUploader — the fluent API surface you will spend 90% of your time in.
  3. Plank\Mediable\Media — the model (getUrl(), contents(), stream(), copyToDisk(), variants).
  4. The readthedocs site (laravel-mediable.readthedocs.io) and 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 }}">

Implementation Patterns

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

Gotchas and Tips

  • Deleting a parent model does not delete its files. Call $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.
  • Uploads are file-first, DB-second. A failed insert after 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.
  • Filename sanitization is aggressive since 5.0. Only ASCII alphanumerics plus ., -, _, / 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.
  • Duplicate handling is a policy, not a default you can ignore. 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.
  • MIME type comes from file contents by default in 6.x, not from the client. If you want browser-supplied types, call 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.
  • Visibility defaults changed in 6.0: if you never call makePublic()/makePrivate(), the filesystem disk's visibility is used. On S3 this is the difference between a working getUrl() and a 403.
  • Do not publish or copy migrations on 5.0+. The package loads them itself; leftovers from a 4.x install cause duplicate-table errors, especially after schema:dump. Follow UPGRADING.md.
  • Custom media model / table: if you subclass 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.
  • Testing: 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.
  • Check PHP limits before blaming the package: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
thuraaung2493/laravel-rulecraft
cyberwizard/schedule-catchup
livetennisapi/livetennisapi-laravel
roundly-consulting/notifications-for-laravel
flightsadmin/bootstrap-starter-kit
lasserafn/laravel-intempus
likyly/sdk
tdt/input
coreation/php-shapefile
coreation/gisconverter
primix/widgets
primix/notifications
primix/actions
primix/tables
primix/details
primix/forms
primix/support
marcelgwerder/laravel-api-handler
firebrikdotcom/conveyor-laravel-broadcaster
kanata-php/conveyor-laravel-broadcaster