codebyray / livewire-media-uploader
Reusable Livewire v3 media uploader that integrates with Spatie Media Library and ships a publishable view.
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
Language:Blade
Requires
- php: >=8.1
- laravel/framework: ^10.0|^11.0|^12.0
- livewire/livewire: ^3.0
- spatie/laravel-medialibrary: ^10.12 || ^11.0
Requires (Dev)
- mockery/mockery: ^1.6
- nunomaduro/collision: ^7.10
- orchestra/testbench: ^8.0 || ^9.0
- pestphp/pest: ^2.34
- pestphp/pest-plugin-laravel: ^2.4
- phpunit/phpunit: ^10.5
README
Livewire Media Uploader
Livewire Media Uploader is a reusable Livewire v3 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.
Table of Contents
- Features
- Requirements
- Installation
- Publishing Assets
- Theme System
- Quick Start
- Usage Examples
- Configuration
- Props
- Events
- Model Setup (Spatie Media Library)
- Overlays & UX Notes
- Troubleshooting
- Roadmap
- License
Features
- ✅ Livewire v3 component with themeable Blade UI
- Tailwind (default)
- Bootstrap (optional)
- Fully publishable and overridable
- ✅ Spatie Media Library integration (attach, list, edit meta, delete)
- ✅ Publishable view for per-project customization
- ✅ Drag & drop uploads + progress bar
- ✅ Inline edit of caption / description / order
- ✅ Name-conflict strategies: rename | replace | skip | allow
- ✅ Optional exact duplicate detection via SHA-256
- ✅ Collection → preset mapping (auto
accept
attribute) - ✅ Image preview overlay + delete confirmation modal
- ✅ Works with:
- Saved model instance (
:for="$model"
) - String model + id (
model="user" :id="1"
) - FQCN, morph map alias, or dotted paths with custom namespaces
- Local alias map
- Saved model instance (
Requirements
- PHP 8.1+
- Laravel 10.x | 11.x | 12.x
- Livewire ^3.0
- spatie/laravel-medialibrary ^10.12
- TailwindCSS (optional but recommended for the default view)
- Alpine.js (used by overlays/progress; see Overlays & UX Notes)
- CSS depending on theme:
- Tailwind theme → TailwindCSS (recommended)
- Bootstrap theme → Bootstrap CSS (no Bootstrap JS required; Alpine drives modals)
Installation
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 ... />
Publishing Assets
Config:
php artisan vendor:publish --tag=media-uploader-config
Views:
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
Theme System (Tailwind + Bootstrap + custom)
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', ], // ... ];
Dark mode (Tailwind theme)
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>
Custom themes
- Copy an existing theme directory (e.g. themes/tailwind) to themes/custom and edit the Blade.
- Register it in the map and select it:
'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.
Environment variables (optional)
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
Notes
- Values are comma-separated; spaces are OK (the package trims them).
- After changing .env, run:
- php artisan config:clear
# (or) php artisan config:cache
- The
<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. - If uploads fail due to size, make sure your PHP/Server limits also allow it (e.g. upload_max_filesize, post_max_size).
Quick Start
-
Ensure your target Eloquent model implements
Spatie\MediaLibrary\HasMedia
and is saved.Model Setup (Spatie Media Library)
Your model must implement
HasMedia
and be saved before attaching media.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 { $this->addMediaCollection('photos') // matches collection="photos" ->useDisk('public') // or 's3' ->withResponsiveImages(); // optional // If you also have avatars somewhere: $this->addMediaCollection('avatars')->singleFile(); } public function registerMediaConversions(Media $media = null): void { $this->addMediaConversion('thumb') ->fit('contain', 256, 256) ->performOnCollections('photos', 'avatars') // scope to specific collections ->nonQueued(); } }
-
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" />
Usage Examples
-
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" />
Create flow (deferred uploads)
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" />
Livewire component (simplified)
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'); } }
How it works
- On create screens, the component accepts model="post" without an id.
- Files and per-file metadata are queued locally.
- After you persist the model, dispatch:
$this->dispatch('media:attach', model: 'post', id: $post->id, collection: 'images');
- The uploader resolves the saved target, attaches any queued files, and emits media-attached.
Configuration
The package merges config/media-uploader.php
:
accept_from_config
— iftrue
, 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 KB
Example:
'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:
- Explicit
$preset
prop - Mapping from
collections
- Fallback to
default
Props
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 |
Toggle multi-file input. |
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). |
Events
The component dispatches browser events you can listen for:
media:attach
— incoming event the component listens for. Arguments:model
(class/alias),id
, optionalcollection
, optionaldisk
. Triggers attaching of any queued files to the now-saved target.media-attached
— emitted after a successfulmedia: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>
The list view tries
getUrl('thumb')
and falls back togetUrl()
if no conversion is available.
Overlays & UX Notes
- Image Preview Overlay (lightbox): toggled by
x-show="preview.open"
. - Delete Confirmation Modal: toggled by
$wire.confirmingDeleteId !== null
. - Add once in your layout to prevent flash-of-overlay:
<style>[x-cloak]{ display:none !important; }</style>
- Z-index defaults: preview
z-[60]
, delete modalz-50
. Adjust to your stack if you have higher layers.
Troubleshooting
-
“Target model must be saved…”
Ensure the model exists in DB ($model->exists === true
) before rendering the component. -
“must implement Spatie\MediaLibrary\HasMedia”
Addimplements HasMedia
+InteractsWithMedia
to your model. -
Unknown model class/alias
If usingmodel="something"
+:id
, make sure:- It’s a valid FQCN, morph alias, or maps via dotted path within
namespaces
, or - You passed a local alias via
:aliases="['something' => \App\Models\YourModel::class]
.
- It’s a valid FQCN, morph alias, or maps via dotted path within
-
accept
not applied
Setaccept_from_config=true
and ensure your preset hastypes
/mimes
. Or override viaaccept
prop. -
No thumbnails
Add athumb
conversion (see Model Setup).
Roadmap
- Drag-to-reorder (update
order_column
). - Show document icon instead of thumbnail in Attached media list if the file is not an image.
PRs welcome!
License
MIT © CodebyRay (Ray Cuzzart II)
Component aliases: media-uploader
and media.media-uploader
View namespace: media-uploader::livewire.media-uploader