koassi / filament-file-explorer
Finder-style file explorer for Filament v4 and v5, powered by Spatie Media Library.
Package info
github.com/KoassiAkakpo/filament-file-explorer
pkg:composer/koassi/filament-file-explorer
Requires
- php: ^8.2
- filament/filament: ^4.0 || ^5.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- livewire/livewire: ^3.0 || ^4.0
- spatie/laravel-medialibrary: ^11.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^4.7
- pestphp/pest-plugin-laravel: ^4.1
README
Finder-style file explorer for Filament v4/v5, powered by Spatie Media Library.
Works two ways:
- Standalone page (default) — a panel-level page in the navigation menu, backed by a single root folder. No model, no record.
- Record-scoped pages — an explorer attached to an Eloquent record, reachable from its resource.
Installation
composer require koassi/filament-file-explorer php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations" php artisan migrate php artisan filament-file-explorer:install
Register the plugin in your panel provider:
use Koassi\FilamentFileExplorer\FilamentFileExplorerPlugin; public function panel(Panel $panel): Panel { return $panel->plugin(FilamentFileExplorerPlugin::make()); }
Add the package views to your Filament theme so Tailwind picks up their classes:
@source '../../../../vendor/koassi/filament-file-explorer/resources/views/**/*.blade.php';
npm run build php artisan filament:assets php artisan vendor:publish --tag=filament-file-explorer-assets
That's it. An Explorer entry appears in the navigation at /{panel}/file-explorer; the root folder is created on first visit.
Configuring the standalone page
Anything in the standalone config block can also be set fluently on the plugin, which takes precedence and stays panel-scoped:
FilamentFileExplorerPlugin::make() ->slug('library') // route: /{panel}/library ->scopeKey('shared-library') // namespace handed to the authorizer ->rootFolder('Shared library') // root folder name ->navigationLabel('Library') ->navigationIcon('heroicon-o-folder-open') ->navigationGroup('Content') ->navigationSort(20) ->withFilesNavigation() // also show the flat files table in the menu ->authorizer(\App\Support\FileExplorerAuthorizer::class)
Behaviour, not just chrome, is panel-scoped too:
FilamentFileExplorerPlugin::make() ->quota(10 * 1024 ** 3) // bytes a scope may hold ->refreshEvery(20) // seconds between automatic refreshes ->defaultViewMode('columns') // grid, columns or details ->maxFolderDepth(6) ->tableColumns(fn (array $columns) => Arr::except($columns, ['preview']))
Other switches: ->withoutFilesPage(), ->withoutNavigation(), ->withoutPages(), ->disabled().
Choosing the root folder
The scope key and root folder come from a FileExplorerRootResolver. Three ship with the package:
| Resolver | Behaviour |
|---|---|
GlobalRootResolver (default) |
One root folder shared by everyone |
PerUserRootResolver |
One root folder per authenticated user |
PerTenantRootResolver |
One root folder per Filament tenant (reuses the tenant's own root when it uses HasFileExplorer) |
FilamentFileExplorerPlugin::make()->rootResolver(PerUserRootResolver::class)
Or write your own:
class TeamRootResolver implements FileExplorerRootResolver { public function scopeKey(): string { return 'team.'.auth()->user()->current_team_id; } public function rootFolderId(): int { return auth()->user()->currentTeam->ensureFileExplorerRoot()->id; } }
Bind it as a singleton so the root is resolved once per request.
Two optional companion interfaces are worth implementing:
ResolvesExistingRootaddsexistingRootFolderId(): ?int, which must answer without writing. Filament callscanAccess()on every navigation render, so without it the first page of a session creates the root folder — for every user, including the ones about to be denied. The three shipped resolvers implement it. When the scope has no root yet the authorizer is asked with0, and the folder is created on the first real visit.ProvidesMultipleRootsaddsroots(): array, and the sidebar turns into a location switcher:
public function roots(): array { return [ ['id' => auth()->user()->ensureFileExplorerRoot()->id, 'name' => 'My files'], ['id' => $this->sharedRootId(), 'name' => 'Shared'], ]; }
The roots of one scope share its scope key, so they share one authorization decision and one ability set — roots that must be authorized apart belong in separate scopes. The explorer still browses one root at a time; the current folder and the clipboard are namespaced per root, and each location remembers where it was left. A switch is validated against roots(), never against an id from the request.
Anything a resolver throws that is not an abort is reported, so a missing migration or a mistyped class does not just make the page vanish from the menu.
Replacing the pages
The packaged pages are enough for most apps. To customise them:
php artisan filament-file-explorer:make-page --standalone
then register the generated classes:
FilamentFileExplorerPlugin::make() ->explorerPage(\App\Filament\Pages\FileExplorer::class) ->filesPage(\App\Filament\Pages\FileExplorerFiles::class)
Record-scoped pages
Attach an explorer to a model instead of the panel.
- Add a
folder_idcolumn and the trait:
php artisan filament-file-explorer:make-folder-migration projects php artisan migrate
class Project extends Model { use \Koassi\FilamentFileExplorer\Models\Concerns\HasFileExplorer; }
- Generate the pages and register them:
php artisan filament-file-explorer:make-page ProjectResource
public static function getPages(): array { return [ // ... 'files' => Pages\ManageProjectFiles::route('/{record}/files'), 'files-list' => Pages\ListProjectFiles::route('/{record}/files-list'), ]; }
Both modes coexist: registering the plugin does not interfere with record-scoped pages, and they use separate root folders.
Views
Three ways to look at a folder, remembered per scope: icons, columns, and details (rows with kind, size and date).
Three, not five: list and table were variations on details with a column dropped, so they offered three ways to look at the same rows and nothing to choose between them. A stored preference naming one of them falls back to the default, which is what an unknown mode has always done.
Columns is the Finder's cascading browser: one pane per level of the path, the contents of the folder you are in on the right, and the trail through the tree marked in the panes behind it. Clicking a folder in any pane moves there and drops the panes beyond; clicking a file in a pane behind the last one moves there and selects it.
Two things about it are deliberate:
- Left and right walk the panes. Right descends into the selected folder and lands on the first entry of the pane it opens, so you can keep descending; left walks back out with the folder you just left selected. Up and down stay inside the pane, and with shift held every arrow extends the selection there instead of navigating.
- Images show their thumbnail, through the media route like everywhere else, at exactly the size of the icon it replaces so panes of photos and panes of documents line up on the same edge.
- Only the last pane holds selectable items. The panes behind it are navigation, and carry none of the
data-fe-type/data-idattributes the selection layer reads — so shift-ranges and the marquee operate on the folder being browsed, exactly as in every other view. Folders in any pane are drop targets, though: a destination you can see is a destination you can drop on. - Searching falls back to the flat result list. A search answers with matches from the whole scope, which is not a path; drawing it as one would be a lie about where the results are.
It costs one listing query per level of the path, which folders.max_depth bounds, and every pane is windowed in SQL like the main listing — a pane over a folder holding thousands of files is the same problem the listing already solves.
The
tableview used to be labelled Columns, which is the Finder's name for the cascading browser and not what it renders. It is now labelled Table, and Columns means the browser.
Large libraries
The explorer renders listing.per_page items at a time (100 by default), folders first, with a “Load more” control for the rest. Sorting, filtering and windowing all happen in SQL, so a folder holding thousands of files stays responsive.
Narrow a folder to one kind — images, PDF, documents, spreadsheets, presentations, archives, audio, video — from the funnel in the toolbar. The entry toggles, so choosing the active kind again clears it, and a bar above the listing says which filter is on with a way out: a narrowed folder that simply looked empty would be a bug report.
Kinds are matched on the stored mime type, never on the extension. The filter has to run in SQL or the totals and “Load more” would count rows the window then dropped — and the mime type is the half worth trusting, sniffed from the bytes rather than typed by whoever uploaded. Only files are narrowed: a folder has no kind, and hiding folders would filter you into a room with no doors. The filter is not remembered across mounts, unlike the view and the sort — how you like to look at a library is a preference, what you are looking for right now is not.
Sort by name, date, kind or size, ascending or descending. Size sorts the files; a folder has no size of its own — the only honest one would be the recursive weight of its subtree, which is a query per folder or a column to keep in step with every upload, move and delete — so it falls back to its name, as it already did for kind. Folders fill the window first whatever the sort, so they are never interleaved with the files they would be compared against.
// config/filament-file-explorer.php 'listing' => ['per_page' => 100],
Thumbnails
Images are rendered from a thumbnail conversion instead of the original, so a folder of photos costs kilobytes rather than megabytes. Nothing to install: Media Library already requires spatie/image, which drives GD or Imagick.
'thumbnails' => [ 'enabled' => true, 'width' => 320, 'height' => 320, 'queued' => false, // a host with no worker would never get one ],
Thumbnails go out through the media route like everything else, so they are subject to the same ability and containment checks — and a file uploaded before this existed keeps rendering, because the route serves the original when the conversion is missing. To fill those in:
php artisan media-library:regenerate "Koassi\FilamentFileExplorer\Models\Folder" --only=thumbnail --only-missing
Where files land
The package never picks a disk or a path: uploads go to toMediaCollection() with no disk, so everything is Media Library's own configuration — media-library.disk_name for the disk, its PathGenerator for the layout. By default that means {disk}/{media_id}/file.png, one directory per file at the root of the disk, mixed in with whatever else the application stores there.
To gather the explorer's files under a single directory, point Media Library's custom_path_generators at the Folder model:
namespace App\Support; use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator; class FileExplorerPathGenerator extends DefaultPathGenerator { protected function getBasePath(Media $media): string { return 'file-explorer/'.$media->getKey(); } }
// config/media-library.php 'custom_path_generators' => [ \Koassi\FilamentFileExplorer\Models\Folder::class => \App\Support\FileExplorerPathGenerator::class, ],
storage/app/public/file-explorer/12/photo.png
storage/app/public/file-explorer/12/conversions/photo-thumbnail.jpg
Overriding only getBasePath() keeps conversions/ and responsive-images/ where Media Library expects them, and keying the generator to the model leaves the rest of the application's media untouched. Nothing in the explorer needs to know: paths are resolved through getPath() / getPathRelativeToRoot(), and the trash only ever changes a file's name and collection, never its directory. If you set morph_class, key the config on that value rather than on Folder::class unless it is in the morph map.
media-library.prefix does the same thing in one line, but for every media in the application — fine when the explorer is the only thing using Media Library.
Either way the path is computed, never stored, so the change is retroactive: files already uploaded stay where they are and the new path will not find them. Move them once before switching, or after, in a maintenance command.
Keeping the files private needs no more than a disk that is not public — MEDIA_DISK, or media-library.disk_name. Nothing in the explorer calls getUrl(): downloads, previews, thumbnails and zips all go out through the media route, behind the ability and containment checks, so a private disk costs no functionality. Keep the driver local rather than remote and the route still answers with a BinaryFileResponse, which is what gives video seeking and resumable downloads.
Quotas
quota.bytes (null by default) caps what a scope may hold. The cap is per root folder, so with the per-user or per-tenant resolver each user or tenant gets an allowance of that size, and the sidebar shows the usage — amber past 85%, red when full.
Uploads and copies that would go over are refused before anything is written, and the reason is reported. Replacing a file only counts the difference. Trashed files still count: they sit on the disk until they are purged, and a trash that stopped counting would be a way over the cap.
Who added a file
The uploader is recorded on every upload and shown in the inspector and in the files table. It is resolved from the model each time rather than stored as a name, so a renamed account does not leave stale names behind, and memoised per request: a page costs one query per distinct uploader. When the account is gone, the id is still shown.
Several sessions on the same tree
Nothing is broadcast, so two people browsing the same root see nothing of each other until they navigate. refresh.seconds (or ->refreshEvery()) closes that: off by default, floored at five seconds.
The polling is driven from Alpine rather than wire:poll, so it stands down while the tab is hidden, while an item is being dragged, and while the user is renaming, creating a folder, uploading or reading a dialog. A "Load more" survives a refresh. If the folder you are standing in is deleted by someone else, the explorer falls back to the root instead of rendering a folder that is no longer there — with the trash off, a folder purged under you leaves Livewire unable to restore the model at all, and the page has to be reloaded.
Mutations also announce themselves to the other explorers on the same page, which is what keeps a FileExplorerPicker in a modal in step with the page behind it.
Trash
Deleting moves things aside instead of destroying them. Folders are soft-deleted, files move to a separate media collection, and the toolbar's trash button lists what is in there with where it came from and when it went — each row restorable or deletable for good.
// config/filament-file-explorer.php 'trash' => [ 'enabled' => true, // false deletes permanently, as before 'collection' => 'file-explorer-trash', ],
Restoring a folder brings back what went down with it, but leaves a file you had trashed on its own where you put it. Restore and purge require the same ability as deleting (delete for files, deleteFolder for folders).
Nothing expires on its own. Schedule the purge if you want it to:
Schedule::command('file-explorer:purge-trash --days=30')->daily();
Upgrading an existing install adds a deleted_at column to the folders table, so run php artisan migrate.
Keyboard
The listing is a listbox: it takes focus, and the items are options.
| Keys | Action |
|---|---|
| Arrows | Move the selection (rows and grid) |
| Shift + click / Shift + arrows | Extend the selection to a range |
| Ctrl/Cmd + click | Add or remove one item |
| Ctrl/Cmd + A | Select everything on screen |
| Enter | Open the selected folder, or preview the selected file |
| F2 | Rename |
| Ctrl/Cmd + C / X / V | Copy, cut, paste |
| Delete / Backspace | Delete the selection |
| Escape | Close the context menu, cancel a rename |
Shortcuts only fire while the listing has focus, never while typing in the search or rename field, and each one still goes through its ability check. Downloading a multi-item selection produces a single archive (selection/zip, capped at 500 items).
Preview and confirmation
Double-clicking a file (or pressing Enter, or picking Open in the context menu) opens it in a lightbox: images, video, audio, PDFs and text render inline, anything else offers a download. Left and right arrows walk through the files of the current listing. Previewing requires the download ability, since it streams the same bytes as a download.
PDFs and text files render in a same-origin <iframe>, so an app that sends X-Frame-Options: DENY needs SAMEORIGIN (or a frame-ancestors 'self' CSP) for that part of the preview to show.
Deleting asks first, in a dialog that names what is about to go, counts what a folder takes with it, and lists the items the authorizer refuses to delete — those are kept, and the rest still goes.
Authorization
Implement FileExplorerAuthorizer to control access and per-ability permissions. $scopeKey tells you which explorer is being accessed — the configured scope key for the standalone page, {model-kebab}.{id} for a record page.
php artisan filament-file-explorer:make-authorizer
FilamentFileExplorerPlugin::make()->authorizer(\App\Support\FileExplorerAuthorizer::class)
abilities() returns eleven keys, and one you leave out counts as a denial. But the set can grow: nothing reads your array directly — Support\Abilities does, and an ability the explorer gains later that your implementation does not answer inherits the one it is a variation of rather than being read as a refusal. share follows download, so an authorizer written today keeps working and keeps meaning what you meant by it. Answer the new key yourself to have the last word:
public function abilities(string $scopeKey, int $rootFolderId): array { return [ // … the eleven 'share' => false, // explicit: no sharing even though download is allowed ]; }
Nothing ever defaults to allowed — an ability nobody declared is refused. Your own extra keys are kept, so you can read them for actions of your own.
The media routes (show, zip-media, zip-folder) never trust the scope key in their own URL: they resolve the scope's root folder through the standalone resolver, or from the roots the current session has legitimately opened, and then require the media to sit under it. A media link is therefore only served to someone whose session has a claim to that scope — a URL kept from an expired session returns 403 until the explorer page is opened again.
Your own folder model
The tree is built from Koassi\FilamentFileExplorer\Models\Folder. Point the config at a subclass to add columns, a global scope, a factory or anything else:
// config/filament-file-explorer.php 'folders' => [ 'model' => \App\Models\Folder::class, ],
class Folder extends \Koassi\FilamentFileExplorer\Models\Folder { // whatever you need }
It has to extend the package's model — the media collection, the thumbnail conversion, the soft deletes and the containment walk all live on it — and a class that does not is refused with a clear message at the first query rather than failing three layers down.
Two things this deliberately does not change:
- Media rows keep storing the package's class in
model_type.getMorphClass()answers for the base model on purpose, so switching does not orphan a library already uploaded: every row still passes the containment guard, which checks that value. Setmorph_classonly if you are migrating from some other implementation. - Config, not a plugin setting. A model cannot know which panel it is being browsed from — the same reason the thumbnail conversion is config only.
Share links
Right-click a file and Share gives it a link that works with no account and no session — which is the point: it is how you send a file to someone outside the panel. The dialog shows the link, when it expires, how many times it has been opened, and a button to stop sharing.
// config/filament-file-explorer.php 'share' => [ 'enabled' => true, 'default_ttl_days' => 7, 'max_ttl_days' => 30, // a ceiling, not a default; null allows links that never expire ],
Gated on the share ability, which follows download for any authorizer that does not answer it — so an authorizer written before this existed keeps meaning what its author meant, and one that refuses downloads refuses sharing too.
How it holds up without a session
The package's two guards cannot both run on a request with no authenticated user, so they are split in time:
- The ability is checked when the link is made, and that decision is what the row records. There is nobody to ask afterwards.
- Containment is checked on every request, against the root stored on the row — never against anything derived from the media, which would only prove the file sits under its own ancestor.
That split is what makes a link die on its own, with nothing to keep in step: a file moved out of the scope it was shared from fails the containment walk, a trashed file leaves the explorer's collection and fails the same check, and a deleted file has no row. Revoking is a database write and takes effect immediately.
The route takes the token and nothing else — no scope key, no media id, no conversion name — so a link cannot be edited into a link for another file or another rendition. A bad token, an expired one, a revoked one and a file that has gone all answer 404 alike; telling them apart would say whether a token was ever real.
Links are stored rather than signed, because revoking is the half of sharing that matters. Revoking keeps the row, so a withdrawn share is still there to inspect — and FileShared / ShareRevoked are fired for both, like every other mutation.
Upgrading an existing install adds the shares table, so run php artisan migrate.
Events
Every mutation fires an event, so an application can keep an audit trail, notify, scan, index or replicate without extending the component.
use Koassi\FilamentFileExplorer\Contracts\FileExplorerEvent; use Koassi\FilamentFileExplorer\Events\FileUploaded; // One listener for everything that happens in the explorer. Event::listen(FileExplorerEvent::class, function (FileExplorerEvent $event): void { Log::info($event::class, ['scope' => $event->scopeKey]); }); // Or one kind at a time. Event::listen(FileUploaded::class, ScanUpload::class);
FileExplorerEvent is an interface, not a shared base class, because Laravel's dispatcher resolves listeners for the interfaces an event implements but not for its parent classes — so listening to it really does catch all of them.
| Event | Payload beside the scope |
|---|---|
FileUploaded |
media, folder |
FolderCreated |
folder |
FileRenamed |
media, previousName |
FolderRenamed |
folder, previousName, previousSlug |
FileMoved |
media, fromFolderId, toFolderId |
FolderMoved |
folder, fromParentId, toParentId |
FileCopied |
copy, source, folder |
FolderCopied |
copy, source |
FileTrashed |
media, withFolder |
FolderTrashed |
folder |
FileRestored |
media |
FolderRestored |
folder |
FileDeleted |
mediaId, name, fileName, folderId, size, purgedFromTrash |
FolderDeleted |
folderId, name, parentId, purgedFromTrash |
FileShared |
media, share |
ShareRevoked |
share |
All of them carry scopeKey, rootFolderId and actor (nullable — a console command has none). The scope is on every event because it is the only way to tell two explorers apart: the same tree is reachable from a page and from a picker in a modal, and the per-user and per-tenant resolvers run the same code for every scope.
Four things worth knowing about the shape:
- The deletions carry a snapshot, not a model. By the time a listener runs the row is gone and the bytes are off the disk — which is also why
sizeis onFileDeleted, as the last moment it can be known. FileUploadedfires per file, so a multi-file upload fires several times, and an upload that replaces an existing file firesFileDeletedfor what it destroyed.FolderCopiedfires once for the folder that was asked for, never per descendant — but each file the copy created does fireFileCopied, because every new file row is a new object to index or scan.- An upload whose thumbnail failed still fires.
CouldNotLoadImageis thrown after the row and the original are written, so the file is there; the event would otherwise be missing exactly where an upload went half-wrong.
Assets
There is no build step: php artisan filament:assets publishes the package's JS and CSS straight from its sources. Re-run it after upgrading, as you would for any Filament plugin — and keep the @source line from the installation steps so your theme still sees the Tailwind classes the views use.
Testing
composer install ./vendor/bin/pest # PHP suite node --test "tests/Js/*.test.mjs" # selection and keyboard logic
The JS tests need nothing installed: they run the shipped script in a node:vm context with a stubbed Alpine and DOM.
Both suites run in CI on every push and pull request, on PHP 8.3 and 8.4. 8.3 is the floor for the test suite, not for the package — the explorer itself runs on PHP 8.2, but Pest and Testbench both require 8.3, so there is no toolchain to run the suite with below it.
Provider order matters when booting Filament under Testbench:
Filament\Support\SupportServiceProviderbinds Livewire'sDataStoremechanism non-shared, so it must register beforeLivewireServiceProvider. Seetests/TestCase.php.
Roadmap
The package is on 0.x, which is the only window in which the public API can still change. ROADMAP.md records what had to be settled before it freezes at 1.0.0 — an ability set that can grow, a swappable folder model, selection state per component, domain events — all of which have shipped, along with the features that were planned alongside them. What remains is additive.
License
MIT — see LICENSE.