Could we help you? Please click the banners. We are young and desperately need the money
You're building a Laravel app. Users need to upload a profile photo. Simple enough —
you grab the file from the request, store it somewhere, save the path in the database,
and move on.
Then your client asks for thumbnails. Then a second upload field for a cover image.
Then the mobile app needs URLs in API responses. Then someone wants to replace a file
without orphaning the old one. Suddenly your "simple" file handling is a mess of
custom columns, manual path building, and one-off image resize jobs.
This is the problem Spatie Media Library solves. Instead of managing files yourself,
you attach them to your Eloquent models and let the package handle storage, retrieval,
conversions, and cleanup.
In this guide we'll cover what the package actually does, how it works under the hood,
when you should use it, and how to get started with the features you'll use every day.
Spatie Media Library (spatie/laravel-medialibrary) is an open source Laravel package
that lets you associate files of any kind — images, PDFs, videos, documents — with
your Eloquent models through a clean, consistent API.
Instead of storing file paths scattered across dozens of database columns, the package
uses a single media table to track every file, which model it belongs to, and where
it's stored. Your models stay clean, and the package handles the rest.
Before installing any package it's worth asking: do I actually need this, or can I
handle it myself?
Here's an honest comparison.
// Storing a file manually
$path = $request->file('avatar')->store('avatars', 'public');
$user->update(['avatar_path' => $path]);
// Getting the URL
$url = Storage::disk('public')->url($user->avatar_path);
// Deleting and replacing
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$newPath = $request->file('avatar')->store('avatars', 'public');
$user->update(['avatar_path' => $newPath]);
This works fine for a single file. But it gets messy fast:
avatar_path, cover_path, resume_path...)avatar_thumb_path)// Storing a file
$user->addMediaFromRequest('avatar')->toMediaCollection('avatar');
// Getting the URL
$user->getFirstMediaUrl('avatar');
// Replacing (old file is automatically removed)
$user->clearMediaCollection('avatar');
$user->addMediaFromRequest('avatar')->toMediaCollection('avatar');
Same result, but the package handles the filesystem operations, tracks everything in
one media table, and cleans up old files automatically.
Use Spatie Media Library when:
Stick with manual file handling when:
For most real projects, the package is worth it from the second model that needs a file.
The package requires PHP 8.2 or higher and Laravel 10 or higher.
Install via Composer:
composer require spatie/laravel-medialibrary
Publish and run the migration. This creates the media table that stores all file
records:
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate
Optionally publish the config file if you want to customize storage disk, file size
limits, or queue settings:
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-config"
By default the package stores files on Laravel's public disk. For most beginner
projects that's fine. Make sure you've run:
php artisan storage:link
This creates the symlink from public/storage to storage/app/public so uploaded
files are web-accessible. If you skip this step your file URLs won't work.
To attach media to a model, add the HasMedia interface and the InteractsWithMedia
trait to it. That's all the setup required on the model side.
Let's use a Post model as our example:
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Post extends Model implements HasMedia
{
use InteractsWithMedia;
protected $fillable = ['title', 'body'];
}
Notice there are no file path columns on the model or in its migration. The media
table handles all of that separately. Your posts table stays clean.
Once your model implements HasMedia, you can attach files to it in several ways.
// In your controller
public function store(Request $request)
{
$request->validate([
'title' => 'required|string',
'image' => 'required|image|max:2048',
]);
$post = Post::create(['title' => $request->title, 'body' => $request->body]);
$post->addMediaFromRequest('image')
->toMediaCollection('featured-image');
return response()->json($post, 201);
}
Useful when processing files that aren't directly uploaded by users:
$post->addMedia('/path/to/file.pdf')
->toMediaCollection('attachments');
Download and attach a remote file directly:
$post->addMediaFromUrl('https://example.com/image.jpg')
->toMediaCollection('featured-image');
Getting files back out is just as straightforward.
// Get the URL of the first file in a collection
$url = $post->getFirstMediaUrl('featured-image');
// Get the first Media object (with full details)
$media = $post->getFirstMedia('featured-image');
echo $media->file_name; // image.jpg
echo $media->mime_type; // image/jpeg
echo $media->size; // 204800 (bytes)
echo $media->getUrl(); // https://yourapp.com/storage/1/image.jpg
// Get all media in a collection
$allMedia = $post->getMedia('featured-image');
foreach ($allMedia as $media) {
echo $media->getUrl();
}
Collections are named groups of media on a model. Think of them as labeled slots:
a Post might have a featured-image collection (one image) and an attachments
collection (multiple files).
By default every collection accepts any number of files and any file type. You define
and constrain collections in a registerMediaCollections method on your model:
// app/Models/Post.php
use Spatie\MediaLibrary\MediaCollections\Models\Media;
public function registerMediaCollections(): void
{
// Single file collection — new uploads replace the existing one
$this->addMediaCollection('featured-image')
->singleFile();
// Multiple files, images only
$this->addMediaCollection('gallery')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
// Any file type, no restrictions
$this->addMediaCollection('attachments');
}
The singleFile() method is particularly useful — when you call addMediaFromRequest
on a single file collection, the old file is automatically deleted before the new one
is stored. No orphaned files, no manual cleanup.
This is where the package really earns its place. Conversions let you automatically
generate resized versions of images when a file is added to a collection.
Define conversions in a registerMediaConversions method on your model:
// app/Models/Post.php
use Spatie\MediaLibrary\MediaCollections\Models\Media;
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(300)
->height(300);
$this->addMediaConversion('banner')
->width(1200)
->height(400);
}
When you add an image to any collection on this model, the package automatically
generates a thumb (300x300) and a banner (1200x400) version in the background.
Access a specific conversion by passing its name as the second argument:
// Original image
$post->getFirstMediaUrl('featured-image');
// Thumbnail version
$post->getFirstMediaUrl('featured-image', 'thumb');
// Banner version
$post->getFirstMediaUrl('featured-image', 'banner');
Each conversion is stored as a separate file alongside the original. You don't manage
any of this manually — the package handles generation and storage automatically.
Image conversions run on a queue to avoid slowing down your HTTP requests. Make sure
your queue worker is running during development:
php artisan queue:work
If you want a conversion to run synchronously (immediately, not on a queue) — for
example during tests — add nonQueued():
$this->addMediaConversion('thumb')
->width(300)
->height(300)
->nonQueued();
When building an API you'll want media URLs included in your JSON responses. The
cleanest way is to add a computed attribute to your model or API resource.
// app/Models/Post.php
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function featuredImageUrl(): Attribute
{
return Attribute::get(
fn () => $this->getFirstMediaUrl('featured-image') ?: null
);
}
protected function thumbnailUrl(): Attribute
{
return Attribute::get(
fn () => $this->getFirstMediaUrl('featured-image', 'thumb') ?: null
);
}
// Usage in a controller
return response()->json([
'id' => $post->id,
'title' => $post->title,
'featured_image_url' => $post->featured_image_url,
'thumbnail_url' => $post->thumbnail_url,
]);
For larger projects, API Resources are the cleaner approach:
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'featured_image_url' => $this->getFirstMediaUrl('featured-image') ?: null,
'thumbnail_url' => $this->getFirstMediaUrl('featured-image', 'thumb') ?: null,
'gallery' => $this->getMedia('gallery')->map(fn ($m) => [
'url' => $m->getUrl(),
'thumb_url' => $m->getUrl('thumb'),
]),
];
}
}
A User model with a single avatar that gets replaced when updated:
// app/Models/User.php
public function registerMediaCollections(): void
{
$this->addMediaCollection('avatar')->singleFile();
}
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')->width(100)->height(100);
$this->addMediaConversion('medium')->width(300)->height(300);
}
// In your controller
public function updateAvatar(Request $request)
{
$request->validate(['avatar' => 'required|image|max:1024']);
// Because the collection is singleFile(), the old avatar is deleted automatically
$request->user()
->addMediaFromRequest('avatar')
->toMediaCollection('avatar');
return response()->json([
'avatar_url' => $request->user()->getFirstMediaUrl('avatar', 'medium'),
]);
}
A Product model where you can upload several gallery images, each with a thumbnail:
// app/Models/Product.php
public function registerMediaCollections(): void
{
$this->addMediaCollection('gallery')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
}
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')->width(200)->height(200);
$this->addMediaConversion('preview')->width(800)->height(600);
}
// Adding multiple images at once
public function uploadGallery(Request $request, Product $product)
{
$request->validate(['images.*' => 'required|image|max:4096']);
foreach ($request->file('images') as $image) {
$product->addMedia($image)->toMediaCollection('gallery');
}
return response()->json([
'gallery' => $product->getMedia('gallery')->map(fn ($m) => [
'id' => $m->id,
'url' => $m->getUrl(),
'thumb' => $m->getUrl('thumb'),
]),
]);
}
When a user removes a specific image from a gallery:
use Spatie\MediaLibrary\MediaCollections\Models\Media;
public function deleteMedia(Request $request, Product $product, Media $media)
{
// Make sure the media belongs to this product
abort_if($media->model_id !== $product->id, 403);
$media->delete(); // Also deletes the file from storage and all conversions
return response()->json(['message' => 'Image removed.']);
}
Files get stored but URLs return 404s. Always run this after setting up a new project:
php artisan storage:link
You upload an image but conversions never appear. This is almost always because the
queue worker isn't running.
Bad:
# No worker running — conversions silently queue up and never process
php artisan serve
Good:
# Run these in two separate terminals during development
php artisan serve
php artisan queue:work
Or use nonQueued() on your conversions during local development to skip the queue entirely.
The whole point of the package is that it manages file storage. Don't duplicate this.
Bad:
// Adding a media column to your own table defeats the purpose
$post->update(['image_path' => $post->getFirstMediaUrl('featured-image')]);
Good:
// Always retrieve URLs directly from the media library
$post->getFirstMediaUrl('featured-image');
This causes an N+1 query problem — one database query per model.
Bad:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->getFirstMediaUrl('featured-image'); // Queries DB every iteration
}
Good:
$posts = Post::with('media')->get(); // Load all media in one query
foreach ($posts as $post) {
echo $post->getFirstMediaUrl('featured-image'); // Uses cached result
}
Always use ->with('media') when loading collections of models that have media.
Never pass uploaded files to the media library without validating them first.
Bad:
$post->addMediaFromRequest('image')->toMediaCollection('featured-image');
Good:
$request->validate(['image' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp']);
$post->addMediaFromRequest('image')->toMediaCollection('featured-image');
# Install
composer require spatie/laravel-medialibrary
# Publish and run migrations
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate
# Create storage symlink
php artisan storage:link
class Post extends Model implements HasMedia
{
use InteractsWithMedia;
}
$model->addMediaFromRequest('field')->toMediaCollection('collection');
$model->addMedia('/local/path/file.jpg')->toMediaCollection('collection');
$model->addMediaFromUrl('https://example.com/file.jpg')->toMediaCollection('collection');
$model->getFirstMediaUrl('collection'); // URL of first file
$model->getFirstMediaUrl('collection', 'thumb'); // URL of a conversion
$model->getFirstMedia('collection'); // Media object
$model->getMedia('collection'); // All media in collection
public function registerMediaCollections(): void
{
$this->addMediaCollection('avatar')->singleFile();
$this->addMediaCollection('gallery')->acceptsMimeTypes(['image/jpeg', 'image/png']);
}
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')->width(300)->height(300);
$this->addMediaConversion('thumb')->width(300)->height(300)->nonQueued(); // Synchronous
}
$model->clearMediaCollection('collection'); // Delete all media in collection
$media->delete(); // Delete a specific media item
Spatie Media Library takes the repetitive, error-prone parts of file handling in
Laravel and replaces them with a clean, consistent API. One media table, one trait
on your model, and you get file uploads, image conversions, collections, and URL
retrieval for every model in your app.
Key takeaways:
composer require spatie/laravel-medialibrary, publish migrations, and run storage:linkHasMedia interface and InteractsWithMedia trait to any model that needs files — no extra database columns requiredaddMediaFromRequest()->toMediaCollection() to attach uploaded files to a modelregisterMediaCollections() — use singleFile() for avatars and profile imagesregisterMediaConversions() to auto-generate thumbnails and resized versions->with('media') when loading lists of models to avoid N+1 queriesphp artisan queue:work during development so image conversions are processedOnce you're comfortable with the basics, the package has a lot more to explore —
S3 and cloud storage, responsive images, custom path generators, and the paid
Media Library Pro for drag-and-drop upload components. But the fundamentals covered
here will get you through the vast majority of real-world use cases.