toneflix-code/laravel-fileable
Laravel Fileable adds simple file attachments to Eloquent models. Upload, store, and associate files with any model through a clean API, with support for multiple files, custom storage disks, and automatic cleanup of orphaned uploads in your Laravel apps.
1. Install
composer require toneflix-code/laravel-fileable
2. Publish the config, migrations, and run them
php artisan vendor:publish --provider="ToneflixCode\Fileable\FileableServiceProvider"
php artisan migrate
If tagged publishing isn't documented, fall back to --tag=config / --tag=migrations (or php artisan vendor:publish and pick the toneflix-code entries interactively). Check composer.json for Laravel auto-discovery — if present, the service provider and facade are registered for you.
3. Add the trait to the model that owns files
use ToneflixCode\Fileable\Traits\Fileable;
class Post extends Model
{
use Fileable;
}
4. First use case — attach an uploaded file in a controller
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
$post->attachFile($request->file('cover'), 'covers');
return new PostResource($post);
}
Where to look first
src/ for the trait and any File model. This tells you whether it uses a polymorphic files table, JSON columns on the parent, or a single column.fileable_id, fileable_type, disk, path, mime, size, collection, or similar).config/fileable.php — this is the contract that everything else reads from.Trait-driven (automatic) uploads. The "automatic" half of the trait usually hooks saving/saved model events and reads an attribute you assign directly:
$post->cover = $request->file('cover'); // UploadedFile
$post->save(); // file persisted by the trait
This is convenient in Filament/Livewire-style forms but hides I/O inside save() — see Gotchas.
Explicit attach/detach. Prefer the explicit API in controllers and jobs so uploads are visible in the call graph:
$post->attachFile($file, 'covers');
$post->attachFiles($request->file('gallery'), 'gallery');
$post->detachFile($fileId);
Disk and path control. Pass the disk per call ('s3', 'public') or rely on the config default. Model the destination directory by class, e.g. covers/posts/{id}, so files are easy to find and garbage-collect. Store the relative path in the DB and the disk name separately — never the full URL.
Collections / multiple files per model. Treat the disk/collection argument as the grouping key. On the read side you typically get something like $post->files, $post->files('gallery'), or $post->file('cover') — confirm the accessor names in the trait and wrap them in your own scopes (withFiles) if the API is awkward.
Serialization. Files are meaningless to a frontend without a URL. Add an API resource that maps each file to disk->url($path) (or a signed temporary URL for private disks) rather than exposing raw storage paths:
'cover' => $post->file('cover')
? Storage::disk($post->file('cover')->disk)->url($post->file('cover')->path)
: null,
Validation stays in the FormRequest. The package does not validate — you still own mimes, max:2048, image, etc. Validate before the model is touched so a failed request never writes to disk.
Replacing a file. The safest pattern is attach-new, then detach-old, so a failed replacement never leaves the model with no file. Verify the package deletes the physical file on detach; if not, do it yourself.
Async / queue integration. If the trait's automatic hook fires on save(), uploading inside a queued job is fine, but the UploadedFile must already be moved to a temp path (you can't serialize an UploadedFile). The explicit attachFile() API is easier to reason about in jobs.
Adoption risk. With 2 stars and 0 dependents, treat this as a small, unvetted package: pin the exact version in composer.json, read the trait source before shipping, and wrap usage behind your own small service or model method so you can swap it later.
Uploads inside transactions. If the trait writes files during saving/saved, a later DB rollback leaves orphan files on disk. Do uploads after the transaction commits (DB::afterCommit) or accept the orphans and add a files:prune command.
Disk misconfiguration is the #1 cause of 404s. Check config/filesystems.php and FILESYSTEM_DISK in .env. For the public disk you must run php artisan storage:link, and remember Storage::url() returns a path relative to APP_URL — behind a proxy or CDN this often needs ASSET_URL or a custom URL resolver.
The "automatic" trait may assume column names. Verify the trait doesn't expect e.g. a cover column on the model. If it does, either add the column or use the explicit attachFile() path instead.
Deletion semantics. Confirm whether deleting the model also deletes its files. The safest route is to add your own deleting observer that calls detachFile() for every file, and use forceDelete awareness for soft-deletes (a soft-deleted post should not lose its cover).
Naming collisions. If the package hashes filenames, you get dedup for free but lose the original name; store it in your own column if the frontend needs it for Content-Disposition.
Testing. Fake the disk and assert on it — this is the cleanest way to verify the package behaves as documented:
Storage::fake('public');
$this->postJson('/posts', ['cover' => UploadedFile::fake()->image('a.jpg')])
->assertCreated();
Storage::disk('public')->assertExists('covers/posts/1/a.jpg');
Debugging checklist. In order: (1) config/fileable.php disk value; (2) Storage::disk($disk)->exists($file->path) in tinker; (3) whether the file is on the default disk vs the model's recorded disk; (4) whether storage:link exists; (5) whether the trait's event listener is actually registered (Model::getEventDispatcher()).
Extension points. Look for a configurable File model class, a filename/directory resolver closure, and overridable trait methods (attachFile, pathFor). Overriding these is usually a safer fork-free customisation than patching vendor/.
How can I help you explore Laravel packages today?