Install and wire up (about 10 minutes):
composer require outl1ne/nova-medialibrary
php artisan vendor:publish --provider="Outl1ne\NovaMediaLibrary\NovaMediaLibraryServiceProvider"
php artisan migrate
php artisan storage:link
The package sits on top of spatie/laravel-medialibrary, so confirm Spatie's own migrations have run and that config/medialibrary.php exists. Then publish/inspect config/nova-medialibrary.php before writing any code — the disk, the media model class, and the default library view are all decided there, and changing the media model later is a migration, not a config flip.
Where to look first, in this order:
config/nova-medialibrary.php — disk, media model, UI defaults. This is the file that causes most "why is my upload 404ing" moments later.vendor/outl1ne/nova-medialibrary/src/Fields/Medialibrary.php — the fluent API surface of the field. It's a small class; reading it is faster than guessing which chainable methods exist in your installed version.media table — everything the package does ends up as rows there (model_type, model_id, collection_name, file_name, disk).First use case — a single cover image on a Post:
// app/Models/Post.php
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Post extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('cover')->singleFile();
}
}
// app/Nova/Post.php
use Outl1ne\NovaMediaLibrary\Fields\Medialibrary;
public function fields(Request $request)
{
return [
Medialibrary::make('Cover', 'cover')
->onlyImages()
->help('Pick from the library or upload a new image.'),
];
}
If the Media Library tool doesn't appear in the sidebar, register it explicitly in NovaServiceProvider::tools() — check the package README for the exact class name in your version, since tool registration has moved around between releases.
Mental model to hold onto: the field's second argument is a media collection name, not a database column. Nothing is written to a cover column on posts. If you're expecting a string in $post->cover, that's the first thing to unlearn — read it via $post->getFirstMediaUrl('cover').
Single asset (avatar, hero image, PDF download). Use ->single() on the field and ->singleFile() on the collection. singleFile() means a new upload replaces the old one at the storage layer, which is what you want for anything 1:1. Without it you accumulate files silently.
Galleries and ordered sets. Drop ->single(), keep the collection non-single, and read with $post->getMedia('gallery'). Ordering follows Spatie's order_column; if the field exposes a sortable/reorder option in your version, use it, otherwise sort in your accessor and treat Nova ordering as best-effort. Always pair a multi-select gallery with a per-item metadata story (alt text, caption) — the media library stores file metadata, not editorial metadata, so either add a custom property to the Media model or keep a parallel JSON field keyed by media ID.
Reuse over re-upload — the real value of the library. The picker lets an editor select an image already in the library instead of uploading a duplicate. This only works well if you're disciplined about the media model: one shared Media class, one disk, and consistent collection naming across resources. Teams that let each resource invent its own collection naming end up with three copies of the same logo and no way to dedupe.
Index and detail screens. Don't render the field on the index. Use Image::make('Cover', fn () => $post->getFirstMediaUrl('cover', 'thumb')) and eager-load:
public static $with = ['media'];
Without that $with, a 50-row index fires 50 media queries. This is the single most common performance complaint with any medialibrary-backed Nova resource.
Conversions. Register them on the model and reference the conversion name wherever the field asks for a preview size:
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')->width(300)->nonQueued();
}
Keep nonQueued() for thumbnails that must appear immediately in Nova; queue the heavy derivatives. If thumbnails show as broken, you almost always need php artisan medialibrary:regenerate --only-missing.
Headless / API delivery. Nova is only the admin surface. Expose media in your API resource explicitly:
'cover' => $this->getFirstMediaUrl('cover', 'thumb'),
'gallery' => $this->getMedia('gallery')->map(fn ($m) => [
'id' => $m->id,
'url' => $m->getUrl(),
'alt' => $m->getCustomProperty('alt'),
]),
Never serialize the raw media collection — it leaks disk paths, model_type, and internal IDs.
Seeding and imports. Skip the Nova field entirely and use Spatie directly in seeders and console commands: $post->addMediaFromUrl($url)->toMediaCollection('cover'). Same table, same library, and it means your fixtures and your editors share one asset pool.
Permissions. Editors, marketers, and developers have different needs. Gate destructive access with ->canSee()/->readonly() driven by policies, and consider whether non-admin roles should be allowed to browse the whole library (the tool exposes everything on the configured disk) or only upload.
The field doesn't write a column — plan your schema accordingly. No migration is needed for the field itself, but any code that assumes $post->cover is a string will break. Also, $fillable is irrelevant here; mass-assignment has no bearing on media attachment.
onlyImages() is a picker filter, not validation. It hides non-images in the library browser. Server-side type and size enforcement still has to come from Spatie's acceptsFile() on the collection, plus PHP's upload_max_filesize / post_max_size and any proxy body limits in front of the app. Large-file uploads failing at ~2MB or ~8MB is a PHP config problem, never a Nova problem.
Disk mismatch is the top support ticket. Three places must agree: config/nova-medialibrary.php, config/filesystems.php, and Spatie's config/medialibrary.php. On local, a missing storage:link produces broken thumbnails with correct database rows. On S3, 'visibility' => 'public' and correct bucket policy are required for getUrl() to actually load.
Changing the media model later is painful. If you point the config at a custom model after launch, existing rows keep the old model_type. Decide up front if you need custom properties on media (alt text, credits, copyright) and subclass once.
Deletes don't always clean up. Deleting a Post through Nova removes the parent, but whether the underlying media record and file go with it depends on your Spatie setup and whether the media is shared. For anything reused across resources, prefer leaving the file in the library and pruning orphans on a schedule rather than cascading deletes.
single() vs singleFile() are different levers. The field-level ->single() constrains the picker to one selection; ->singleFile() constrains the collection to one file. Using only the field option lets data drift when the API or a seeder writes more than one — always set the collection constraint too.
Debugging checklist, in order: (1) does a row exist in media with the right model_type, model_id, collection_name? (2) does the file exist on the configured disk at $media->getPath()? (3) is the URL resolvable in the browser? That triage separates "field didn't save" from "storage misconfigured" from "conversion not generated" in under a minute.
Extension points worth knowing. Custom Media model for extra metadata; a subclassed field if you need to change the picker's behaviour (the package is a thin layer over Spatie, so subclassing is realistic); published Vue components if you need a different library UI; and standard Nova field decorators (->hideFromIndex(), ->rules(), ->nullable()) which pass through normally.
Budget for low bus factor. 46 stars and a single maintainer mean the package is a thin convenience wrapper, not an ecosystem. Before adopting, confirm the release line matches your Nova major version (Nova 4 vs 5 field APIs differ) and skim the changelog for breaking field-method changes. If the picker UI is the only thing you need, remember you can fall back to Spatie's own MediaLibraryFile-style patterns or a hand-rolled field without rewriting your data model — the storage layer is Spatie's, so the data survives if you drop the Nova package.
Keep the package out of your domain layer. Let your models and services talk to Spatie's API (getFirstMedia, getMedia, addMediaFromUrl); treat the Nova field as pure presentation. That way a future package swap touches app/Nova/* and nothing else.
How can I help you explore Laravel packages today?