Record-scoped pages
A project, a client, a case file — anything that should own files of its own gets an explorer reachable from its resource. The scope key becomes {model-kebab}.{id} and the root folder is the record’s own, so two records never see each other’s files.
Both modes coexist. Registering the plugin does not interfere with record-scoped pages, and they use separate root folders.
1. Give the model a folder
php artisan filament-file-explorer:make-folder-migration projects
php artisan migrate
use Koassi\FilamentFileExplorer\Models\Concerns\HasFileExplorer;
class Project extends Model
{
use HasFileExplorer;
}
The trait adds a folder() relation, ensureFileExplorerRoot(), and the scope key. With auto_create_root on (the default) the root folder is created when the record is, so a new project already has somewhere to put files.
Its defaults are all overridable on the model:
class Project extends Model
{
use HasFileExplorer;
// Which column holds the folder id — default 'folder_id'.
public function fileExplorerFolderForeignKey(): string
{
return 'library_folder_id';
}
// What the root folder is called.
public function fileExplorerRootFolderName(): string
{
return $this->name.' files';
}
// The scope key becomes '{prefix}.{id}' — default is the model's kebab name.
public function fileExplorerScopeKeyPrefix(): string
{
return 'project';
}
}
Records that already exist
The trait creates the root folder on the model’s creating event, so rows that were already in the table when you added the trait have no folder_id. Adding the explorer to a model that already holds records is the ordinary way this feature gets adopted, so that is the normal case rather than an edge one.
Nothing to do: the root is created on the first visit to that record’s explorer page, named after the record, and the id is written to the column. A second visit finds it.
It happens on the visit and not before, for the same reason the standalone page creates its root in mount() and never in canAccess() — Filament calls canAccess() on every navigation render, so creating there would write a folder for every authenticated user on every page of the panel.
If you would rather fill them in ahead of time — to see them in a report, or to avoid the write happening inside someone’s page load:
Project::query()->whereNull('folder_id')->each(
fn (Project $project) => $project->ensureFileExplorerRoot()
);
ensureFileExplorerRoot() is idempotent: it returns the existing folder when the column is already set.
With
auto_create_rootoff, roots are yours to assign and the page will not write one. It refuses with a message naming the record and the column instead of a bare 404.
2. Generate the pages
php artisan filament-file-explorer:make-page ProjectResource
That writes two page classes into your resource’s Pages directory, extending the package’s abstract pages. Register them:
public static function getPages(): array
{
return [
// ...
'files' => Pages\ManageProjectFiles::route('/{record}/files'),
'files-list' => Pages\ListProjectFiles::route('/{record}/files-list'),
];
}
--explorer or --list generates just one of the two, and that is a supported way to use it: each page offers a header button linking to its companion, and the button is simply not drawn when the companion is not in getPages(). Nothing else changes.
What the generated page is
Nothing but the two answers the explorer needs:
class ManageProjectFiles extends FileExplorerPage
{
protected static string $resource = ProjectResource::class;
public function fileExplorerScopeKey(): string
{
return $this->record->fileExplorerScopeKey();
}
public function resolveFileExplorerRootFolderId(): int
{
return $this->record->ensureFileExplorerRoot()->id;
}
}
The abstract parent does the rest: it resolves the pair, aborts 403 through the authorizer, and hands both values to the Livewire component. That is the whole seam — a third mode would mean implementing those two methods and nothing more.
Which also means the generated class is yours to change. Add a heading, a widget, an extra authorization check; it stays a Filament page.
Authorizing a record scope
Your authorizer receives project.4 as the scope key, so the record is right there in it:
public function canAccess(string $scopeKey, int $rootFolderId): bool
{
if (! str_starts_with($scopeKey, 'project.')) {
return true; // some other explorer's business
}
$project = Project::find((int) substr($scopeKey, strlen('project.')));
return $project !== null && auth()->user()->can('view', $project);
}
See Authorization for the rest of the contract.
Reading a record’s files from your own code
The explorer is a UI over Media Library, so a record’s files are ordinary media rows on its folder tree:
$root = $project->ensureFileExplorerRoot();
$root->getMedia('file-explorer'); // files at the top level
use Koassi\FilamentFileExplorer\Support\FolderTree;
$ids = app(FolderTree::class)->descendantFolderIdsIncludingRoot($root->id);
Use Support\Quota for what a record’s scope holds, and Support\Annotations for its descriptions and tags.