codebyray/livewire-media-uploader
Livewire Media Uploader is a reusable Livewire v3/v4 component that integrates seamlessly with Spatie Laravel Media Library. It ships a clean Tailwind Blade view by default (fully publishable), Bootstrap theme as an option, Alpine overlays for previews/confirmations, drag-and-drop uploads, per-file metadata (caption/description/order), configurable presets, name-conflict strategies, and optional SHA-256 duplicate detection. Drop it in, point it at a model, and you’re shipping in minutes.
order_column)accept attribute)authorizeAbility) — delegates to your app's own Gate/Policy, no auth package required:for="$model")model="user" :id="1")Note on Laravel 10/11: Earlier releases of this package listed Laravel 10 and 11 as supported. Both are now past their security-support window (Laravel 10 is EOL; Laravel 11 security support ended March 2026), and current releases of
laravel/frameworkin those lines carry known, unpatched advisories — meaning a freshcomposer installtargeting either will be blocked by Composer's own audit for most consumers. Support for both has been dropped as ofv0.5.0. If you're still running Laravel 10/11, pin this package tov0.4.x, but prioritize upgrading Laravel first — that's the more urgent fix.Every PHP/Laravel/Livewire combination listed above is verified on every push via GitHub Actions.
composer require codebyray/livewire-media-uploader
Auto-discovery will register the service provider. If you disable discovery, add:
// config/app.php
'providers' => [
// ...
Codebyray\LivewireMediaUploader\MediaUploaderServiceProvider::class,
],
The component is registered under both aliases:
<livewire:media-uploader ... /><livewire:media.media-uploader ... />php artisan vendor:publish --tag=media-uploader-config
php artisan vendor:publish --tag=media-uploader-views
After publishing, customize the Blade at:
resources/views/vendor/media-uploader/themes/tailwind/media-uploader.blade.php
resources/views/vendor/media-uploader/themes/bootstrap/media-uploader.blade.php
Select the theme in config/media-uploader.php:
// config/media-uploader.php
return [
'theme' => 'tailwind', // 'tailwind' (default) or 'bootstrap'
'themes' => [
'tailwind' => 'media-uploader::themes.tailwind.media-uploader',
'bootstrap' => 'media-uploader::themes.bootstrap.media-uploader',
],
// ...
];
This package’s Tailwind theme is dark-ready. Add this tiny snippet in your main layout <head> to apply the user’s saved choice / system default:
<script>
(() => {
const t = localStorage.theme ?? 'system';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = t === 'dark' || (t === 'system' && prefersDark);
if (dark) document.documentElement.classList.add('dark');
})();
</script>
'theme' => 'custom',
'themes' => [
'tailwind' => 'media-uploader::themes.tailwind.media-uploader',
'bootstrap' => 'media-uploader::themes.bootstrap.media-uploader',
'custom' => 'media-uploader::themes.custom.media-uploader',
],
Note: The component’s Livewire + Alpine behavior is identical across themes. Only classes/markup differ. If you use the Bootstrap theme, make sure your layout includes Bootstrap CSS.
You can override preset limits and accepted types/mimes via .env. These map directly to config/media-uploader.php:
# Livewire Media Uploader (optional)
# Images
MEDIA_TYPES_IMAGES=jpg,jpeg,png,webp,avif,gif
MEDIA_MIMES_IMAGES=image/jpeg,image/png,image/webp,image/avif,image/gif
MEDIA_MAXKB_IMAGES=10240
# Documents
MEDIA_TYPES_DOCS=pdf,doc,docx,xls,xlsx,ppt,pptx,txt
MEDIA_MIMES_DOCS=application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain
MEDIA_MAXKB_DOCS=20480
# Videos
MEDIA_TYPES_VIDEOS=mp4,mov,webm
MEDIA_MIMES_VIDEOS=video/mp4,video/quicktime,video/webm
MEDIA_MAXKB_VIDEOS=102400
# Fallback preset
MEDIA_TYPES_DEFAULT=jpg,jpeg,png,webp,avif,gif,pdf,doc,docx,xls,xlsx,ppt,pptx,txt
MEDIA_MIMES_DEFAULT=image/jpeg,image/png,image/webp,image/avif,image/gif,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain
MEDIA_MAXKB_DEFAULT=10240
php artisan config:clear
<input accept="…"> attribute is auto-filled from the active preset when accept_from_config is true (default). You can still override it per-component with the accept prop.Ensure your target Eloquent model implements Spatie\MediaLibrary\HasMedia and is saved.
Your model must implement HasMedia and be saved before attaching media.
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Post extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
// Multi-file collection.
$this->addMediaCollection('photos')
->useDisk('public')
->withResponsiveImages();
// Single-file collection. Each new upload replaces the existing file.
$this->addMediaCollection('avatars')
->singleFile();
}
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')
->fit(Fit::Contain, 256, 256)
->performOnCollections('photos', 'avatars')
->nonQueued();
}
}
Multiple uploads and
singleFile()The uploader's
multipleprop controls whether the file input allows selecting multiple files. It does not override Spatie Media Library's collection configuration.If a collection is configured with
->singleFile(), Spatie will remove the existing media item each time another file is added. Selecting multiple files can therefore process every selected file while leaving only the last file in the collection.For galleries and other multi-file collections, do not use
->singleFile():$this->addMediaCollection('photos');Use
->singleFile()only when the collection should contain one item, such as an avatar or logo:$this->addMediaCollection('avatar') ->singleFile();
Include Livewire & Alpine (usually in your app layout):
@livewireStyles
<style>[x-cloak]{ display:none !important; }</style>
@livewireScripts
Drop the component into your Blade:
<livewire:media-uploader :for="$user" collection="avatars" preset="images" />
Pass a saved model instance
<livewire:media-uploader :for="$user" collection="avatars" preset="images" />
Short string model + id
<livewire:media-uploader model="user" :id="$user->id" collection="images" preset="images" />
Morph map alias**
<livewire:media-uploader model="users" :id="$user->id" collection="profile" preset="images" />
FQCN
<livewire:media-uploader model="\App\Models\User" :id="$user->id" collection="documents" />
Dotted path + custom namespaces
<livewire:media-uploader
model="crm.contact"
:id="$contactId"
:namespaces="['App\\Domain\\Crm\\Models', 'App\\Models']"
collection="images"
preset="images"
/>
Local aliases (per-instance)
<livewire:media-uploader
model="profile"
:id="$user->id"
:aliases="['profile' => \App\Models\User::class]"
collection="gallery"
/>
Single-file mode + hide list
<livewire:media-uploader
:for="$user"
collection="avatar"
:multiple="false"
:showList="false"
preset="images"
/>
Name conflict strategies
<livewire:media-uploader :for="$user" collection="files" onNameConflict="rename" />
<livewire:media-uploader :for="$user" collection="files" onNameConflict="replace" />
<livewire:media-uploader :for="$user" collection="files" onNameConflict="skip" />
<livewire:media-uploader :for="$user" collection="files" onNameConflict="allow" />
Duplicate detection by SHA-256
<livewire:media-uploader :for="$user" collection="images" preset="images" :skipExactDuplicates="true" />
Restrict types/mimes/max size manually
<livewire:media-uploader
:for="$user"
collection="documents"
:accept="'.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'"
:allowedTypes="['pdf','doc','docx']"
:allowedMimes="['application/pdf','application/msword','application/vnd.openxmlformats-officedocument.wordprocessingml.document']"
:maxSizeKb="5120"
/>
You can let users pick files before the model exists, and attach them after save.
Blade (create page)
<!-- Note: pass model class/alias without id -->
<livewire:media-uploader
model="post"
collection="images"
preset="images"
:multiple="true"
:showList="true"
/>
use App\Models\Post;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class PostCreate extends Component
{
public string $title = '';
public string $body = '';
public ?int $pendingPostId = null;
protected function rules(): array
{
return ['title' => 'required|string|max:255', 'body' => 'required|string'];
}
public function save(): void
{
$post = Post::create([
'user_id' => Auth::id(),
'title' => $this->title,
'body' => $this->body,
]);
// Let uploaders attach everything queued for this collection
$this->pendingPostId = $post->id;
// Fire once per collection rendered on the page
$this->dispatch('media:attach', model: 'post', id: $post->id, collection: 'images');
}
#[On('media-attached')]
public function afterMediaAttached(string $model, string|int $id): void
{
if ($this->pendingPostId && (int)$id === (int)$this->pendingPostId) {
$this->pendingPostId = null;
$this->redirectRoute('posts.show', ['post' => $id], navigate: true);
}
}
public function render() { return view('livewire.posts.post-create'); }
}
$this->dispatch('media:attach', model: 'post', id: $post->id, collection: 'images');
The package merges config/media-uploader.php:
accept_from_config — if true, auto-fills <input accept> from the selected presetcollections — map collection name → preset keypresets.*.types — extensions (comma-separated)presets.*.mimes — MIME types (comma-separated)presets.*.max_kb — max file size per file in KBExample:
'collections' => [
'avatars' => 'images',
'images' => 'images',
'attachments' => 'docs',
],
Show all collections together (grouped)
Set :list-all="true" to render a grouped list of every collection on the target model. Items stay fully editable.
<livewire:media-uploader
:for="$post"
:list-all="true"
:showList="true"
/>
The component decides the active preset in this order:
$preset propcollectionsdefault| Prop | Type | Default | Description |
|---|---|---|---|
for |
Model |
— | Saved Eloquent model instance implementing HasMedia. |
model |
string |
— | Model resolver: alias, FQCN, morph alias, or dotted path. |
id |
`int | string` | — |
collection |
string |
images |
Media collection name. |
disk |
?string |
null |
Storage disk (e.g. s3). |
multiple |
bool |
true |
Allow selecting multiple files. The target Spatie collection must not use singleFile() if multiple files should be retained. |
accept |
?string |
null |
<input accept> override (otherwise may be auto from config). |
showList |
bool |
true |
Show the attached media list. |
maxSizeKb |
int |
500 (overridden to preset’s max_kb if empty) |
Max file size (KB). |
preset |
?string |
null |
Choose a preset (images, docs, videos, default, etc.). |
allowedTypes |
array |
[] |
Extensions filter (e.g. ['jpg','png']). |
allowedMimes |
array |
[] |
MIME filter (e.g. ['image/jpeg']). |
onNameConflict |
string |
rename |
Strategy: rename | replace | skip | allow. |
skipExactDuplicates |
bool |
false |
Uses SHA-256 stored in custom_properties->sha256. |
namespaces |
array |
['App\\Models'] |
Namespaces for dotted-path resolution. |
aliases |
array |
[] |
Local alias map, e.g. ['profile' => \App\Models\User::class]. |
attachedFilesTitle |
string |
"Current gallery" |
Heading text in the list card. |
listAll |
bool |
false |
When true, the attached media list shows all collections, grouped by collection name (still editable). |
authorizeAbility |
?string |
null |
Gate/Policy ability checked against the target model before upload/delete/edit/attach. See Authorization. |
The component dispatches browser events you can listen for:
media:attach — incoming event the component listens for. Arguments: model (class/alias), id, optional collection, optional disk. Triggers attaching of any queued files to the now-saved target.media-attached — emitted after a successful media:attach. Payload: { model: FQCN, id: string }.media-uploaded — emitted after an immediate upload (when a target already exists).media-deleted — emitted after deletion (detail.id contains the Media ID).media-meta-updated — emitted after inline metadata is saved.Example:
<div
x-data
x-on:media-uploaded.window="console.log('uploaded!')"
x-on:media-deleted.window="console.log('deleted', $event.detail?.id)"
>
<livewire:media-uploader :for="$user" collection="images" preset="images" />
</div>
By default, the component does not perform any authorization. It verifies that a given Media record belongs to the resolved target model before allowing edits/deletes, but it does not check whether the current user is allowed to modify that model. It's your app's responsibility to ensure the component only renders where the user already has access (route middleware, a policy check before rendering the page, etc.).
If you'd like the component to enforce this itself, set authorizeAbility to a Gate/Policy ability name. It's checked against the resolved target model before every mutating action (uploadFiles, remove, saveEdit, and the media:attach event handler), using Laravel's own Gate::authorize() — no extra package required, and it plays nicely with anything already wired up (Policies, Gate closures, Spatie Permissions via a Gate, etc.).
<livewire:media-uploader
:for="$post"
collection="images"
preset="images"
authorizeAbility="update"
/>
With the example above, before any upload/delete/edit/attach action runs, the component calls the equivalent of Gate::authorize('update', $post). If your PostPolicy::update() returns false, the action aborts with a 403 instead of proceeding.
Notes:
delete and update to map to different Policy methods, don't set authorizeAbility — instead wrap the component's Blade usage behind your own check, or open an issue/PR describing the use case.channel prop used with the media:attach event is a namespacing convenience for routing the event to the right component instance — it is not an authorization boundary. Use authorizeAbility (or your own upstream checks) if untrusted input could influence which model/id gets dispatched to media:attach.authorizeAbility unset preserves the exact behavior of versions prior to 0.5.0 — this is a fully backward-compatible, opt-in addition.The list view tries
getUrl('thumb')and falls back togetUrl()if no conversion is available.
x-show="preview.open".$wire.confirmingDeleteId !== null.<style>[x-cloak]{ display:none !important; }</style>
z-[60], delete modal z-50. Adjust to your stack if you have higher layers.“Target model must be saved…”
Ensure the model exists in DB ($model->exists === true) before rendering the component.
“must implement Spatie\MediaLibrary\HasMedia”
Add implements HasMedia + InteractsWithMedia to your model.
Unknown model class/alias
If using model="something" + :id, make sure:
namespaces, or:aliases="['something' => \App\Models\YourModel::class].Multiple files selected, but only one remains after upload
Check the target model's registerMediaCollections() method. If the collection uses Spatie Media Library's ->singleFile(), each new upload replaces the previous media item.
For multiple files:
$this->addMediaCollection('photos');
For a collection that should contain only one file:
$this->addMediaCollection('avatar')
->singleFile();
accept not applied
Set accept_from_config=true and ensure your preset has types/mimes. Or override via accept prop.
No thumbnails
Add a thumb conversion (see Model Setup).
PRs welcome!
MIT © CodebyRay (Ray Cuzzart II)
Component aliases: media-uploader and media.media-uploader
View namespace: media-uploader::livewire.media-uploader
How can I help you explore Laravel packages today?