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

Nova Media Library Laravel Package

classic-o/nova-media-library

A Laravel Nova media library package for browsing, uploading, organizing, and selecting media files in a clean Nova interface. Manage assets and attach them to resources without leaving the admin panel.

View on GitHub
Deep Wiki
Context7

Getting Started

Scope check first. This is a Nova UI layer over spatie/laravel-medialibrary — a sidebar tool (browse/upload modal) plus a form field (attach media to a resource). It is not a storage engine, and Nova is a licensed dependency. If Nova isn't already in the app, there's nothing to evaluate yet.

Pin, don't float. The package targets older Nova field/tool conventions; Nova 4/5 rewrote the front-end layer. Install, then immediately edit composer.json to an exact version:

composer require classic-o/nova-media-library
# composer.json: "classic-o/nova-media-library": "x.y.z"   (not ^x.y)

A floating constraint plus a Nova minor bump is how the library modal goes blank in production.

Publish Spatie's config and migrations (note: Spatie's provider, not this package's):

php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="migrations"
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="config"
php artisan migrate

Then make three deliberate decisions in config/media-library.php before writing any code: disk_name, path_generator, and queue_conversions_by_default. The third one bites hardest if left at default.

Register the tool in NovaServiceProvider::tools():

public function tools()
{
    return [
        \ClassicO\NovaMediaLibrary\NovaMediaLibrary::make(),
    ];
}

Run php artisan nova:publish if you haven't. Sidebar entry present but modal empty ⇒ stop debugging config, you have a Nova-major asset mismatch.

Attach the field to exactly one resource. Add the persistence column first — it holds an ID, not a path:

Schema::table('posts', function (Blueprint $table) {
    $table->unsignedBigInteger('cover_id')->nullable()->index();
});
MediaLibrary::make('Cover', 'cover_id'),

Before trusting that snippet, open vendor/classic-o/nova-media-library/src/. The tool and field share a namespace and are trivially swapped, and the field's constructor argument order has drifted between releases. The source is small — read all of it; it's the best documentation you'll get.

First use case: one content-heavy resource, one hero image. Pick your Post or HelpArticle, and run the whole loop by hand once in the browser: upload through the tool → attach through the field → save → render on the front end → delete the record → inspect the media table. That single pass surfaces nearly every integration problem you'd otherwise find in staging.

Where to look, in order: (1) vendor/classic-o/nova-media-library/src/; (2) the raw media row after one upload; (3) config/media-library.php; (4) your Nova version's field API docs.

Implementation Patterns

Model the field as a foreign key, never a URL. Centralise resolution behind one accessor so Blade, API resources, emails, and sitemaps all read the same thing:

// app/Models/Post.php
public function cover()
{
    return $this->belongsTo(\Spatie\MediaLibrary\Models\Media::class, 'cover_id');
}

public function getCoverUrlAttribute(): ?string
{
    return $this->cover?->getUrl();          // or ->getUrl('thumb')
}

That accessor is the highest-leverage decision you make with this package. Change disks, add a CDN, fork the field — you edit one method. Absolute URLs baked into rows break the instant you move local → S3.

Single vs. multiple: verify, don't assume. One asset uses the plain field; galleries use a multiple() modifier. Depending on the installed version, a multi-value column may be a JSON array, a JSON object, or a delimited string. After the first save, open the row and look — guessing wrong yields a silently empty gallery with no exception and no log line. Cast only if the column really stores JSON:

protected $casts = ['gallery' => 'array'];

Write a feature test asserting the persisted shape so a package bump can't quietly change it.

Lean into the shared-library workflow — that's the actual value. The point of the tool is that an editor uploads a banner once and attaches it to many posts. Practically: create a browse-first habit ("search the library before uploading"), keep library CRUD inside the tool's modal or a dedicated Nova resource. Where a file is genuinely one-off and record-specific (an avatar, a signed PDF), a plain File::make() is the better tool — the library model just adds orphan risk there.

Define conversions on the model, next to the field that consumes them.

public function registerMediaConversions(Media $media = null): void
{
    $this->addMediaConversion('thumb')->width(400)->nonQueued();
    $this->addMediaConversion('hero')->width(1600)->nonQueued();
}

If several resources consume the same media rows, push conversions into a Media subclass so they compute once instead of per-owner.

Keep the public read path narrow. Don't leak the admin-side media model into your API contract — a future move to raw Medialibrary or a real DAM becomes a data migration rather than a rewrite:

'cover' => [
    'url'   => $this->cover_url,
    'thumb' => $this->cover?->getUrl('thumb'),
    'alt'   => $this->cover?->getCustomProperty('alt'),
],

