halimtuhu/array-images
A Laravel package for handling multiple images stored as arrays. Upload, save, resize, and retrieve image collections attached to your Eloquent models with simple helper methods, keeping multi-image fields like galleries and product photos organized in one column.
Install the field
composer require halimtuhu/array-images
The package is a Nova field, so it assumes laravel/nova is already installed in your project. There is no service provider to publish or config to register — just the Composer autoload.
Prepare the database column
The whole point of the field is to write an array of image paths into a single column. Add a JSON (preferred) or TEXT column in a migration:
Schema::table('products', function (Blueprint $table) {
$table->json('gallery')->nullable();
});
Cast the attribute on the model
// app/Models/Product.php
protected $casts = [
'gallery' => 'array',
];
Without the cast, Eloquent returns the raw JSON string and every foreach/count/$product->gallery[0] in your app will misbehave.
Register the field in the Nova resource
use Halimtuhu\ArrayImages\ArrayImages;
public function fields(Request $request)
{
return [
ArrayImages::make('Gallery', 'gallery')
->disk('public')
->path('products/gallery'),
];
}
The second argument to make() is the model attribute — it must match the migration column name exactly.
Where to look first
vendor/halimtuhu/array-images/src/ — open the field class to confirm the exact namespace, the public configuration methods (disk, path, storeOriginalName, etc.), and how fill() serialises the value.vendor/halimtuhu/array-images/resources/js/ — the Vue component tells you what the field actually posts (array of strings? objects with name/url?) and what it expects back on detail/edit views.make() signature and any options the author documents.First use case
A classic "gallery" field: an editor uploads several product photos in Nova, and the API/front-end renders them from a single gallery attribute:
@foreach ($product->gallery as $path)
<img src="{{ Storage::disk('public')->url($path) }}" alt="">
@endforeach
One field covers N images, one column, one cast.
Store paths relative to the disk, not full URLs. Pick one convention on day one. The field writes whatever the upload returns; if you also store absolute URLs elsewhere in the app, Storage::disk('public')->url($path) will double-prefix. Relative paths (products/gallery/abc.jpg) survive domain changes and are the sane default for Nova's Image/File machinery.
Always drive the field through the resource, not fill() in code. Nova's fill() for uploaded fields is where the array serialisation happens. If you need to seed or import a gallery, write the array directly and rely on the model cast — do not try to reuse the field object outside Nova.
Validation belongs on the field, before it reaches the controller.
ArrayImages::make('Gallery', 'gallery')
->rules('array', 'max:6')
->rules(function ($attribute, $value, $fail) {
foreach ((array) $value as $path) {
if (! Storage::disk('public')->exists($path)) {
$fail("Missing file: {$path}");
}
}
});
Nova fields run rules() in the field's own validation pass, so the request never hits the DB with a broken shape.
Read paths in application code, never in the resource. Keep $product->gallery as a plain array in models, jobs and API resources; treat the Nova field as a write-time convenience. This keeps the domain model free of Nova coupling and makes the column easy to assert against in tests.
Clean up orphaned files with a model event or Prunable. The field updates the array; it generally does not delete the underlying file when an item is removed in the UI. Add the cleanup yourself:
protected static function booted(): void
{
static::updated(function (Product $product) {
$old = array_diff($product->getOriginal('gallery') ?? [], $product->gallery ?? []);
foreach ($old as $path) {
Storage::disk('public')->delete($path);
}
});
static::deleted(fn (Product $p) => Storage::disk('public')->delete($p->gallery ?? []));
}
Combine with the ->index() / ->onlyOnDetail() display controls the same way you would on a normal Nova Image field, so a six-image gallery does not blow up the resource index table.
API integration: once the column is cast to array, expose it directly in a JsonResource — no transformation needed. If your front-end wants a URL, build it in the resource:
'gallery' => collect($this->gallery ?? [])
->map(fn ($p) => Storage::disk('public')->url($p)),
Fork the Vue component if the UX is too narrow. Because it is a single-file Nova field, the fastest way to add drag-reorder or a download link is to copy resources/js/ into your own resource, rename the component, and point the field's $component at it. This is usually cheaper than waiting on the upstream repo, which has not released since 2019.
Nova compatibility is the biggest risk. The last release is January 2019, which places it in the Nova 1.x/2.x era. Nova changed its asset pipeline (compiled JS shipped in the package, Nova::script()/Nova::style() registration, later Vite-based build in Nova 4). The package's composer.json and its Nova::script()/Nova::style() calls will almost certainly need adjusting for Nova 3 or 4. Test on a scratch Nova install before adopting; expect to either pin an older Nova or vendor the component.
Double-encoding is the classic failure. If fill() calls json_encode() on the value and you have 'gallery' => 'array' on the model, Eloquent encodes the string again and you get "[\"a.jpg\"]" — a string that decodes to a string. If your detail view shows a literal ["/storage/..."], remove the cast or stop the field from encoding (override fill() — see extension points).
Removing an image in the UI does not delete the file. The field only rewrites the array. Orphaned files quietly accumulate in storage/app/public. Pair it with the Prunable trait or model observer above.
Missing storage:link breaks every preview. If you use the public disk, run php artisan storage:link on each environment, or the Nova field and your Blade views will both show broken image icons with valid paths.
S3 / non-public disks need explicit configuration. Pass ->disk('s3') and ensure the bucket policy allows reads; the field will store the key but the Vue preview component may generate a URL using a different disk assumption. Check the compiled JS if previews are broken but uploads succeed.
Text vs JSON columns. TEXT works with the array cast, but some MySQL versions reject defaults on TEXT. Prefer JSON on MySQL 5.7+/Postgres/SQLite. Avoid ->default([]) on TEXT.
Debugging render issues: Nova swallows a lot. If the field appears blank or as a raw input, open the browser console — Vue errors from Nova fields do not surface in Laravel's log. Confirm the component name registered in the package matches the one shipped in the compiled asset, and confirm the asset is actually registered (php artisan nova:publish after installing).
Extension points if the API is too thin: the field class extends Nova's base Field/Image field, so you can subclass it and:
fill()/resolve() for custom serialisation,fillUsing()/resolveUsing() to post-process with a closure,->withMeta(['maxFiles' => 10]) and read this.field.maxFiles in a forked Vue component,->rules(), ->nullable(), ->help() and the other base Nova field methods — most of them pass straight through.Pin the version in composer.json. With a single old release and no active maintenance, ^1.0 can drift to whatever the author tags next. Use an exact constraint ("halimtuhu/array-images": "1.0.0") and consider vendoring the package into app/Nova/Fields if you need a long-lived fork — the code is small enough that this is often the cleanest path for a production app.
How can I help you explore Laravel packages today?