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 Media Laravel Package

finller/laravel-media

Laravel Media gives Eloquent models a fluent API for attaching and managing files: define conversions, generate responsive images, and store media across local or cloud filesystems with minimal configuration and clean, expressive code.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish

composer require finller/laravel-media

php artisan vendor:publish --tag="laravel-media-config"
php artisan vendor:publish --tag="laravel-media-migrations"
php artisan migrate

php artisan storage:link

The package ships a config file (config/media.php) and a media table migration. Run the migration before touching any model — the Media model is a polymorphic relation, so nothing works until the table exists.

Where to look first

  1. config/media.php — default disk, queue settings for conversions, path generator, metadata extractors. This is the file that answers 80% of "why is it doing that?" questions.
  2. Finller\Media\Models\Media — the record your models relate to: disk, path, name, mime_type, size, metadata (JSON), conversions.
  3. Finller\Media\MediaCollection — the class you subclass to define what a collection is (disk, single-vs-multiple, accepted mimes, which conversions run).
  4. Finller\Media\Conversions\Conversion — the class you subclass to define how an image is transformed. Conversions are objects, not closures, which is the main design difference from Spatie Media Library.
  5. README — the exact method names (addMedia, addMediaFromRequest, getFirstMediaUrl) are close to Spatie's but not guaranteed identical across versions. Skim once before writing your first controller.

First use case: a cover image with a thumbnail

use Finller\Media\Concerns\HasMedia;

class Post extends Model
{
    use HasMedia;

    public function registerMediaCollections(): array
    {
        return [
            'cover' => CoverCollection::class,
        ];
    }
}
use Finller\Media\MediaCollection;
use Finller\Media\Conversions\Conversion;

class CoverCollection extends MediaCollection
{
    public string $name = 'cover';
    public ?string $disk = 'public';
    public bool $single = true;          // replace previous on re-upload
    public array $conversions = [
        Thumbnail::class,
    ];
}
class Thumbnail extends Conversion
{
    public string $name = 'thumb';

    public function apply(Media $media): void
    {
        $this->image->fit(\Spatie\Image\Enums\Fit::Crop, 400, 400);
    }
}
// Controller
$post->addMediaFromRequest('cover')->toMediaCollection('cover');

// Blade
<img src="{{ $post->getFirstMediaUrl('cover', 'thumb') }}" alt="">

That's the whole happy path: one trait, one collection class, one conversion class, one call to store, one call to render.

Implementation Patterns

One collection class per semantic slot, not per model. CoverCollection, GalleryCollection, AttachmentCollection. The collection owns the disk, the accepted MIME list, the single/multiple rule, and the conversion list. When marketing asks "can avatars also be WebP?", you edit one class instead of grepping models.

Conversions as named, reusable units. Because a conversion is a class with a name, you can reuse Thumbnail across collections and reference it by name at render time (getFirstMediaUrl('cover', 'thumb')). This is the biggest day-to-day ergonomic win over closure-based conversion registration.

Nested conversions for derivative chains. Declare conversions inside a conversion to derive from its output rather than from the original:

class Hero extends Conversion
{
    public string $name = 'hero';

    public array $conversions = [
        HeroThumb::class,   // runs against the hero output, not the original upload
    ];

    public function apply(Media $media): void
    {
        $this->image->fit(Fit::Crop, 1600, 900);
    }
}

Use this for srcset ladders (hero → hero_800, hero_1200) so you don't re-decode the original four times, and so a change to the parent's crop automatically propagates to children after regeneration.

Queue the heavy work in production, run sync in tests.

// config/media.php
'queue_conversions' => env('MEDIA_QUEUE_CONVERSIONS', true),

Then, in TestCase::setUp(), either set that to false or Queue::fake() — otherwise a feature test that uploads a 4MB JPEG spins up image processing and slows the suite to a crawl.

Form requests do the validation, not the package. The collection can declare accepted MIME types, but user-facing error messages belong in validation:

'cover' => ['required', 'image', 'mimes:jpg,png,webp', 'max:5120'],

Do both: validation for UX, collection rules as a server-side backstop for programmatic uploads.

Metadata extraction is the underrated feature. Image dimensions, EXIF, and (with the right optional dependencies) audio/video duration land in a JSON column. Use it instead of re-reading files:

