drpshtiwan/livewire-media-selector
A lightweight WordPress-style media selector for Laravel and Livewire. Browse, search, paginate, and upload files from your disks, with single or multi-select, drag-to-reorder, collections, soft deletes, and trait helpers for quick model integration.
Verify the compatibility gate first, before you plan anything around this component. You need PHP 8.3+, Laravel 12–13, and Livewire 3.5+ or 4.x. If the project is on Laravel 11 or earlier you are pinned to the 1.x line, which predates the #[Locked] security work, the collection-scoped re-validation, and Laravel 13 support. Check with composer show laravel/framework livewire/livewire php before requiring — this is the one mistake that costs you a sprint rather than an afternoon.
composer require drpshtiwan/livewire-media-selector
php artisan vendor:publish --tag=media-selector-config
php artisan vendor:publish --tag=media-selector-migrations
php artisan migrate
php artisan storage:link
Then publish the CSS. The assets are not auto-injected — skip this and you get an unstyled modal that looks broken rather than merely ugly:
php artisan vendor:publish --tag=media-selector-assets --force
Publish views and lang files only when you actually need to customise them (--tag=media-selector-views, plus the lang tag if your locale is Arabic or Kurdish).
Where to look first, in order:
config/media-selector.php — this is the real API surface. disk, directory, allowed_extensions, maxUploadKb, per_page, show_thumbnails, can_upload, table, and model all live here. Read it before writing a tag; most "why doesn't it…" questions are answered in this file.Media model — this decides whether its table becomes your system of record or a staging area (see Gotchas).vendor/drpshtiwan/livewire-media-selector/resources/views — the UI is Blade you can publish. Knowing this on day one prevents you filing an upstream feature request in week three.CHANGELOG.md for whichever major you install. Three majors have shipped with documented breaking changes; config keys and security defaults have moved.First use case — one image picker on one form, today:
<livewire:media-selector wire:model="media" collection="gallery" />
public $media; // populated by the component when the user confirms
public function save(): void
{
$this->validate(['media' => 'required']);
$this->post->update(['hero_media_id' => $this->media]);
}
That is a browse/search/paginate/upload/preview modal for roughly zero bespoke code, and it is the fastest way to judge whether the look and feel clears your team's bar before you commit to it.
One component per field, each with its own collection. The same tag serves single and multi select; the difference is the shape of the wire:model payload (a single identifier for one, an ordered set for many). Render a <livewire:media-selector> per image slot on a product form — collection="hero", collection="thumbnail", collection="gallery" — and you get a coherent admin UI without building a picker per screen. Multi-select returns items in user-dragged order, so persist that order rather than re-sorting by ID.
Collections are the primary organising lever, not separate disks. collection="avatars" and collection="gallery" are scoped lists that don't bleed into each other, and selection/delete/restore are re-validated server-side against the active scope (disk + user + collection + extra scope). Keeping everything on one disk and separating by collection keeps URL generation and storage:link simple.
Let your policies drive the flags, never the browser. The permission properties are #[Locked] and resolved in mount(), so pass them from gates at the call site:
<livewire:media-selector
wire:model="gallery"
collection="gallery"
:can-delete="auth()->user()?->can('delete', $post)"
:can-upload="auth()->user()?->can('create', Media::class)"
:restrict-to-current-user="true"
/>
For multi-tenant apps restrict-to-current-user is the difference between a per-account library and a data leak; combine it with a policy that scopes reads to the tenant as well.
Model integration goes through the trait helpers. Attach HasMediaSelector-style integration to the models that own media and use attachMedia, syncMedia, and getMediaUrl rather than hand-rolling pivot logic. syncMedia is your friend for galleries (replace the set in one call, preserving order); attachMedia for append-only use cases like attachments. The relation respects media-selector.model from config, so a custom model subclass keeps working.
React to events instead of polling. The component dispatches Livewire and browser events on upload, delete, and selection. Register a #[On('...')] listener on the parent component to refresh counters, invalidate cache, or kick off image conversions. Read the component source for the exact event names before wiring them — they aren't stable API in the way the attributes are.
Moderation workflow. Soft delete plus canSeeTrash / canRestoreTrash gives you a trash tab for free. Gate the trash tab to admins only; for regular editors pass :can-see-trash="false" so deleted assets simply vanish from their view rather than becoming a confusing purgatory.
Per-feature overrides beat global config. disk, directory, perPage, maxUploadKb, and show-thumbnails can all be set per component. Use this for the one screen that needs 10 MB PDFs while every other picker stays at 2 MB images — don't widen the global config to accommodate an exception.
i18n and RTL come along for the ride. Translations for English, Arabic, and Kurdish ship in the box, and layout keys flip automatically for RTL locales. If you need to change strings, publish the lang files rather than editing vendor.
#[Locked] and resolved once in mount(). You cannot flip canUpload from the frontend, and you equally cannot mutate these from updated() hooks or JavaScript — set them as attributes at render time. That's the security model working as intended, not a bug.vendor:publish --tag=media-selector-assets --force, or you will debug a "styling regression" that is really stale assets.enshrined/svg-sanitize) or serve with Content-Disposition: attachment. Note that an image/* MIME wildcard also matches image/svg+xml, so be explicit with allowed_extensions — extension validation follows that list.Array to string conversion errors at runtime.show_thumbnails=false is partial. It hides the selected-preview strip below Clear/Choose Media but the modal grid still renders thumbnails. If you're disabling thumbnails for performance on a large library, that's not the lever — reduce perPage or show_thumbnails per component.lmsPage. Anything you write that manipulates the page — custom filters, query strings, reset buttons — must use that property name. The package's own trash and delete actions reset it consistently; your code should too.media-selector.table or model, change both sides. The migration, the model, and the trait relation must agree. Changing the table after migrating means republishing and re-running the migration (or an ALTER) — do it in the first hour, not the third week.maxUploadKb is enforced by the component, but PHP's upload_max_filesize and post_max_size sit above it. If uploads fail silently with no validation message, check the PHP ini before the package.media_id written under one collection and read under another will be rejected — the protection is real, but it presents as "my image disappeared" rather than an error.#[Locked] violations surface in the Livewire error payload, not as a flash message; watch the network tab for the failed round-trip. For missing previews, inspect the stored path and disk — preview rendering uses safe URL/MIME helpers now and won't throw on missing files, so broken cards mean stale rows, not a rendering bug. Prune orphaned records with a scheduled command.#[Locked] work and Laravel 13 support; there is no backport path for older frameworks.wire:model belongs to the record you're saving. Validate the selection against your own scoped query before persisting.How can I help you explore Laravel packages today?