capevace/cabinet
Cabinet is a Laravel package for file and media management, offering an Eloquent-backed file manager with uploads, storage disk integration, and folder organization. It makes adding media handling and file browsing to your Laravel apps simple and clean.
composer require capevace/cabinet
php artisan vendor:publish --provider="Capevace\Cabinet\CabinetServiceProvider" # config, migrations, assets
php artisan migrate
php artisan storage:link
The two files you will touch first are config/cabinet.php and the migrations. The config defines buckets — each bucket is a named file source backed by a driver. A minimal disk-backed bucket looks like:
// config/cabinet.php
'buckets' => [
'uploads' => [
'name' => 'Uploads',
'driver' => 'disk',
'disk' => 'public',
'root' => 'uploads',
],
],
The first use case to validate the install is: open the Cabinet UI (default route registered by the package, usually /cabinet, configurable via the config's route prefix), upload a file into uploads, and confirm it appears under storage/app/public/uploads. Once that round-trip works, wire the UI into your app via the Blade component (<x-cabinet::cabinet /> / file picker) and start attaching files to models.
For model attachments, add the package's HasFiles concern to the model that owns the files:
use Capevace\Cabinet\Concerns\HasFiles;
class Post extends Model
{
use HasFiles;
}
$post->files; // attached files
$post->attachFile($file); // or the package's current attach API
One bucket per domain concern, not one giant bucket. Separate avatars, documents, exports, and imports into distinct buckets. It keeps disk roots isolated, makes authorization rules simpler (you can allow the picker on avatars without exposing documents), and means swapping a driver for one domain doesn't touch the rest.
Swap drivers instead of rewriting code. The point of the package is that the same UI/API sits over different sources. A dev environment can point documents at a local disk driver while production points the same bucket at S3 or a spatie/laravel-medialibrary-backed driver via config. Reach for a custom driver when you need files that already live elsewhere (a legacy DB table, an asset CDN, an external API); implement the driver interface, register it, and the picker/browser works unchanged.
Embedding the picker in a form. The typical workflow is: a Blade view renders the file-picker component bound to a bucket; the picked file's identifier is submitted in a hidden input; the controller then attaches that file to the model. Always resolve the incoming identifier through the bucket (Cabinet::bucket('avatars')->find($id)) rather than trusting a raw path from the request — never accept a raw disk path from the client.
Uploading programmatically (imports, jobs, API endpoints). Use the facade for anything not driven by the UI:
use Capevace\Cabinet\Facades\Cabinet;
$file = Cabinet::bucket('exports')->put('reports/2025-09.csv', $contents);
// Later, in a controller:
return redirect()->away($file->temporaryUrl(now()->addMinutes(10)));
Rendering files. Centralise URL generation behind your own helper/DTO so private disks work: for public disks use $file->url(); for private disks always use $file->temporaryUrl(...). If you render URLs directly from Blade, private buckets will silently produce broken links.
Organising with directories. Keep a stable convention ({tenant}/{model}/{uuid}-{name}) for paths. The physical path is your only durable handle across drivers, so treat it as data, not display.
Authorization. Put a Gate::define('browse-cabinet', ...) in a service provider and guard the package's route with middleware you control (typically auth plus your gate). Do not expose the UI's route to unauthenticated users just because it works locally.
Livewire / Inertia interop. The picker is a JS component; if you're in Livewire, listen for its "file selected" event and $wire.set(...) the identifier. If you're in Inertia, the picker works but you'll want the selected ID as a form field so it survives Inertia's navigation model.
config/cabinet.php does nothing after php artisan config:cache. Re-cache or clear it — this is the #1 "my bucket doesn't exist" cause.composer update, re-run vendor:publish --tag=cabinet-assets --force or you'll see a stale JS bundle with a mismatched API.storage:link is required for public disks. Without it, uploads succeed but URLs 404. Similarly, remember APP_URL matters — generated URLs use it, so a wrong APP_URL in staging breaks the picker's thumbnails.$file->url() returns a plain path on private disks; always branch on disk visibility, or write one urlForFile() helper and use it everywhere.avatars is meaningless in documents. Always resolve within the bucket, and validate the bucket name from the request against a hardcoded allowlist — never let a request choose the bucket freely.$post->files in a loop is an N+1. Use Post::with('files')->paginate() on list pages.Storage::fake('public') to avoid needing real credentials, and test the driver contract separately from the UI so a driver swap doesn't quietly break uploads.Cabinet::extend(...)/config registration) is where you plug in external APIs. Implement it as a thin adapter class, keep it out of your app's domain code, and unit-test it against a fake HTTP client for API-backed drivers.How can I help you explore Laravel packages today?