Generate responsive sets in the accessor too, not in Blade:

public function getCoverSrcsetAttribute(): ?string
{
    return $this->cover?->getSrcset('hero');
}

Use custom properties for editorial metadata. Alt text, captions, and credits belong in getCustomProperty() / setCustomProperty(), not in new columns on media. The editor sets them in the tool's edit form. Always render alt with a fallback.

Handle deletion explicitly — polymorphic relations don't cascade.

protected static function booted()
{
    static::deleting(function (Post $post) {
        if ($post->cover?->model_type === Post::class) {
            $post->cover->delete();   // only when this record owns the media
        }
    });
}

If media are shared across records (the library case), never delete on parent delete. Schedule a sweep for media rows with zero owner references past N days, and document the policy. "Who deletes orphaned files?" is the operational hole this package leaves open.

Backfilling legacy uploads. If a path column already exists, write a one-off command that creates a Media row per legacy file via addMedia(...)->toMediaCollection() and writes the new ID back. Keep both columns live during a dual-run window, cut reads over to the accessor, then drop the old column. Two columns, one accessor, zero downtime.

One integration test is worth more than the README. Cover upload → attach → convert → detach → delete against your pinned Nova and package versions, with Storage::fake() for the disk, asserting on both the media table and the field's persisted value. Put it in CI so the next Nova bump fails loudly instead of quietly degrading the admin UI.

Gotchas and Tips

Nova compatibility is the whole ballgame. This package most likely targets Nova 2/3 conventions while your app may be on 4/5. Prove it in a scratch app against your exact Nova/Laravel/PHP matrix — not by reading the README. The blast radius is small (one model, one field, one tool), so a fork is days rather than weeks; but budget it before promising a date.

The storage format silently varies by version. JSON array vs. comma-separated IDs. A mismatch produces an empty gallery with no error, no exception, and no log entry. Inspect the raw column after the first save.

Chainable modifiers drift between releases. multiple(), disk(), folder(), single() — verify each against vendor/.../src/ before use. Old blog posts describe APIs that no longer exist. The failure mode of a silently no-op'd modifier is files landing on the default disk instead of the one you specified.

Conversions run inline by default and will time out the modal. A synchronous conversion on a 12 MB upload means a long request, high memory, and a Nova modal that dies mid-spinner. Set queue_conversions_by_default in config/media-library.php, run a worker, and mark only genuinely small conversions ->nonQueued(). Confirm image_driver (GD vs. Imagick) is present in the container — a missing Imagick extension fails at upload with an opaque error.

Local disk needs the symlink. Uploads succeed and preview fine in the Nova library while 404ing on the front end. php artisan storage:link. On S3, check bucket policy and CORS before blaming the package.

Audit upload validation before the tool reaches non-admins. The tool accepts whatever Medialibrary accepts. Check mime/extension rules, whether SVG is permitted (stored XSS if served from your own domain), and zip handling. Tighten via accepts_mime_types / max_file_size config and registerMediaCollections()->acceptsMimeTypes([...]). Gate the tool behind an admin policy until that's done.

Orphans accumulate on both sides. Deleting a post leaves the media row and the file; deleting a media row may leave the file if the disk delete fails. Sweep both directions — rows with no owner, and files on disk with no row. Without a scheduled sweep your bucket grows monotonically.

The package owns the Media model — you may now have two sources of truth. If an assets or files table already exists, decide on day one which wins, and treat media conceptually as an adapter so later consolidation is a data migration, not a rewrite.

Don't fork config into code. 'public' and /uploads/ hard-coded in a Nova resource or controller defeats the point and guarantees a painful disk migration. Disk, path generator, and conversions live in config and model methods.

Debugging a blank library modal, in order:

  1. Re-run php artisan nova:publish after any Nova change.
  2. Browser console — a 404 on the tool's compiled JS means assets weren't published or the Nova major doesn't match.
  3. storage/logs/laravel.log for a Class not found on the tool.
  4. Confirm the tool is actually returned from NovaServiceProvider::tools(), not merely imported. Missing registration renders nothing and throws nothing.

Realistic extension points: a Media model subclass (conversions, collections, custom properties), a custom path_generator, Nova-resource-level fields() composition, and a thin fork of the field's Vue component if the UI needs one more control. Anything deeper — approval workflows, versioning, rights/expiry — is out of scope and is your trigger to evaluate a real DAM.

Write the runbook on day one. Where files live, how to regenerate conversions (php artisan medialibrary:regenerate), how to find and clean orphans, and how to restore the database and bucket as a unit — they diverge, and a partial restore leaves dangling media rows pointing at nothing. This is a small, forkable bet, but only if someone is watching for the next Nova major.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
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