anil / file-picker
A WordPress-like file picker component for Laravel Livewire. Supports images, videos, audio, documents and all file types with a beautiful modal interface.
Requires
- php: ^8.2|^8.3|^8.4|^8.5
- illuminate/contracts: ^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/view: ^11.0|^12.0|^13.0
- livewire/livewire: ^3.0|^4.0
- plank/laravel-mediable: ^6.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
README
A WordPress-like file picker component for Laravel Livewire. Supports images, videos, audio, documents, and all file types with a beautiful modal interface.
Features
- 📁 All File Types — Images, videos, audio, documents, spreadsheets, presentations, archives, and code files
- 🎨 Beautiful UI — Modern, clean interface inspired by WordPress media library, with a responsive Sheet-style detail panel on tablet/mobile
- 🔍 Search & Filter — Quick search and filter by file type, folder, tag, or favorite
- 📤 Drag & Drop + Paste — Drag-drop files in, or paste from clipboard
- 🚨 Upload Error Reporting — Validation errors render per-file, browser-side failures (413 / network) surface in the same toast
- ✅ Single / Multiple Selection — Configurable max-files
- 🗑️ Trash & Restore — Soft-delete with recoverable trash + retention-based pruning
- 🔁 Replace File — Update an existing media record's file in place
- 🪪 Hash & Duplicate Detection — SHA-256 dedup with
reuse,reject, orallowstrategies - ⭐ Favorites — Star important items
- 🏷️ Tags — Free-form labels for richer organization
- 📂 Folders — Group media into folders with single & bulk move
- ✏️ Inline Editing — Rename and edit alt text without leaving the picker
- 👤 Ownership Tracking — Auto-record
user_id, optionally scope library per user - 📊 Storage Quotas — Global and per-user storage caps
- 📈 Statistics API — Aggregate counts / sizes / by-type via
FilePicker::getStats() - 📥 Downloads — Force-download single files or bulk download as ZIP
- 🛠️ Console Commands —
file-picker:prune-trash,file-picker:prune-orphans,file-picker:stats - ⚙️ Highly Configurable — Feature toggles, theme colors, custom drivers, custom authorization, custom filters
- 🎯 Form Integration — Works with Livewire components and traditional HTML forms
- ♿ Accessible — Keyboard navigation, focus management, Esc-to-close
Requirements
- PHP 8.2+ (also tested on 8.3 / 8.4)
- Laravel 11.x, 12.x, or 13.x
- Livewire 3.x or 4.x
- plank/laravel-mediable ^6.0 — installed automatically
Installation
1. Install via Composer
composer require anil/file-picker
2. Run the install command
php artisan file-picker:install
This single command will:
- Publish
config/file-picker.php - Run the package migration — an additive migration that adds the package's columns (
folder,tags,is_favorite,hash, etc.) to Plank'smediatable
That's it. Assets (CSS/JS) are served automatically via a built-in route — no need to publish them.
Optional flags
# Overwrite already-published files php artisan file-picker:install --force # Skip running migrations (publish only) php artisan file-picker:install --no-migrate # Also publish blade views for UI customisation php artisan file-picker:install --views # Also publish language files to override text strings php artisan file-picker:install --lang # Publish CSS/JS to public/ (not required — served via route by default) php artisan file-picker:install --assets
3. Add stack slots to your layout
The component pushes its CSS into @stack('head') and its JS into @stack('scripts'). Add these to your layout if not already present:
<!DOCTYPE html> <html> <head> ... @stack('head') </head> <body> ... {{ $slot }} @stack('scripts') </body> </html>
Usage
Basic usage
{{-- Single file selection --}} <livewire:file-picker input-name="featured_image" /> {{-- Multiple file selection --}} <livewire:file-picker input-name="gallery" :multiple="true" :max-files="5" /> {{-- Restrict to specific file types --}} <livewire:file-picker input-name="avatar" :allowed-types="['image']" />
In a Livewire component
namespace App\Livewire; use Livewire\Component; use Livewire\Attributes\On; class PostForm extends Component { public array $selectedMedia = []; #[On('filesSelected')] public function handleFilesSelected(array $selected, string $inputName): void { // $selected is an array of media IDs $this->selectedMedia = $selected; } public function render() { return view('livewire.post-form'); } }
{{-- resources/views/livewire/post-form.blade.php --}} <div> <livewire:file-picker input-name="media" :multiple="true" :max-files="10" :selected="$selectedMedia" :allowed-types="['image', 'video']" /> <p>Selected: {{ count($selectedMedia) }} files</p> </div>
In a traditional HTML form
<form id="my-form" action="/posts" method="POST"> @csrf {{-- Single file — populates a hidden input on selection --}} <livewire:file-picker input-name="featured_image" form-id="my-form" /> {{-- Multiple files — auto-submit form after selection --}} <livewire:file-picker input-name="gallery[]" :multiple="true" :max-files="10" form-id="my-form" :auto-submit="true" /> <button type="submit">Save</button> </form>
With a JavaScript callback
<livewire:file-picker input-name="media" callback-function="onMediaSelected" /> <script> function onMediaSelected(selected, inputName, inputId) { console.log('Selected media:', selected); } </script>
Component Properties
| Property | Type | Default | Description |
|---|---|---|---|
multiple |
bool |
false |
Allow multiple file selection |
maxFiles |
int |
10 |
Maximum number of files that can be selected |
selected |
array |
[] |
Pre-selected media IDs |
allowedTypes |
array |
[] |
Restrict to specific file types (empty = all) |
inputName |
string |
'files' |
Name attribute for the hidden input(s) |
inputId |
string |
auto | ID attribute for the hidden input |
formId |
string |
'' |
Form ID to target for auto-submit |
autoSubmit |
bool |
false |
Auto-submit the form after selection |
callbackFunction |
string |
'' |
Global JS function name called after selection |
buttonLabel |
string |
auto | Override the trigger button label |
showPreview |
bool |
true |
Show selected file previews below the button |
perPage |
int |
24 |
Items per page in the media library |
Allowed File Types
Restrict the picker to one or more types via allowedTypes:
<livewire:file-picker :allowed-types="['image', 'document']" />
| Type | Extensions |
|---|---|
image |
jpg, jpeg, png, gif, webp, svg, bmp, ico, tiff, avif |
video |
mp4, webm, ogg, mov, avi, mkv, wmv, flv, m4v |
audio |
mp3, wav, aac, ogg, flac, m4a, wma, aiff |
document |
pdf, doc, docx, txt, rtf, odt, md, epub |
spreadsheet |
xls, xlsx, csv, ods, numbers |
presentation |
ppt, pptx, odp, key |
archive |
zip, rar, 7z, tar, gz, bz2, xz |
code |
js, ts, php, html, css, json, yaml, vue, jsx, tsx, py, go, rs, etc. |
You can customize extensions per type in config/file-picker.php under extensions.
Events
JavaScript event
Fired on the window after the user confirms their selection:
window.addEventListener('file-picker:selected', (event) => { const { selected, inputName, inputId } = event.detail; console.log('Selected media:', selected); });
Each item in selected is an object with: id, url, filename, size, extension, file_type, alt, created_at.
Livewire events
Two Livewire events fire every time the selection changes. Pick the one that fits your handler shape:
filesSelected — named arguments, easiest for typed signatures:
#[On('filesSelected')] public function onFilesSelected(array $selected, string $inputName): void { // $selected is an array of media IDs (int) // $inputName is the picker's `input-name` prop (route by it if you have multiple pickers on one page) $this->selectedIds = $selected; }
file-picker-selected — single array payload with the full picker context (used by the bundled JS callback support):
#[On('file-picker-selected')] public function onFilePickerSelected(array $payload): void { // $payload keys: selected, inputName, inputId, formId, multiple, autoSubmit, callbackFunction $this->selectedIds = $payload['selected']; }
If you have several pickers on the same page, switch on
$inputName(or$payload['inputName']) to route the selection to the right property.
Upload Errors
Upload problems are surfaced to the UI on three levels:
- Server-side validation (size, mime type) —
uploadFiles()runs the configuredmimes/maxrules and any failures are rendered:- as a toast at the top of the upload tab (
uploadStatus = 'error'), and - as a per-file list (
{original_filename}: {message}) below the toast.
- as a toast at the top of the upload tab (
- Per-file driver failures during processing —
DuplicateMediaException,StorageQuotaExceededException,UploadFailedException, and any otherThrowablethrown by the driver are caught and aggregated into the same toast (e.g. "2 uploaded, 1 failed"). - Browser-side failures — the bundled JS listens for
livewire-upload-erroron the picker's file input and forwards the HTTP status to the component viasetUploadError(string $message). Common cases are mapped automatically:413→ "the file is larger than the server allows"422→ "the file did not pass validation"- other status codes → generic "Upload failed (HTTP …)"
Error toasts are sticky — they don't auto-clear like success messages do. The user dismisses them with the × button (or any new upload action resets the state).
If you want to push your own error into the toast from a parent component or hook, call setUploadError:
$this->dispatch('refresh-file-picker'); // example // or, from inside your custom driver / extension: $this->setUploadError('Quota exceeded — contact your administrator.');
Tablet & Mobile
The library tab uses a side-by-side layout on desktop (≥1025px) with the Attachment Details panel always visible. On tablet/mobile (≤1024px), the panel becomes a right-side sheet that's collapsed by default — tapping an item just selects it.
To open the details sheet on touch devices, an edit icon appears on each grid item (top-left, right after the selection checkbox). Tapping it:
- promotes the item to the active selection (without toggling existing selections off),
- dispatches
open-details, which slides the sheet in.
The icon is hidden on desktop where the sidebar is always inline. You can rename the button via the texts.view_details config key (or the published lang file).
Drivers
Plank driver (default)
The package is built on top of plank/laravel-mediable — installed automatically as a hard dependency. The bundled FilePickerMedia model extends Plank's Media model and the install migration adds the extra columns we need (folder, tags, is_favorite, hash, width, height, duration, user_id, download_count, custom_properties, deleted_at) to Plank's media table.
Defaults:
FILE_PICKER_DRIVER=plank FILE_PICKER_DISK=public FILE_PICKER_DIRECTORY=media
Using a non-public disk? Plank's
mediable.allowed_disksconfig defaults to['public']only. To uses3or another disk, publish Plank's config (php artisan vendor:publish --tag=mediable-config) and add your disk toallowed_disks.
Custom driver
Implement Anil\LivewireFilePicker\Contracts\MediaDriverInterface (or extend Anil\LivewireFilePicker\Drivers\AbstractDriver) and register the FQCN as the driver:
// config/file-picker.php 'driver' => \App\Media\MyCustomDriver::class,
Authorization
By default all actions are permitted. To add authorization, implement FilePickerAuthorizationInterface:
namespace App\Auth; use Anil\LivewireFilePicker\Contracts\FilePickerAuthorizationInterface; class MediaAuthorization implements FilePickerAuthorizationInterface { public function canViewLibrary(): bool { return auth()->check(); } public function canUpload(): bool { return auth()->user()?->can('upload-media') ?? false; } public function canDelete(int $mediaId): bool { return auth()->user()?->can('delete-media') ?? false; } public function canEditAlt(int $mediaId): bool { return auth()->check(); } }
Register it in the config:
// config/file-picker.php 'authorization_class' => \App\Auth\MediaAuthorization::class,
Custom Filters
Add custom filter controls to the media library toolbar. Two parts are required:
1. Define the UI controls in config:
// config/file-picker.php 'ui' => [ 'custom_filters' => [ [ 'name' => 'tag', 'label' => 'Tag', 'type' => 'select', // select | text | checkbox | date_range 'placeholder' => 'All Tags', 'options' => [ '' => 'All Tags', 'nature' => 'Nature', 'urban' => 'Urban', ], ], [ 'name' => 'featured', 'label' => 'Featured Only', 'type' => 'checkbox', ], ], 'custom_filter_class' => \App\Filters\MediaFilter::class, ],
2. Implement the filter class:
namespace App\Filters; use Anil\LivewireFilePicker\Contracts\CustomFilter; use Illuminate\Database\Eloquent\Builder; class MediaFilter implements CustomFilter { public function apply(Builder $query, array $filters): Builder { if (!empty($filters['tag'])) { $query->where('tag', $filters['tag']); } if (!empty($filters['featured'])) { $query->where('featured', true); } return $query; } }
Configuration Reference
Publish the config to customize everything:
php artisan vendor:publish --tag=file-picker-config
Driver
'driver' => env('FILE_PICKER_DRIVER', 'plank'), // 'plank' | CustomDriver::class 'drivers' => [ 'plank' => [ 'model' => FilePickerMedia::class, // extends Plank\Mediable\Media 'disk' => env('FILE_PICKER_DISK', 'public'), 'directory' => env('FILE_PICKER_DIRECTORY', 'media'), 'visibility' => env('FILE_PICKER_VISIBILITY', 'public'), ], ],
Upload limits
'max_file_size' => env('FILE_PICKER_MAX_SIZE', 102400), // KB (default: 100 MB)
Defaults
'defaults' => [ 'multiple' => false, 'max_files' => 40, 'per_page' => 24, 'show_preview' => true, ],
Sorting
'sorting' => [ 'field' => 'created_at', // created_at | filename | size | extension 'direction' => 'desc', // asc | desc ],
Feature toggles
'features' => [ 'upload' => true, 'delete' => true, 'bulk_delete' => true, 'edit_alt' => true, 'rename' => true, 'search' => true, 'filter' => true, 'sorting' => true, 'drag_drop' => true, 'refresh' => true, 'keyboard_navigation' => true, 'paste_upload' => true, ],
UI / Theme
'ui' => [ 'modal_style' => 'fullscreen', // 'fullscreen' | 'centered' 'thumbnail_height' => 150, // px 'show_type_badges' => true, 'show_file_size' => true, 'show_date' => true, 'colors' => [ 'primary' => '#0073aa', 'primary_hover' => '#005a87', 'danger' => '#ef4444', 'success' => '#10b981', 'warning' => '#f59e0b', ], 'font_family' => "'Inter', sans-serif", 'border_radius' => 8, // px 'grid_min_width' => 160, // px 'grid_gap' => 14, // px 'sidebar_width' => 300, // px 'backdrop_blur' => 12, // px 'backdrop_opacity' => 0.6, 'z_index' => 9999, 'upload_preview_size' => 120, // px 'upload_area_max_height'=> 400, // px 'filter_types' => ['image', 'document', 'video', 'audio', 'spreadsheet', 'presentation'], 'custom_filters' => [], 'custom_filter_class' => '', ],
Text strings
All text in the UI is configurable and can also be translated via published lang files:
php artisan file-picker:install --lang
# or
php artisan vendor:publish --tag=file-picker-lang
'texts' => [ 'modal_title' => 'Media Library', 'tab_upload' => 'Upload Files', 'tab_library' => 'Media Library', 'drop_zone' => 'Drop files here or click to upload', 'search_placeholder' => 'Search media...', 'no_items' => 'No media found', 'insert_button' => 'Insert Selected', 'delete_confirm' => 'Are you sure you want to delete this file?', 'view_details' => 'View details', // label for the tablet/mobile edit icon 'sidebar_title' => 'Attachment Details', 'close_details' => 'Close details', // ... see config/file-picker.php for the full list ],
Route middleware
'route_middleware' => ['web'],
The package registers a route at /vendor/anil/livewire-file-picker/{file} to serve CSS/JS assets. This route is protected by the middleware listed here.
Customising Views
Publish the blade views to override the UI:
php artisan file-picker:install --views
# or
php artisan vendor:publish --tag=file-picker-views
Views will be published to resources/views/vendor/file-picker/.
API Reference
FilePicker Component Methods
| Method | Description |
|---|---|
openModal() / closeModal() |
Open / close the modal |
setViewMode('library'|'trash') |
Switch between active library and trash |
toggleSelection($id) |
Toggle selection of a media item |
viewDetails($id) |
Promote item to active and open the details panel (used by the tablet/mobile edit icon) |
clearSelection() |
Clear all selected items |
insertSelected() |
Confirm selection and close modal |
uploadFiles() |
Upload pending files |
setUploadError($message) |
Push an error message into the upload toast (also called from the JS upload-error hook) |
deleteMedia($id) |
Soft-delete a media item (move to trash) |
restoreMedia($id) |
Restore from trash |
forceDeleteMedia($id) |
Permanently delete (and remove file from disk) |
bulkDelete($ids) |
Soft-delete many at once |
toggleFavorite($id) |
Toggle favorite |
addTag() / removeTag($id, $tag) |
Manage tags on a media item |
startMoving($id) + saveMove() |
Move a media item to a folder |
bulkMoveToFolder($ids, $folder) |
Move many at once |
startReplacing($id) |
Replace the underlying file (next upload swaps it) |
refreshMedia() |
Reload media items |
clearFilters() |
Reset search/type/folder/tag/favorite filters |
FilePicker Facade
Outside the component, drive the library directly:
use Anil\LivewireFilePicker\Facades\FilePicker; FilePicker::upload($temporaryFile, ['folder' => 'reports', 'tags' => ['q1', 'finance']]); FilePicker::replaceFile($id, $newFile); FilePicker::toggleFavorite($id); FilePicker::addTag($id, 'archive-2024'); FilePicker::moveToFolder($id, 'archive/2024'); FilePicker::restore($id); FilePicker::forceDelete($id); FilePicker::getStats(); // counts, sizes, by_type, favorites_count, trashed_count FilePicker::findByHash($sha256); // dedup lookups
Console Commands
php artisan file-picker:prune-trash --days=30 --dry-run php artisan file-picker:prune-orphans --dry-run php artisan file-picker:stats
Download Routes
| Route | Purpose |
|---|---|
GET /file-picker/download/{id} |
Force-download a single file |
GET /file-picker/download-zip?ids[]= |
Stream a zip of selected media |
Computed Properties
| Property | Type | Description |
|---|---|---|
selectedMediaItems |
array | Full details of selected media |
hasSelection |
bool | Whether any items are selected |
selectionLabel |
string | Human-readable selection count |
selectedCount |
int | Number of selected items |
Versioning
This package follows Semantic Versioning. While we're in the 0.x line the public API may change between minor releases — pin to a specific 0.x range (e.g. ^0.1) rather than across them.
composer require anil/file-picker:^0.1
Note on
^0.1: Composer expands this to>=0.1.0, <0.2.0. When0.2.0lands you'll need to bump the constraint deliberately (which is the intended behaviour for pre-1.0 packages).
Static Analysis
This package uses PHPStan with Larastan at level 8.
composer analyse
Contributing
Contributions are welcome! Please open an issue or pull request.
License
The MIT License (MIT). Please see License File for more information.