ardenthq/nova-image-gallery-field
A Laravel Nova field for managing image galleries directly in your admin panel. Upload, reorder, and remove multiple images with an intuitive UI, making it easy to add rich media galleries to your Nova resources.
ardenthq/nova-image-gallery-field is a single-purpose Nova field: it replaces Nova's one-file Image field with a multi-upload grid that supports drag-and-drop reordering. It is a presentation + sort-order field — it does not add conversions, thumbnails pipelines, or a media table. You get a JSON-ish array of stored file paths, in the order the editor arranged them.
composer require ardenthq/nova-image-gallery-field
php artisan vendor:publish --provider="ArdentHQ\NovaImageGalleryField\FieldServiceProvider"
php artisan nova:publish # if the field ships compiled assets under dist/
Where to look first, in this order:
README.md — copy the one-line ::make() example and note the exact class namespace; vendor-prefixed package namespaces drift between majors and this is the one thing that will stop you dead.src/ImageGallery.php — read resolve() and fill(). Everything you need to know about the storage contract (array of paths? objects with url/name? absolute or relative?) is in those two methods. Do this before writing a migration.resources/js/components/FormField.vue — this is where upload, delete, and the sortable drag-handle live. If the default UX doesn't fit (e.g. you need captions or alt text per image), this file is what you fork.src/FieldServiceProvider.php — asset publishing tags and any config merge.First use case — a merchandised product gallery:
// migration
$table->json('images')->nullable();
// Product model
protected $casts = ['images' => 'array'];
// app/Nova/Product.php
use ArdentHQ\NovaImageGalleryField\ImageGallery;
public function fields(Request $request)
{
return [
ImageGallery::make('Images')
->disk('public')
->rules('array', 'max:12')
->help('Drag to reorder — the first image is the hero.'),
];
}
Smoke test before you build on it: upload three images, reorder, save, reload the edit form, then dd($product->fresh()->images). Confirm the DB values are what you expect (relative paths vs. full URLs) and that order survives. Ten minutes here saves a refactor later.
Store paths, resolve URLs at render time. The gallery should persist relative paths (products/gallery/abc123.jpg) and you build URLs with Storage::disk($disk)->url($path) in your API resource or Blade view. If the package writes absolute URLs into the column, normalise them on read — otherwise switching from local to S3 (or changing APP_URL) silently breaks every existing row.
Treat sort order as first-class data. The whole value of this field is that index 0 == hero image. Never sort client-side after the fact; if you have an API, return the array in stored order and document that the first element is significant.
Pair with a cleanup observer. The field manages the reference, not necessarily the file. Add explicit file lifecycle handling:
Product::updated(function (Product $product) {
$removed = array_diff($product->getOriginal('images') ?? [], $product->images ?? []);
Storage::disk('public')->delete($removed);
});
Product::deleted(function (Product $product) {
Storage::disk('public')->delete($product->images ?? []);
});
Use getOriginal() (or getRawOriginal()) rather than trusting the field to cascade deletes.
Multiple galleries on one resource. Each gallery needs its own JSON column and its own disk path segment (products/gallery, products/lifestyle). Do not point two fields at the same directory unless you like orphaned files shared between two records.
Disk strategy per environment. ->disk('public') is fine locally; on a CDN/tenant setup, resolve the disk dynamically — ->disk(fn () => tenant()->disk()) if the field supports closures, otherwise a small subclass overriding the disk property. Keep the disk name in config, never inline 's3' in the Nova resource.
Validation belongs on the array. Nova file fields validate odd things, so be explicit: ->rules('array', 'max:12') for the container, plus 'images.*' => 'image|max:5120' as a secondary rule if you add a rules() array. Validate mimes:jpeg,png,webp rather than trusting extension detection.
Index view. Rendering a full grid in Nova's index table is a performance footgun on 50-row pages. Either ->onlyOnDetail() or ->hideWhenUpdating() for the heavy cases, and show a count/text preview on the index.
Testing. Don't drive the Vue component; test the field contract:
Storage::fake('public');
$field = ImageGallery::make('Images')->disk('public');
$field->fill($request, $product);
$this->assertCount(2, $product->fresh()->images);
Storage::disk('public')->assertExists($product->images[0]);
Combine with UploadedFile::fake()->image('hero.jpg') and assert order after a second fill() with reversed input.
Migration path. If you're coming from Spatie MediaLibrary or a legacy image column, write a one-off artisan command that copies files into the gallery directory and writes the array column — don't try to make the field read two sources.
Extension points. Because the package is archived, treat it as your code: composer require with a VCS or path repository pointing at a fork under packages/, and patch it in place. That converts "unmaintained dependency" into "internal component."
It is archived. 18 stars, zero dependents, no maintenance path. It works today on Nova 4/5, but a future Nova release can break it with no upstream fix. Fork it, pin it, and budget an afternoon per Nova major.
File deletion is probably your problem. Verify whether removing a thumbnail from the grid deletes the underlying file. Most fields of this shape don't, or do so only on the same request. Assume orphans and schedule a Storage::delete() reconciliation command comparing directory contents against all images columns.
Path vs. URL confusion. The most common bug: your accessor does asset($path) on a path that already contains a disk prefix, producing /storage/products/gallery/products/gallery/x.jpg. Dump raw DB values once and write the URL helper against that truth.
Rebuild assets after every deploy. Nova fields compile Vue into dist/. If nova:publish isn't in your deploy script, you get a white box in the form after a composer install on a fresh server. Add it to your deploy hooks — this is the #1 "it works locally" report for every Nova field.
PHP limits bite before the field does. Multi-upload of 8×8MB shots fails silently at upload_max_filesize / post_max_size / max_file_uploads (default 20). Check php -i | grep -E 'upload_max|post_max|max_file_uploads' and raise Nginx client_max_body_size too. The field's JS gives weak error surfacing — check the Network tab, not the toast.
Drag-to-reorder doesn't persist until the form saves. Editors will reorder, navigate away, and lose it. Either accept it (consistent with Nova) or add a note in ->help().
JSON column quirks. MySQL 5.7+ JSON reorders object keys but preserves array order — good. Postgres jsonb is also fine for arrays. Do not cast to collection if the field's fill() expects a plain array; a Collection cast round-trip can silently produce [].
dependsOn and reactive fields. If the gallery lives next to a type selector, ->dependsOn() triggers a re-render that can drop unsaved file state. Test that specific interaction or hide the gallery behind ->canSee().
Multi-tenancy. Per-tenant buckets need the disk resolved at request time, not at class-definition time. If the field caches $this->disk in the constructor, subclass and override.
Debugging checklist, in order: dd($field->value) server-side in the resource → Network tab payload on save → raw DB row → Storage::disk(...)->allFiles($dir). That isolates field vs. persistence vs. filesystem in four steps.
When not to use it. If you need alt text, captions, focal points, responsive conversions, or per-image metadata, this field will fight you — jump to Spatie MediaLibrary plus a small custom Nova field, or Outl1ne/nova-media-hub. If you need one hero image plus a loose set, Nova's built-in Image field plus a plain File array is less machinery. Choose this package only for the narrow case it nails: an ordered, human-curated list of images with zero metadata.
How can I help you explore Laravel packages today?