$media = $post->getFirstMedia('cover');
[$w, $h] = [$media->getMetadata('width'), $media->getMetadata('height')];

This is what lets you render correct width/height attributes — and therefore avoid CLS — without an getimagesize() call on every page load.

API resources and Blade components. Wrap getFirstMediaUrl() in a single MediaResource or <x-image> component that handles fallback, srcset from a conversion ladder, and loading="lazy". Do not scatter raw URLs through views; when you switch from public disk to S3 + CloudFront, you'll change one place.

Multi-environment disks. local in dev, s3 in staging/production, driven by env in config/media.php. Because conversions are stored alongside the original on the same disk, the whole tree moves together when you migrate storage — but existing rows keep their old disk value, so a migration script is required if you switch disks on an existing dataset.

Regeneration workflow. Change a conversion's dimensions, then:

php artisan media:regenerate --collection=cover --conversion=thumb

Treat this as a deploy step, not an afterthought. New uploads get the new size; old rows keep the old file until regenerated, and stale files remain on disk until they're overwritten (path is usually deterministic, so they get clobbered on success).

Direct-to-S3 / Livewire uploads. Store the temporary upload, then attach in the updated hook rather than on every keystroke. The package's addMedia accepts an UploadedFile or a path, so the pattern is: JS uploads to a tmp/ prefix → form submits the key → controller validates ownership of the key → addMediaFromDisk($key, 's3').

Gotchas and Tips

Conversions are lazy and cached by name. A conversion that throws (bad EXIF, corrupt file, unsupported WebP on old GD) fails only when someone requests the URL or the regenerate command runs — which may be in production, weeks later. Run media:regenerate against a production-like dataset before deploying conversion changes.

GD vs Imagick is a real behavioral fork. WebP/AVIF support, EXIF orientation handling, and colour profiles differ. Pin the driver in config/media.php and match it across local, CI, and production containers. A Docker image without libwebp will silently fall back or throw — usually the latter.

Deleting a model does not always delete its files. Verify the cleanup path: database cascade removes rows, but the files are removed by an observer / model event. Queued deletion + a failed job = orphaned S3 objects and a storage bill nobody notices for a year. Add a scheduled media:prune (or equivalent) if the package provides one, or write one that diffs the disk against the media table.

config:cache in production captures config/media.php at build time. If your deployment sets MEDIA_DISK after config:cache, you'll be writing to the wrong disk and won't find out until the first upload. Standard Laravel trap, but media makes it expensive because the files are already gone.

Transactions and file I/O don't mix. If you call addMedia() inside DB::transaction() and the transaction rolls back, the file stays on disk and the row vanishes. Wrap the attach in DB::afterCommit(), or accept orphans and prune them.

Nested conversions multiply cost, and depth matters. Each level re-decodes and re-encodes. hero → hero_800 → hero_400 is three JPEG encodes per upload. Keep chains to two levels where possible; if you need six srcset widths, that's six encodes from the parent, not a chain.

Stale conversions after a config change are not detected automatically. The conversion name is the cache key. Rename thumb to thumbnail and every existing row will regenerate on first request (or 404, depending on settings) — either pre-empt it with a regeneration deploy, or never rename.

Private disks need URLs, not paths. On S3 with private visibility, getUrl() returns a bare object URL that 403s. Use getTemporaryUrl() (or your CDN signer) in the view layer; don't let a fallback Storage::url() leak into production templates.

Sanitise filenames, don't trust them. The original filename is preserved for display, but the storage path should be generated (hash/UUID). Confirm the path generator in config is not just Str::slug($file->getClientOriginalName()) before you ship — that's a path-traversal and collision vector.

Extension points worth knowing: a custom Conversion subclass for anything the built-ins don't cover, a custom metadata extractor registered in config/media.php for non-image types, a custom path generator for multi-tenant prefixes (tenants/{id}/posts/{post}/), and swapping the Media model class in config if you need extra columns. All four are stable, documented seams — prefer them over forking.

In tests: Storage::fake('public'), Queue::fake(), and assert on the media table + disk contents rather than on generated URLs. URL assertions break the moment you change a disk or a path generator, and they tell you nothing about whether the conversion actually produced a file.

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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib