codeitamarjr / laravel-attachments
Persist and manage polymorphic file attachments in Laravel applications.
Package info
github.com/codeitamarjr/laravel-attachments
pkg:composer/codeitamarjr/laravel-attachments
Requires
- php: ^8.3
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/filesystem: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.17|^10.11|^11.0
- phpunit/phpunit: ^11.5
README
codeitamarjr/laravel-attachments adds a small attachment layer/model on top of Laravel filesystem.
It gives you:
- A polymorphic
attachmentstable for any Model - A
HasAttachmentstrait with explicit single-file and multi-file collection helpers - An
AttachmentServicefor storing, replacing, and deleting files - Public/private visibility handling with URL abstraction
Why This Package Exists
In many Laravel applications, user file uploads end up being handled:
- store the file in one place
- save metadata somewhere else
- manually wire the metadata back to a model
- remember to clean up storage when the model or file is replaced or deleted
This package creates the attachments table and the Attachment model, and it gives you a reusable way to attach files to any model and persist their metadata on the Attachment model, updating and deleting files being handled by the Trait and Service.
Quick Start
composer require codeitamarjr/laravel-attachments php artisan vendor:publish --tag=attachments-migrations php artisan migrate
Sample usage:
class Invoice extends Model implements Attachable { use HasAttachments; } // Store a new file in the "document" collection for the invoice model, associating the uploader by their authenticated ID: $attachments->store($invoice, $file, 'document', auth()->id());
Contents
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
Configuration
The published config/attachments.php file exposes:
disk: the filesystem disk used to store uploaded filesvisibility: the default visibility for stored attachmentsuploader_model: model used by theuploader()relationshipuploader_foreign_key: attachments column used for the uploader relationshipdirectory: the base directory inside that diskprivate_url_ttl: how long private temporary URLs should remain valid
By default the package reads:
ATTACHMENTS_DISK=public // Optional, but defaults to public if not set. Make sure the selected disk is properly configured in config/filesystems.php and exposed in your application when applicable, Private attachments require a filesystem driver that supports Laravel temporary URLs. ATTACHMENTS_VISIBILITY=public // Optional, but defaults to public if not set. Can be overridden per attachment when storing. ATTACHMENTS_UPLOADER_MODEL="App\\Models\\User" // Optional, but defaults to User if not set ATTACHMENTS_UPLOADER_FOREIGN_KEY=uploaded_by // Nullable by default, but required if you set an uploader model ATTACHMENTS_DIRECTORY=attachments // Base directory for all attachments in the selected disk ATTACHMENTS_PRIVATE_URL_TTL=5 // Minutes for the temporary URL to remain valid
Basic Usage
Add the HasAttachments trait to any model that should own files:
<?php namespace App\Models; use CodeItamarJr\Attachments\Contracts\Attachable; use CodeItamarJr\Attachments\Traits\HasAttachments; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; class Invoice extends Model implements Attachable { use HasAttachments; public function documentAttachment(): MorphOne { return $this->attachment('document'); } public function getDocumentUrlAttribute(): ?string { return $this->firstAttachmentUrl('document'); } }
Any Eloquent model can use the package, such as Invoice, User, Post, Product ...
Models that use the package should implement the CodeItamarJr\Attachments\Contracts\Attachable contract. The HasAttachments trait already provides the required methods.
The trait adds:
attachments()for the full morph-many relationshipattachmentsFor($collection)for all attachments in a collectionsingleAttachment($collection)for single-file collections by conventionattachment($collection)for single-file collections by conventionfirstAttachment($collection)for the first attachment model in a collectionlastAttachment($collection)for the last attachment model in a collectionattachmentAt($collection, $position)for the Nth attachment model in a collectionfirstAttachmentUrl($collection, $expiresAt = null)for the first attachment URL in a collectionlastAttachmentUrl($collection, $expiresAt = null)for the last attachment URL in a collectionattachmentUrlAt($collection, $position, $expiresAt = null)for the Nth attachment URL in a collection
Storing Files
Use AttachmentService to create a new attachment:
use App\Models\Invoice; use CodeItamarJr\Attachments\Services\AttachmentService; public function storeInvoiceDocument(AttachmentService $attachments) { $invoice = Invoice::findOrFail(request('invoice_id')); $file = request()->file('document'); if (! $file) { return; } $attachments->store($invoice, $file, 'document', auth()->id()); }
store() appends a new attachment to the selected collection. This makes multi-file collections a first-class feature.
If you do not want to associate the attachment with an uploader, you can omit the fourth argument:
$attachments->store($invoice, $file, 'document');
Store a private attachment by overriding the default visibility:
$attachments->store($invoice, $file, 'signed-copy', auth()->id(), 'private');
Store multiple named collections for the same model:
$attachments->store($invoice, $documentFile, 'document', auth()->id()); $attachments->store($invoice, $receiptFile, 'receipt', auth()->id());
Store multiple files in the same collection:
$attachments->store($invoice, $scanA, 'supporting-documents', auth()->id()); $attachments->store($invoice, $scanB, 'supporting-documents', auth()->id()); $invoice->attachmentsFor('supporting-documents')->get(); $invoice->firstAttachment('supporting-documents'); $invoice->lastAttachment('supporting-documents'); $invoice->attachmentAt('supporting-documents', 2); $invoice->firstAttachmentUrl('supporting-documents'); $invoice->lastAttachmentUrl('supporting-documents'); $invoice->attachmentUrlAt('supporting-documents', 2);
Stored files are organized using this pattern:
{directory}/{model-name}/{model-id}/{collection}/{hashed-filename}
Example:
attachments/invoice/15/document/8f9c0d....pdf
Replacing Files
Replace the current file for a collection:
$attachments->replace($invoice, $file, 'document', auth()->id());
replace() replaces the whole target collection. Any existing attachments in that collection are deleted before the new file is stored.
Replace a single attachment inside a multi-file collection:
$attachments->replaceById($invoice, $attachmentId, $file, auth()->id());
Use this when a collection contains multiple files, such as a gallery or supporting documents list, and only one specific attachment should be replaced.
Deleting Files
Delete one collection:
$attachments->delete($invoice, 'document');
Delete one attachment inside a multi-file collection:
$attachments->deleteById($invoice, $attachmentId);
Delete all attachments for a model:
$attachments->delete($invoice, null);
Models using HasAttachments also clean up their stored files automatically when they are force-deleted.
Collection Semantics
Collections can be used in two ways:
- Single-file collections, such as
logo,avatar, orsigned-copy - Multi-file collections, such as
documents,receipts, orgallery
Recommended conventions:
- Use
store()to append files to a collection - Use
attachmentsFor()when you want all files in a collection - Use
singleAttachment()orattachment()when the collection is meant to behave like a single-slot attachment - Use
firstAttachment(),lastAttachment(), orattachmentAt()when you need specific items from a multi-file collection - Use
firstAttachmentUrl(),lastAttachmentUrl(), orattachmentUrlAt()when you need specific URLs from a multi-file collection - Use
replace()when the collection should behave like a single-slot attachment and older files should be removed - Use
replaceById()when a multi-file collection should keep the rest of its files while replacing only one attachment - Use
deleteById()when a multi-file collection should keep the rest of its files while deleting only one attachment
Attachment Model
Each attachment record stores:
collectiondiskpathvisibilityfilenamemime_typesizeuploaded_by
uploaded_by stores the uploader model's key, while uploader() resolves the related model instance using your package configuration.
The included Attachment model provides:
url()which returns a normal URL for public files and a temporary URL for private filestemporaryUrl()when you want to explicitly generate a signed temporary URLisPublic()andisPrivate()visibility helpers
Testing
Run the package test suite from the package directory:
composer install
composer test
Changelog
Please see CHANGELOG.md for release-oriented package notes.
License
MIT. Please see LICENSE for more information.