slimani/filament-media-manager
A media manager plugin for Filament v4 and v5 with hierarchical folders, tagging, search and filtering, previews, and configurable multi-disk storage. Built natively for Filament forms, tables, and actions with a smooth, responsive browser UI and per-field or global disk settings.
Install the package and publish both migration sets — the plugin's own tables (media_files, folders, tags, media_attachments) plus Spatie MediaLibrary's, since the package builds on it:
composer require slimani/filament-media-manager
php artisan vendor:publish --tag="media-manager-migrations"
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate
php artisan vendor:publish --tag="media-manager-config"
Register the plugin in your panel provider — this alone gives you the media browser page (folders, search, filters, tags, bulk actions) in the sidebar:
use Slimani\MediaManager\MediaManagerPlugin;
public function panel(Panel $panel): Panel
{
return $panel->plugin(MediaManagerPlugin::make());
}
Then make a model "media-aware" with the InteractsWithMediaFiles trait and add the FK column for single-file relations:
Schema::table('users', function (Blueprint $table) {
$table->foreignId('avatar_id')->nullable()->constrained('media_files')->nullOnDelete();
});
class User extends Model
{
use InteractsWithMediaFiles;
protected $fillable = ['name', 'email', 'avatar_id'];
public function avatar(): BelongsTo
{
return $this->mediaFile('avatar_id');
}
}
Where to look first: the published config file (global disk, conversions, table prefix), then docs/url-generation.md — disk visibility drives almost every "my image doesn't load" bug you'll hit. Skim the README's Selection behavior note too, because the multi-select gesture is non-obvious and will generate user questions.
First use case: drop a MediaPicker::make('avatar_id')->relationship('avatar') into an existing resource form. That's the shortest path from install to value — the media browser page is already useful on its own for ad-hoc uploads, but the picker is where it earns its keep day-to-day.
Single-file vs. polymorphic collections. There are two distinct integration styles, and picking the wrong one is the most common setup mistake:
// Single file — needs an FK column on YOUR model's table + $fillable entry
public function avatar(): BelongsTo { return $this->mediaFile('avatar_id'); }
// Collections — no migration needed, uses the package's media_attachments table
public function documents(): MorphToMany { return $this->mediaFiles('documents'); }
Form field pattern. relationship() must point at the method name on the model, not the FK column:
MediaPicker::make('avatar_id')->relationship('avatar')->label('User Avatar')->required(),
MediaPicker::make('documents')->relationship('documents')->multiple()->label('Files'),
Table + infolist presentation. MediaColumn is for list views; it proxies Filament's image column API so ->circular() and ->stacked() work as expected. For detail views, split by intent — MediaImageEntry when you want thumbnail/conversion behaviour, MediaFileEntry when you want the built-in slide-over preview for PDFs, videos, and mixed types:
MediaImageEntry::make('avatar')->conversion('thumb')->circular()->width(100)
MediaFileEntry::make('cv')->label('User CV')
RichEditor integration is a three-part contract. All three pieces are required or you get silent failures:
HasRichContent + uses InteractsWithRichContent, with setUpRichContent() registering the plugin and its collection/directory.RichEditor component gets MediaManagerRichContentPlugin::make() in its ->plugins([...]).renderRichContent($field), not raw HTML:TextEntry::make('resume')->html()->state(fn ($record) => $record->renderRichContent('resume'))
That third step is what makes private-disk images work at all, since it re-signs URLs at render time. Treat it as mandatory, not optional.
Disk strategy. Configure globally in the config file for the app default, and override per field where a model needs isolation (e.g. avatars on public, contracts on s3-private). Pair conversions_disk with the primary disk deliberately — File::getUrl() honours it separately from the storage disk.
Tenancy. Opt-in via the separate migration + ->tenantAware(). The default resolver reads Filament::getTenant(); override with tenantResolver: and tenantColumn: when you're not on Filament's tenant model. Note the resolver and column can't be chosen after the fact without editing the migration first — decide before you run it.
Panel-level customisation as a workflow tool. ->navigationLabel(), ->navigationGroup(), ->shouldRegisterNavigation(fn () => auth()->user()->isAdmin()), plus headerWidgets/footerWidgets and Blade header/footer views let you turn this into a branded "Assets" section of an internal admin without forking the package. That's the extension point worth exploiting before considering a fork.
theme.css — the @import for component styles and the @source glob so Tailwind scans the plugin's Blade files for utility classes. Missing @source yields an unstyled-but-not-broken grid, which is easy to misdiagnose as a CSS bug.$fillable omissions are the classic runtime error. The FK columns (avatar_id, cv_id, …) must be in $fillable, or the picker saves nothing and the form appears to "revert". Symptom: no exception, just a null after save.visibility => 'public'. Everything else gets temporary signed URLs, so any place you cache or persist a resolved URL (emails, PDF exports, API payloads) will eventually serve expired links. Always call getUrl() at render/send time, and use renderRichContent() for stored HTML.tenantAware() enabled and no resolvable tenant, reads return empty sets and writes throw. And critically: tenant DB scoping does not make a public disk private — files stay accessible to anyone with the URL. Use a private disk for tenant assets.withVideoThumbnails() requires FFMPEG on the server. Easy to enable locally and forget in the deploy image; uploads will succeed and thumbnails will silently be absent.composer.json, and smoke-test upgrades against your panel — Filament v4/v5 dual support means the internals carry compatibility branches. Budget for the possibility of adopting the source if you lean on it heavily.php artisan storage:link exists → disk configured in config → disk visibility is public if you expect clean URLs → conversions_disk set if you use conversions → queue worker running if conversions are queued (Spatie default). That sequence resolves the majority of "file uploaded but nothing shows" reports.How can I help you explore Laravel packages today?