codenzia / filament-media
A powerful media manager plugin for Filament v4 with drag-and-drop uploads, folder organization, image editing, and thumbnail generation.
Fund package maintenance!
codenzia
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
pkg:composer/codenzia/filament-media
Requires
- php: ^8.2
- ext-gd: *
- ext-zip: *
- filament/filament: ^4.0
- intervention/image: ^3.11
- spatie/laravel-package-tools: ^1.17.0
Requires (Dev)
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
This package is auto-updated.
Last update: 2026-02-28 22:06:52 UTC
README
A full-featured Digital Asset Management plugin for Filament v4. Upload, organize, tag, version, and serve media files across local and cloud storage — with a modern UI, fine-grained access control, and a developer-friendly service architecture.
Features
File Management — Drag-and-drop uploads with progress tracking, chunked uploads for large files, upload from URL (with SSRF protection), multi-file selection, batch operations, copy/move/rename, alt text, automatic thumbnails with optional watermarks.
Folders — Nested folder structure with unlimited depth, color-coded folders, drag-and-drop organization, automatic folder resolution from file paths.
Tags & Collections — Tag files and folders, organize into named collections, filter and search by tags, bulk tagging, popular tags with usage counts.
Custom Metadata — Define custom fields (text, number, date, select, boolean, URL), attach to files, search and filter by metadata, auto-extract EXIF data.
Search — Database search out of the box, optional Laravel Scout integration, search by name, tags, metadata, file type, and date range.
Versioning — Upload new versions, view history with changelogs, revert to any previous version, configurable retention with auto-prune.
Export & Import — Export as ZIP with metadata manifest, import from ZIP or local folder with automatic metadata restoration.
Organization — Favorites, recent items, type filters (image, video, document, audio, archive), sort by name/date/size, grid and list views, breadcrumb navigation.
Trash & Recovery — Soft delete with trash folder, restore, permanent delete.
Preview — Full-screen gallery modal with version history, image/video/audio preview, document preview (PDF, Office via Google/Microsoft viewers), keyboard navigation.
UI — Responsive design, dark mode, configurable theme colors, context menu, details panel, drag-and-drop between folders, multi-select with Ctrl/Cmd and Shift.
Visibility & Access Control — Per-file public/private visibility, HMAC-SHA256 hash verification for private URLs, custom authorization callbacks, automatic file movement between storage disks, per-user media scoping.
Cloud Storage — Local, Amazon S3, Cloudflare R2, DigitalOcean Spaces, Wasabi, Backblaze B2, BunnyCDN.
Developer Tools — 15 singleton services with DI, 16 Laravel events, MediaFileUpload and MediaPickerField form components, MediaFileGrid / MediaFileList / MediaFiles embeddable Livewire components, FilesUploadWidget, HasMediaFiles and InteractsWithMediaCollections traits, MediaAdder fluent builder, typed exceptions, query scopes, per-panel page visibility, configurable navigation.
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Arrow Keys |
Navigate between items |
Enter |
Open folder or preview file |
Space |
Toggle selection |
Ctrl/Cmd+A |
Select all |
Delete |
Move to trash |
F2 |
Rename |
Escape |
Clear selection / Close preview |
Arrow Left/Right |
Previous/next in preview |
Requirements
- PHP 8.2+
- Laravel 11+
- Filament 4.0+
- GD or Imagick PHP extension (for thumbnails)
- ZIP PHP extension (for export/import and multi-file download)
Installation
composer require codenzia/filament-media
Publish and run migrations:
php artisan vendor:publish --tag="filament-media-migrations"
php artisan migrate
Publish the config:
php artisan vendor:publish --tag="filament-media-config"
Optionally publish views:
php artisan vendor:publish --tag="filament-media-views"
Setup
Register the Plugin
use Codenzia\FilamentMedia\FilamentMediaPlugin; public function panel(Panel $panel): Panel { return $panel ->plugins([ FilamentMediaPlugin::make(), ]); }
Control which pages are registered per panel:
// Admin panel — full access FilamentMediaPlugin::make(), // User dashboard — picker only, no standalone pages FilamentMediaPlugin::make() ->showMediaManager(false) ->showSettings(false),
Storage Link
For local storage:
php artisan storage:link
Custom Theme (Tailwind v4)
If your panel uses a custom theme (->viteTheme()), add these @source directives to your theme CSS so Tailwind discovers the package's utility classes:
@source '../../../../vendor/codenzia/filament-media/resources/views/**/*.blade.php'; @source '../../../../vendor/codenzia/filament-media/src/**/*.php';
Then rebuild: npm run build
This is only needed with custom themes. Filament's default theme works without changes.
Configuration
The config file (config/media.php) provides full control over all options.
Feature Flags
'features' => [ 'tags' => true, 'collections' => true, 'metadata' => true, 'versioning' => true, 'search' => true, 'export_import' => true, ],
Storage Driver
'driver' => 'public', // 'public', 's3', 'r2', 'do_spaces', 'wasabi', 'bunnycdn', 'backblaze'
Navigation
'navigation' => [ 'media' => [ 'label' => null, // null = use translation key 'icon' => 'heroicon-o-photo', 'group' => null, 'sort' => 1, 'visible' => true, ], 'settings' => [ 'label' => null, 'icon' => 'heroicon-o-cog-6-tooth', 'group' => null, 'sort' => 2, 'visible' => true, ], ],
Theme Colors
Colors are injected as CSS custom properties (--fm-*) with separate light and dark mode values:
'theme' => [ 'light' => [ 'primary' => '#6366f1', 'surface' => '#ffffff', 'border' => '#e5e7eb', 'text' => '#111827', // ... see config for all options ], 'dark' => [ 'primary' => '#818cf8', 'surface' => '#111827', // ... ], ],
Upload Limits
'max_file_size' => 10 * 1024 * 1024, // 10MB 'allowed_mime_types' => 'jpg,jpeg,png,gif,pdf,doc,docx,...', 'allowed_download_domains' => [], // empty = all domains allowed for URL uploads
Thumbnails & Watermarks
'sizes' => ['thumb' => '150x150'], 'generate_thumbnails_enabled' => true, 'watermark' => [ 'enabled' => false, 'source' => null, 'size' => 10, 'opacity' => 70, 'position' => 'bottom-right', ],
Private Files
'private_files' => [ 'enabled' => true, 'signed_url_expiry' => 30, // minutes (cloud temporary URLs) 'private_disk' => 'local', // storage disk for private files ],
Search & Versioning
'search' => ['driver' => 'database', 'min_query_length' => 2], // or 'scout' 'versioning' => ['max_versions' => 10, 'auto_prune' => true],
Usage
Media Manager Page
Available at /admin/media (or your panel prefix + /media).
Programmatic Access
All operations use dedicated service classes:
use Codenzia\FilamentMedia\Services\UploadService; use Codenzia\FilamentMedia\Services\FileOperationService; use Codenzia\FilamentMedia\Services\MediaUrlService; use Codenzia\FilamentMedia\Services\TagService; use Codenzia\FilamentMedia\Exceptions\MediaUploadException; // Upload a file try { $file = app(UploadService::class)->handleUpload($uploadedFile, $folderId); } catch (MediaUploadException $e) { // Handle: invalidFileType, fileTooLarge, unableToWrite, etc. } // Get URL, copy, tag $url = app(MediaUrlService::class)->url($file->url); $copy = app(FileOperationService::class)->copyFile($file, $targetFolderId); app(TagService::class)->attachTags($file, ['nature', 'landscape']);
Upload methods return MediaFile directly and throw MediaUploadException on failure.
File Visibility & Access Control
Files have a visibility attribute — public (default) or private. Public files are served via direct storage URL. Private files are served through an authenticated controller with HMAC-SHA256 hash verification.
Changing Visibility
$fileOps = app(FileOperationService::class); $fileOps->changeVisibility($file, 'private'); // moves to private disk $fileOps->changeVisibility($file, 'public'); // moves back
On local storage, files and thumbnails are physically moved between disks.
How Private URLs Work
Public files: /storage/photos/image.jpg
Private files: /media/private/{hash}/{id}
The hash is an HMAC-SHA256 of the file ID, keyed to APP_KEY. URLs cannot be guessed or enumerated without the secret key. The controller verifies authentication and authorization before streaming. Append ?download=1 to force download.
Custom Authorization
use Codenzia\FilamentMedia\FilamentMedia; use Codenzia\FilamentMedia\Models\MediaFile; // In a service provider's boot() method: app(FilamentMedia::class)->authorizeFileAccessUsing(function (MediaFile $file, $user) { return $user && $file->user_id === $user->id; });
The callback receives the MediaFile and the authenticated user (or null). Return true to allow, false to deny. Public files bypass the callback.
Per-User Media Scoping
Control which files each user sees in the Media page:
app(FilamentMedia::class)->scopeMediaQueryUsing(function ($query, $user) { $query->where('media_files.created_by_user_id', $user->id); });
The callback applies a global scope on MediaFile and MediaFolder queries across all views (media, trash, recent, favorites, collections). Not invoked when no user is authenticated.
scopeMediaQueryUsing()controls query-level filtering (what appears in the UI), whileauthorizeFileAccessUsing()controls file-level download authorization. Use both together for complete access control.
Artisan Commands
php artisan media:cleanup # Remove DB entries for missing files php artisan media:cleanup --dry-run # Preview changes php artisan media:cleanup --force # Skip confirmation
Form Components
MediaFileUpload
Pre-configured FileUpload that reads settings from config/media.php:
use Codenzia\FilamentMedia\Forms\MediaFileUpload; MediaFileUpload::make(), // inherits all config settings MediaFileUpload::make('avatars'), // upload to specific directory
Automatically resolves MIME types, respects max file size and server limits, uses the configured storage disk, and preserves original filenames.
MediaPickerField
File picker for selecting existing media:
use Codenzia\FilamentMedia\Forms\MediaPickerField; MediaPickerField::make('featured_image') ->imageOnly() ->required(), MediaPickerField::make('attachments') ->multiple() ->maxFiles(10), MediaPickerField::make('contracts') ->documentOnly() ->directory('contracts') ->collection('legal'),
Direct Upload
By default, the field shows a single "Browse Media" button that opens the full media library picker. Enable directUpload() to add a quick "Upload File" option alongside it — the button becomes a dropdown with both choices:
MediaPickerField::make('featured_image') ->imageOnly() ->directUpload(),
The upload zone supports drag-and-drop and uses the same upload endpoint as the media library. Uploaded files are saved to the root folder and the field state is updated automatically.
To enable direct upload globally for all MediaPickerField instances, set the config default:
// config/media.php 'picker' => [ 'direct_upload' => true, ],
You can still override the global default per-field:
// Disable direct upload for a specific field even when the global default is true MediaPickerField::make('logo')->directUpload(false),
Per-Field File Type Control
By default, uploads are validated against the global allowed_mime_types in config/media.php. Two methods let you customize this per field:
// Add extra extensions to the global list for this field only // (e.g., .ico is not in the global list, but favicons need it) MediaPickerField::make('favicon') ->imageOnly() ->includeFileTypes(['ico']), // Restrict to ONLY these extensions, ignoring the global config entirely MediaPickerField::make('contract') ->allowedFileTypesOnly(['pdf', 'docx']),
Both methods enforce validation on both client-side (browser) and server-side (upload endpoint). Server-side overrides are protected with HMAC-SHA256 signatures to prevent tampering.
Display Styles
The field supports five visual styles via displayStyle():
// Compact (default): Text links for browse/upload with chip-style file list MediaPickerField::make('document') ->documentOnly() ->displayStyle('compact'), // Dropdown: Button with dropdown menu for browse/upload options MediaPickerField::make('document') ->documentOnly() ->directUpload() ->displayStyle('dropdown'), // Thumbnail: Visual preview card — click to browse, hover for actions, drag & drop MediaPickerField::make('featured_image') ->imageOnly() ->displayStyle('thumbnail'), // Integrated Links: Thumbnail preview + text links below, drag & drop MediaPickerField::make('avatar') ->imageOnly() ->displayStyle('integratedLinks'), // Integrated Dropdown: Thumbnail preview + dropdown button below, drag & drop MediaPickerField::make('logo') ->imageOnly() ->directUpload() ->displayStyle('integratedDropdown'),
| Style | Best for | Drag & Drop | Description |
|---|---|---|---|
compact |
Documents, mixed files | No | Text links + chip list with small icons |
dropdown |
Documents, mixed files | No | Dropdown button + chip list with small icons |
thumbnail |
Images, visual content | Yes | Large preview card, hover overlay with change/remove actions |
integratedLinks |
Images + text links | Yes | Thumbnail preview area with text links below |
integratedDropdown |
Images + dropdown button | Yes | Thumbnail preview area with dropdown button below |
To set a global default for all fields:
// config/media.php 'picker' => [ 'display_style' => 'integratedLinks', ],
Per-field values always override the config default.
Preview Size
Control the dimensions of the thumbnail preview container (used by thumbnail, integratedLinks, and integratedDropdown styles). The image itself always maintains its natural aspect ratio via object-contain.
// Square 256px (aspect-square kept when only width is set) MediaPickerField::make('logo') ->displayStyle('integratedDropdown') ->previewWidth('16rem'), // Rectangle 256x128px (aspect-square removed when height is set) MediaPickerField::make('banner') ->displayStyle('integratedDropdown') ->previewWidth('16rem') ->previewHeight('8rem'), // Only change height, keep default width MediaPickerField::make('icon') ->displayStyle('thumbnail') ->previewHeight('6rem'),
Default: 12rem width with aspect-square (192x192px). Any CSS length value works (rem, px, %, etc.). Global defaults can be set in config:
// config/media.php 'picker' => [ 'preview_width' => '12rem', // CSS value, e.g. '12rem', '256px' 'preview_height' => null, // null = aspect-square, or e.g. '8rem' ],
Chip Size
Control the size of the file chips used in compact and dropdown display styles. This affects the thumbnail size, icon size, and text size within each chip.
MediaPickerField::make('avatar') ->displayStyle('dropdown') ->chipSize('lg'), // 64px thumbnails, 16px text MediaPickerField::make('documents') ->displayStyle('compact') ->chipSize('xs'), // 20px thumbnails, 12px text
Available sizes:
| Size | Thumbnail | Text | Description |
|---|---|---|---|
xs |
20px | 12px | Tiny — minimal footprint |
sm |
32px | 14px | Small — default |
md |
48px | 14px | Medium — easier to see previews |
lg |
64px | 16px | Large — prominent file display |
xl |
80px | 18px | Extra large — visual emphasis |
2xl |
96px | 20px | Huge — maximum preview size |
Global default can be set in config:
// config/media.php 'picker' => [ 'chip_size' => 'sm', // 'xs', 'sm', 'md', 'lg', 'xl', '2xl' ],
Lightbox Size
Control the maximum dimensions of the full-screen image preview (lightbox) that appears when clicking a thumbnail. By default, the image fills the available viewport.
// Constrain the lightbox to a smaller area MediaPickerField::make('avatar') ->displayStyle('thumbnail') ->lightboxMaxWidth('600px') ->lightboxMaxHeight('400px'),
Global defaults can be set in config:
// config/media.php 'picker' => [ 'lightbox_max_width' => null, // null = full viewport, or e.g. '800px', '50vw' 'lightbox_max_height' => null, // null = full viewport, or e.g. '600px', '80vh' ],
Lightbox Opacity
Control the backdrop opacity of the lightbox overlay. The value is a percentage from 0 (fully transparent) to 100 (fully opaque). Default: 80.
// More opaque backdrop MediaPickerField::make('avatar') ->displayStyle('thumbnail') ->lightboxOpacity(95),
Global default can be set in config:
// config/media.php 'picker' => [ 'lightbox_opacity' => 80, // 0 = transparent, 100 = fully opaque ],
| Method | Description |
|---|---|
multiple() |
Allow selecting multiple files |
imageOnly() |
Restrict to images |
videoOnly() |
Restrict to videos |
documentOnly() |
Restrict to documents |
acceptedFileTypes(array) |
Custom MIME types for picker filtering |
includeFileTypes(array) |
Add extra file extensions to the global allowed list for this field |
allowedFileTypesOnly(array) |
Restrict uploads to ONLY these file extensions (ignores global config) |
maxFiles(int) |
Limit selections |
directory(string) |
Default upload directory |
collection(string) |
Auto-assign collection |
directUpload(bool) |
Show inline upload option alongside media browser (default: false, or from config) |
displayStyle(string) |
Visual style: 'compact', 'dropdown', 'thumbnail', 'integratedLinks', or 'integratedDropdown' (default: 'compact', or from config) |
previewWidth(string) |
Preview container width as CSS value, e.g. '16rem', '256px' (default: '12rem', or from config) |
previewHeight(string) |
Preview container height as CSS value, e.g. '8rem', '128px'. Setting height removes aspect-square (default: null / aspect-square, or from config) |
chipSize(string) |
Chip size preset: 'xs', 'sm', 'md', 'lg', 'xl', '2xl'. Controls thumbnail, icon, and text size in compact/dropdown styles (default: 'sm', or from config) |
lightboxMaxWidth(string) |
Lightbox image max width as CSS value, e.g. '800px', '50vw' (default: null / full viewport, or from config) |
lightboxMaxHeight(string) |
Lightbox image max height as CSS value, e.g. '600px', '80vh' (default: null / full viewport, or from config) |
lightboxOpacity(int) |
Lightbox backdrop opacity as percentage 0–100 (default: 80, or from config) |
Livewire Components
MediaFileGrid
Displays a grid of media files with full context menu. The parent model must use HasMediaFiles:
{{-- Single model --}} <livewire:filament-media::media-file-grid :record="$record" /> <livewire:filament-media::media-file-grid :record="$record" :deletable="true" /> {{-- Multi-model mode --}} <livewire:filament-media::media-file-grid fileable-type="App\Models\Project" :fileable-ids="[1, 2, 3]" />
MediaFileList
Same features as MediaFileGrid in a table/list layout:
<livewire:filament-media::media-file-list :record="$record" :deletable="true" />
MediaFiles
Unified viewer with a toggle between grid and list layouts:
<livewire:filament-media::media-files :record="$record" :deletable="true" /> {{-- Disable layout toggle --}} <livewire:filament-media::media-files :record="$record" :show-layout-toggle="false" layout="list" />
Shared Props
All three components accept the same props:
| Prop | Type | Default | Description |
|---|---|---|---|
record |
Model|null | null |
Parent model (single-model mode) |
relationship |
string | 'files' |
Relationship method name |
fileableType |
string|null | null |
Morph class (multi-model mode) |
fileableIds |
array | [] |
Model IDs (multi-model mode) |
deletable |
bool | false |
Enable trash/delete |
columns |
string | responsive grid | Tailwind grid classes |
emptyMessage |
string | 'No files attached' |
Empty state message |
contextMenu |
bool | true |
Enable right-click menu |
contextMenuExclude |
array | [] |
Menu items to hide |
Context menu keys: preview, download, copy_link, view_parent, rename, alt_text, tags, collections, versions, metadata, export, visibility, favorites, trash.
FilesUploadWidget
Filament widget for adding uploads to any resource page:
use Codenzia\FilamentMedia\Widgets\FilesUploadWidget; protected function getFooterWidgets(): array { return [ FilesUploadWidget::make([ 'record' => $this->record, 'directory' => 'project-files', 'submitLabel' => 'Upload Files', 'submitColor' => 'success', 'submitAlignment' => 'center', 'visibility' => 'public', ]), ]; }
Attaching Media to Models
Setup
use Codenzia\FilamentMedia\Traits\HasMediaFiles; class Product extends Model { use HasMediaFiles; }
Uploading
$product->addMedia($uploadedFile)->save(); $product->addMedia($uploadedFile) ->usingName('Product Photo') ->toCollection('gallery') ->save(); $product->addMediaFromUrl('https://example.com/photo.jpg') ->withAlt('Product hero image') ->toCollection('gallery') ->toFolder($folderId) ->save(); $product->addMedia('/path/to/file.jpg') ->usingName('Local import') ->save();
Attaching & Detaching
$product->attachMediaFile($mediaFile); $product->attachMediaFiles($mediaFiles); $product->attachMediaWithMeta($mediaFile, ['alt' => 'Product photo']); $product->syncMediaFiles($mediaFiles); $product->syncMediaByIds([1, 2, 3]); $product->detachMediaFile($mediaFile); $product->detachAllMediaFiles(); $product->deleteMediaFile($fileId);
Querying
$product->files; $product->images; $product->videos; $product->documents; $product->audio; $product->mediaByCollection('gallery')->get(); $product->mediaByTag('featured')->get(); $url = $product->getFirstImageUrl(); $url = $product->getFirstImageUrl('/images/placeholder.jpg'); $file = $product->getFirstMediaFile(); $urls = $product->getMediaUrls('gallery'); $product->hasMedia(); $product->hasImages(); $product->clearMedia('gallery');
URL Attributes on MediaFile
$file->preview_url; // Full public URL for display $file->indirect_url; // Controller-routed URL with hash verification $file->url; // Raw relative storage path
Physical File Deletion
$file->deleteWithFile(); // deletes file + soft-deletes record $file->deleteWithFile('Deleted!', 'Failed to delete'); // with Filament notifications $product->deleteMediaFile($fileId); // verifies ownership first
Named Media Collections
For models that need structured, constrained media:
use Codenzia\FilamentMedia\Traits\HasMediaFiles; use Codenzia\FilamentMedia\Traits\InteractsWithMediaCollections; class User extends Model { use HasMediaFiles, InteractsWithMediaCollections; public function registerMediaCollections(): void { $this->addMediaCollection('avatar') ->singleFile() ->acceptsMimeTypes(['image/*']) ->useFallbackUrl('/images/default-avatar.png'); $this->addMediaCollection('gallery') ->onlyKeepLatest(20); $this->addMediaCollection('documents') ->acceptsMimeTypes(['application/pdf', 'application/msword']); } }
| Method | Description |
|---|---|
singleFile() |
One file only — new uploads auto-detach the previous |
onlyKeepLatest(int) |
Keep at most N files, auto-prune oldest |
acceptsMimeTypes(array) |
Restrict MIME types (supports image/* wildcards) |
useFallbackUrl(string) |
URL returned when collection is empty |
$user->addMedia($file)->toCollection('avatar')->save(); $avatarUrl = $user->getFirstCollectionUrl('avatar'); $user->validateCollectionMimeType('avatar', 'image/jpeg'); // true
Collections build on the tag system (MediaTag with type='collection'). No additional migrations needed.
Error Handling
use Codenzia\FilamentMedia\Exceptions\MediaUploadException; try { $file = $product->addMedia($uploadedFile)->save(); } catch (MediaUploadException $e) { logger()->error('Upload failed: ' . $e->getMessage()); }
| Method | When Thrown |
|---|---|
invalidFileType() |
MIME type not in allowed list |
fileTooLarge(string $size) |
Exceeds configured max size |
unableToWrite(string $folder) |
Storage write failed |
networkError(string $url) |
URL download failed |
ssrfBlocked(string $message) |
URL targets internal network |
invalidUrl() |
Malformed or empty URL |
invalidPath() |
Local file path doesn't exist |
noFileDetected() |
Could not detect file type |
tempFileError() |
Temp file creation failed |
Automatic Folder Resolution
When creating a MediaFile without an explicit folder_id, the folder is resolved from the url path:
// Creates "Avatars" folder automatically MediaFile::create([ 'url' => 'avatars/photo.jpg', 'name' => 'Profile Photo', 'mime_type' => 'image/jpeg', 'size' => 12345, 'visibility' => 'public', 'user_id' => $user->id, ]); // Nested: creates Products > Gallery MediaFile::create(['url' => 'products/gallery/photo.jpg', ...]); // Explicit folder_id skips auto-resolution MediaFile::create(['url' => 'avatars/photo.jpg', 'folder_id' => $id, ...]);
Auto-resolution only runs when folder_id is 0 or not set. Uses firstOrCreate() internally, so concurrent uploads are safe.
Tags & Collections
$tagService = app(TagService::class); $tagService->attachTags($file, ['nature', 'landscape']); $tagService->syncTags($file, ['nature', 'updated']); $tagService->detachTags($file, [$tagId]); $popular = $tagService->getPopularTags(20); $tagService->mergeTags([$sourceId1, $sourceId2], $targetId); // Collections (special tags with type='collection') $collection = $tagService->createCollection('Hero Banners', 'Homepage banners'); $tagService->addToCollection($collection->id, [$fileId1, $fileId2]); $files = $tagService->getCollectionContents($collection->id); // Query scopes $files = MediaFile::tagged([$tagId1, $tagId2])->get(); $files = MediaFile::inCollection($collectionId)->get();
Custom Metadata
$metadata = app(MetadataService::class); $metadata->createField([ 'name' => 'Copyright', 'slug' => 'copyright', 'type' => 'text', 'is_searchable' => true, ]); $metadata->setMetadata($file, [$fieldId => '2025 Acme Inc.']); $value = $metadata->getMetadataValue($file, 'copyright'); // Query scope $files = MediaFile::withMetadataValue('license', 'MIT')->get();
Search
$search = app(SearchService::class); $results = $search->search('annual report'); $results = $search->searchFiles('report', $folderId); $results = $search->searchByTag('nature'); $results = $search->searchByMetadata('copyright', 'Acme'); $results = $search->advancedSearch([ 'name' => 'report', 'type' => 'document', 'date_from' => '2025-01-01', 'date_to' => '2025-12-31', ]);
File Versioning
$versions = app(VersionService::class); $version = $versions->createVersion($file, $uploadedFile, 'Updated design v2'); $history = $versions->getVersions($file); $file = $versions->revertToVersion($file, $versionId); $versions->deleteVersion($versionId); $deleted = $versions->pruneOldVersions($file, keepCount: 5);
Export & Import
$exporter = app(ExportImportService::class); $response = $exporter->exportFiles([$fileId1, $fileId2]); $response = $exporter->exportFolder($folderId, includeSubfolders: true); $response = $exporter->exportWithMetadata([$fileId1, $fileId2]); $result = $exporter->importFromZip($uploadedZipFile, $targetFolderId); $result = $exporter->importFromFolder('/path/to/folder', $targetFolderId);
Exports with metadata include a manifest.json preserving tags, collections, and custom metadata.
Orphan File Management
Manage files in storage that have no database record:
$scanner = app(OrphanScanService::class); $orphans = $scanner->scan(); $imported = $scanner->import( paths: ['uploads/photo.jpg'], folderId: 0, userId: auth()->id(), ); $deleted = $scanner->delete(['uploads/old-file.jpg']);
Also available via the Storage Scanner section in Media Settings.
Events
All operations dispatch Laravel events:
File Events
| Event | Properties |
|---|---|
MediaFileUploaded |
MediaFile $file |
MediaFileRenaming |
MediaFile $file, string $newName, bool $renameOnDisk |
MediaFileRenamed |
MediaFile $file |
MediaFileDeleting |
MediaFile $file |
MediaFileDeleted |
MediaFile $file |
MediaFileTrashed |
MediaFile $file |
MediaFileRestored |
MediaFile $file |
MediaFileMoved |
MediaFile $file, $oldFolderId, $newFolderId |
MediaFileCopied |
MediaFile $newFile, MediaFile $originalFile |
MediaFileTagged |
MediaFile $file, array $tagIds |
MediaFileVersionCreated |
MediaFile $file, MediaFileVersion $version |
Folder Events
| Event | Properties |
|---|---|
MediaFolderCreated |
MediaFolder $folder |
MediaFolderRenaming |
MediaFolder $folder, string $newName, bool $renameOnDisk |
MediaFolderRenamed |
MediaFolder $folder |
MediaFolderDeleted |
MediaFolder $folder |
MediaFolderMoved |
MediaFolder $folder, $oldParentId, $newParentId |
Permissions
| Permission | Description |
|---|---|
folders.create |
Create new folders |
folders.edit |
Edit/rename folders |
folders.trash |
Move folders to trash |
folders.destroy |
Permanently delete folders |
folders.favorite |
Add folders to favorites |
files.create |
Upload files |
files.read |
View files |
files.edit |
Edit files (rename, alt text) |
files.trash |
Move files to trash |
files.destroy |
Permanently delete files |
files.favorite |
Add files to favorites |
settings.access |
Access settings page |
$media = app(FilamentMedia::class); $media->hasPermission('files.create'); $media->hasAnyPermission(['files.edit', 'files.trash']); $media->addPermission('files.export');
Security
- Private File Access — Authenticated controller with customizable authorization callbacks
- HMAC-SHA256 URLs — Private file URLs keyed to the application secret, not guessable
- SSRF Protection — URL downloads validated against internal networks, cloud metadata IPs, and configurable domain allowlists
- XSS Prevention — User content escaped via
SafeContentService - File Validation — Uploads validated for MIME type and size
- Rate Limiting — Private file routes throttled
- CSRF Protection — All Livewire actions protected
Architecture
Services (registered as singletons)
| Service | Responsibility |
|---|---|
UploadService |
File uploads, validation, SSRF checks |
FileOperationService |
Rename, copy, move, delete, visibility changes |
ImageService |
Thumbnails, watermarks, image processing |
MediaUrlService |
URL generation, path resolution, MIME detection |
StorageDriverService |
Cloud disk configuration (S3, R2, DO, etc.) |
FavoriteService |
Favorites and recent items |
TagService |
Tags and collections |
MetadataService |
Custom metadata fields |
SearchService |
Full-text search (DB or Scout) |
VersionService |
File versioning |
ExportImportService |
ZIP export/import with metadata |
OrphanScanService |
Storage scan, orphan import/delete |
ThumbnailService |
Image resize and crop |
Support Classes
| Class | Purpose |
|---|---|
MediaAdder |
Fluent builder for uploads (->usingName()->toCollection()->save()) |
MediaCollection |
Collection definition with constraints |
MediaHash |
HMAC-SHA256 hash generation for URL obfuscation |
MediaUploadException |
Typed exceptions for upload failures |
Traits
| Trait | Purpose |
|---|---|
HasMediaFiles |
Polymorphic relationships, attach/detach/sync, fluent upload builder |
InteractsWithMediaCollections |
Named collections with constraints |
Livewire Components
| Component | Purpose |
|---|---|
Media |
Main media manager page |
UploadModal |
File uploads with progress tracking |
PreviewModal |
Gallery-style preview with version history |
MediaPicker |
Embeddable file browser for MediaPickerField |
MediaFileGrid |
File grid with context menu |
MediaFileList |
File list/table with context menu |
MediaFiles |
Unified viewer with grid/list toggle |
Extending
Override any service:
$this->app->singleton(TagService::class, MyCustomTagService::class);
Listen to events:
protected $listen = [ MediaFileUploaded::class => [ GenerateAiDescription::class, SyncToExternalCdn::class, ], ];
Testing
composer test
Image Gallery Component
A standalone Alpine.js image gallery with lightbox, thumbnails, keyboard navigation, and RTL support. Works with any array of image URLs — no dependency on the media manager models.
<x-filament-media::image-gallery :urls="$imageUrls" :alt="$altText" />
| Prop | Type | Default | Description |
|---|---|---|---|
urls |
array | [] |
Array of image URLs |
alt |
string | '' |
Alt text for accessibility and lightbox title |
Features: main image display, prev/next arrows, thumbnail strip, fullscreen lightbox overlay, keyboard navigation (arrow keys + Escape), image counter badge, RTL-aware arrow direction, no-images placeholder.
Changelog
Please see CHANGELOG for recent changes.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
This project uses a dual license:
- Open Source — Available under the MIT License for OSI-approved open source projects.
- Commercial — A commercial license is required for proprietary projects. Visit codenzia.com for options.
See LICENSE.md for full details.