codebyray/livewire-media-uploader
A reusable Livewire v3/v4 component for Spatie Media Library with publishable Tailwind or Bootstrap views, drag-and-drop uploads, inline caption/description/order editing, reordering, conflict strategies, SHA-256 duplicate detection, and optional authorization hooks.
1. Verify you clear the requirements gate first. This is the single biggest tripwire:
spatie/laravel-medialibrary ^10.12 | ^11If you're still on Laravel 11, you cannot use current releases — pin codebyray/livewire-media-uploader:v0.4.* and treat the Laravel upgrade as the real task, not the package.
composer require codebyray/livewire-media-uploader
php artisan vendor:publish --tag=media-uploader-config
php artisan vendor:publish --tag=media-uploader-views # only when you need to customize
2. Prepare the model before touching Blade. The uploader is a thin UI over Spatie Media Library; almost every "it doesn't work" report traces back to the model.
class User extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('avatars')->singleFile()->useDisk('public');
$this->addMediaCollection('gallery')->useDisk('public');
}
}
The model must be saved before media can be attached (see the create-flow pattern below for the exception).
3. Wire up the layout. The component needs Livewire's scripts, Alpine, and the x-cloak rule (the preview overlay and delete confirmation are Alpine-driven):
@livewireStyles
<style>[x-cloak]{ display:none !important; }</style>
...
@livewireScripts
4. Your first use case — an avatar on an edit screen. This is the highest-value, lowest-risk introduction because it's a saved model, a single file, and a known collection:
<livewire:media-uploader :for="$user" collection="avatars" preset="images" />
Where to look first: the README's Props and Events tables, then config/media-uploader.php (presets, theme map, conflict defaults), then the theme Blade at resources/views/vendor/media-uploader/themes/tailwind/media-uploader.blade.php — the view is the real API surface for anything the props don't cover.
:for, fall back to model/idThere are four ways to point the component at a record, and the package resolves them in a documented precedence (FQCN, morph map alias, dotted path with :namespaces, local :aliases map, then model+id):
{{-- Best default: a real instance, no guessing --}}
<livewire:media-uploader :for="$post" collection="images" preset="images" />
{{-- Short string + id, for edit screens where you only have an id --}}
<livewire:media-uploader model="post" :id="$postId" collection="images" />
{{-- Non-standard namespace --}}
<livewire:media-uploader model="crm.contact" :id="$id"
:namespaces="['App\Domain\Crm\Models', 'App\Models']" collection="files" />
{{-- One-off alias, e.g. shared partial used by several models --}}
<livewire:media-uploader model="profile" :id="$user->id"
:aliases="['profile' => \App\Models\User::class]" collection="gallery" />
Rule of thumb: :for everywhere you can; only drop to model/id when the component lives in a partial shared across resources, and then set :aliases/:namespaces explicitly rather than relying on inference.
This is the pattern that matters most day-to-day, because it's where a naive integration breaks. Pass the model class without an id, let users queue files, then hand the new record's id to the uploader after save():
{{-- Create page: no id yet --}}
<livewire:media-uploader model="post" collection="images" preset="images"
:multiple="true" :showList="true" />
public function save(): void
{
$post = Post::create([...]);
$this->pendingPostId = $post->id;
// then notify the uploader(s) that a model now exists —
// use the event name from the README's "Events" section,
// e.g. dispatch/broadcast it with the new id.
}
Practical advice: don't try to reimplement this by hand. Copy the README's example verbatim onto a scratch page, confirm your queued files actually land in the media table, then generalize. Confirm the exact event name from the README's Events section rather than guessing — the create-flow sample is truncated in the docs.
Presets (images, docs, videos, default) come from config and are overridable via MEDIA_TYPES_*, MEDIA_MIMES_*, MEDIA_MAXKB_* env vars. The <input accept> is auto-derived when accept_from_config is true.
Use presets for the 90% case and drop to explicit props only when a screen genuinely differs:
<livewire:media-uploader :for="$user" collection="documents" preset="docs" />
<livewire:media-uploader :for="$user" collection="contracts"
:accept="'.pdf,application/pdf'"
:allowedTypes="['pdf']"
:allowedMimes="['application/pdf']"
:maxSizeKb="5120" />
Setting all three of accept / allowedTypes / allowedMimes together is intentional, not redundant — accept is a browser hint, the other two are server-side validation. On sensitive uploads, always keep the server-side pair.
$this->addMediaCollection('avatars')->singleFile(); // one item, each upload replaces
$this->addMediaCollection('photos'); // gallery, accumulate
The multiple prop only controls whether the file input accepts multiple selections. It does not override singleFile(). A gallery collection with multiple on a singleFile() collection will "work" and silently leave only the last file.
<livewire:media-uploader :for="$user" collection="files" onNameConflict="rename" />
rename — safest default for user uploads; nothing is destroyed.skip — good for idempotent imports.replace — only where "this name is this slot" is a real business rule (logos, signed contracts).allow — explicit opt-in to duplicates.Pair :skipExactDuplicates="true" with rename in gallery scenarios so re-uploading the same photo doesn't produce photo.jpg, photo-1.jpg, photo-2.jpg.
The authorizeAbility prop delegates to your own Gate/Policy — the package doesn't impose a permission system:
<livewire:media-uploader :for="$post" collection="images" authorizeAbility="update" />
Make sure the ability name exists on the model's policy; a typo here fails closed (denies) with little ceremony, so test it explicitly.
localStorage.theme snippet to <head> if you support dark mode.'theme' => 'bootstrap' in config and include Bootstrap CSS only — Alpine drives the modal, no Bootstrap JS.themes/tailwind → themes/custom, register it in the themes map, select it. Behaviour is identical across themes; only markup/classes differ.Publish views only when you mean it. Once published, composer update no longer improves your markup — you own the divergence.
A gallery plus an avatar plus a documents block is normal on a real edit screen. Give each component a stable wire:key and be explicit about collection, since Livewire component instances key off their parameter fingerprint:
<livewire:media-uploader :for="$post" collection="images" :key="'media-images'" />
<livewire:media-uploader :for="$post" collection="docs" preset="docs" :key="'media-docs'" />
Nothing exotic — this is stock Spatie API, so your Blade stays familiar:
$post->getMedia('images')->map(fn ($m) => [
'url' => $m->getUrl(),
'thumb' => $m->getUrl('thumb'), // requires registerMediaConversions()
'caption' => $m->getCustomProperty('caption'),
]);
Since caption/description/order are edited inline by the uploader, read them back from custom properties / order_column rather than keeping a parallel JSON column.
Storage::fake('public');
Livewire::test(\Codebyray\LivewireMediaUploader\...::class, ['model' => 'post', 'id' => $post->id, 'collection' => 'images'])
->set('files', [...])
->assertHasNoErrors();
Fake the disk before the model is created, and assert against the media table rather than the filesystem — attach/resize/conversion behaviour is where the bugs live.
singleFile() + multiple is the #1 data-loss trap. Selecting five photos into an avatar collection processes all five and leaves one. Document the collection config in a comment next to every uploader in your Blade.
Three separate size ceilings must agree. maxSizeKb in the component, Livewire's own temporary_file_upload.rules (default max:12288 KB) in config/livewire.php, and PHP's upload_max_filesize / post_max_size. If a large upload dies with a generic "failed to upload" error, it's almost always the middle one, not the component.
.env edits don't take effect until php artisan config:clear. In production with config caching, that's php artisan config:cache — a redeploy, not a tweak. The preset/mime vars are all read through config.
Hashing every file costs real time. skipExactDuplicates runs SHA-256 over uploads. Fine for documents and screenshots; noticeable on a 200 MB video preset. Enable it per-component, not globally by default.
Unsaved models fail quietly. Passing a Post that hasn't been save()d means there's nothing to attach to. Either save first or use the deferred create flow; don't hand the component a new Post.
Don't load a second Alpine. The overlays and progress bar rely on Livewire's bundled Alpine. A globally-installed Alpine 2 or a duplicate x-data root will break the preview modal in confusing, intermittent ways — the symptom is "clicking a thumbnail does nothing." The [x-cloak]{display:none!important} style is mandatory, or the modal flashes on every render.
Debug from the database outward. Check the media table first (model_type, collection_name, disk, order_column), then disk wiring (storage:link, config/filesystems.php), then php artisan medialibrary:regenerate for conversions. If rows exist but images 404, it's a disk/URL problem, not an uploader problem.
Publish config even if you change nothing. It's the fastest way to see every preset, the theme map, and the conflict/dedupe defaults in one file — much quicker than reading the README twice.
Pin the version and read the CHANGELOG before bumps. v0.5.0 dropped Laravel 10/11 support outright; a similar policy change on Livewire 5 or Laravel 14 will be a silent composer update away. For a package at 7 stars and zero dependents, treat minor versions as potentially breaking.
Contain the blast radius. With a single maintainer and no dependents, the real risk isn't bugs — it's abandonment. Two cheap mitigations: wrap the component in your own Blade partial/slot so call sites aren't coupled to prop names, and publish the theme view into your repo the moment you customize it, so a future fork or in-house replacement is a copy-paste rather than a rewrite.
Extension points, in order of preference: props → config presets → theme map (swap in your own view class) → published view edit. Only fork the package as a last resort; the published-view path covers nearly everything you'd want to change about appearance and most of the interaction wiring.
Verify the Events surface against the README before building on it. The create-flow example is truncated in the docs; grep the package source for dispatch( to see exactly what's emitted and with what payload before wiring listeners in your own components.
How can I help you explore Laravel packages today?