Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Attachments Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Soft deletes. Files are typically removed when the model is actually deleted. With 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.
  • Mass assignment. Attachment columns must be $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.
  • Orphan files are inevitable. Deploys, crashes, and rolled-back transactions leave bytes behind with no DB row. Plan a scheduled pruning command that walks the directory and deletes files not referenced by any row. Do not rely on the package for garbage collection.
  • Query-builder updates bypass events. Model::where(...)->update(['avatar' => null]) nulls the column but leaves the file. Always hydrate and save when touching attachment fields.
  • Store relative paths, never URLs. URLs are generated at read time, so switching disks, adding a CDN, or moving to S3 is a config change instead of a backfill migration.
  • Private disks. 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.
  • Column sizing. Paths get long (directory + random filename). Use string(2048) or text; a default string(255) will silently truncate on some drivers.
  • Filenames are not yours. The package sanitizes/randomizes names to avoid collisions and traversal. If users need the original name, store it in a separate column at upload time.
  • Config caching. If defaults seem ignored in production, run php artisan config:cache after edits — a cached config is a classic "why is it writing to the wrong disk" culprit.
  • Extension points. Before reaching for a custom cast or a new column, check whether the trait exposes overridable methods for disk, directory, or filename resolution. Overriding one method in the model is cheaper than forking the package.
  • Debugging checklist: does the column contain a path after save → does Storage::disk($disk)->exists($path) return true → does the disk's url() resolve. That isolates DB, write, and serve failures in three steps.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core