aniftyco/laravel-attachments
Attach files to any Eloquent model in Laravel with a clean, expressive API. laravel-attachments stores attachments polymorphically with file metadata, so any model can own images, documents, or other files on any storage disk—no boilerplate or custom migrations required.
Install and publish
composer require aniftyco/laravel-attachments
php artisan vendor:publish --tag=attachments-config
The config file (config/attachments.php) is the first thing to open — it defines the default disk, root directory, allowed mime types/sizes, and any filename strategy. Confirm php artisan storage:link has been run if you plan to serve from the public disk.
Wire up a model
Add the trait and declare which columns are attachment-backed. No separate table or migration is needed beyond the nullable string (or JSON, for collections) columns you already have:
use Aniftyco\Attachments\HasAttachments;
class User extends Model
{
use HasAttachments;
protected $attachments = ['avatar'];
}
First use case: profile avatar
public function update(Request $request, User $user)
{
$request->validate(['avatar' => ['nullable', 'image', 'max:2048']]);
$user->avatar = $request->file('avatar'); // UploadedFile
$user->save();
return back();
}
The package writes the file to the configured disk/directory, stores the relative path in the column, and deletes the previous file. Read it back with $user->avatar?->url() (or ->path() / ->exists()), which is what you put in Blade and API resources.
Where to look first when something is unclear: src/HasAttachments.php (or the equivalent trait) and the config file. The package is small and thin — the trait is the whole contract, so reading it once saves an hour of guessing.
Assign-and-save as the single write path. Treat attachment columns like any other attribute: assign an UploadedFile, a path string, or null, then save(). Deletion-on-replace is driven by the model's saving/saved lifecycle, so keep writes going through Eloquent rather than DB::table()->update().
Removing a file is setting the attribute to null and saving — that both nulls the column and unlinks the file:
$user->avatar = null;
$user->save();
Per-field configuration. Where the field map supports it, tune disk/directory per attribute rather than globally — avatars on public, contracts on s3:
protected $attachments = [
'avatar' => ['disk' => 'public', 'directory' => 'avatars'],
'contract' => ['disk' => 's3', 'directory' => 'contracts', 'private' => true],
];
Multiple files per field. For galleries/documents, store an array of uploads in a JSON column (or use a companion model with one attachment each, which scales better for metadata like captions and ordering). Prefer real columns over JSON once you need to query or sort by file.
Form requests own validation. The package handles placement and cleanup, not rules. Keep image, mimes:pdf, max:10240, and dimensions rules in a FormRequest so validation failures mean no bytes hit the disk.
Blade and API resources.
<img src="{{ $user->avatar?->url() ?? asset('img/placeholder.png') }}">
'avatar' => $user->avatar ? [
'url' => $user->avatar->url(),
'name' => $user->avatar->original_name ?? null,
] : null,
Always null-check — a fresh model has no attachment and the accessor will return null, not a stub object worth relying on.
Transactions and ordering. Store the file, then save the model, and wrap the pair in DB::transaction() where the DB write can fail. The package can only clean up files it knows about; a rolled-back transaction after the file lands is a genuine orphan case.
Testing. Storage::fake('public') plus UploadedFile::fake()->image('a.jpg') covers nearly everything; assert with Storage::disk('public')->assertExists($user->fresh()->avatar->path()). Do not assert on generated filenames — assert on the stored column and existence.
SoftDeletes, a soft delete keeps the file (correct for restores), and forceDelete() is what prunes it. Verify this behavior in the trait before building retention policies on top of it — it's the single most common surprise.$fillable, or assign them explicitly. If you assign an UploadedFile via create($request->all()) you've just put an array into an attribute; assign files by hand.Model::where(...)->update(['avatar' => null]) nulls the column but leaves the file. Always hydrate and save when touching attachment fields.url() on a private S3 disk either won't work or returns a temporary URL. Route downloads through a controller with authorization and Storage::download(), or generate temporary URLs explicitly.string(2048) or text; a default string(255) will silently truncate on some drivers.php artisan config:cache after edits — a cached config is a classic "why is it writing to the wrong disk" culprit.Storage::disk($disk)->exists($path) return true → does the disk's url() resolve. That isolates DB, write, and serve failures in three steps.How can I help you explore Laravel packages today?