melogail / multipart-field
A Laravel Nova field.
Package info
github.com/melogail/nova-multipart-field
Language:Vue
pkg:composer/melogail/multipart-field
Requires
- php: ^8.1
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/nova: ^5.0
- laravel/nova-devtool: ^1.7
This package is auto-updated.
Last update: 2026-04-01 10:46:18 UTC
README
A Nova field for multiple file uploads on create/update forms. It provides a drag-and-drop zone, multi-select file input, fixed-size thumbnails for previews (with CSS that overrides Nova’s global img rules), per-file remove before submit, and full edit support: existing stored paths are shown on the update form, users can remove individual files, and the model’s path array is updated while orphan files are deleted from disk (default storage).
Package namespace: Melogail\MultipartField
Vue component key: multipart-field
Table of contents
- Requirements
- Installation
- Feature overview
- Screenshots
- Registering the field in Nova
- Minimal resource example
- Database and Eloquent model
- Fluent API reference
- Editing existing files (update)
- Default storage behavior
- Custom
store()callback - Validation
- How the HTTP request looks
- End-user experience (form UI)
- Custom field CSS (thumbnails)
- Preview URLs (
preview()) - Detail and index views
- Download and delete
- Building frontend assets
- Troubleshooting
- Summary
Requirements
- Laravel Nova v5 (aligned with the APIs used by this field)
- PHP 8.1+ (package
composer.jsonconstraint; use the version your Laravel app requires) - The field is registered as a local Composer package from
nova-components/MultipartField(see your app’scomposer.jsonrepositoriesandrequireentry)
Installation
From your Laravel application root (paths may match your project):
-
Require the path repository package (if not already in
composer.json):{ "repositories": [ { "type": "path", "url": "./nova-components/MultipartField" } ], "require": { "melogail/multipart-field": "*" } } -
Install dependencies:
composer update melogail/multipart-field
-
The package’s service provider should be auto-discovered (
FieldServiceProviderregisters the Nova scripts). If not, registerMelogail\MultipartField\FieldServiceProvidermanually inbootstrap/providers.php. -
Build the field’s JavaScript and CSS (required for Nova to load the form UI and thumbnail styles):
cd nova-components/MultipartField && npm ci && npm run prod
Or use your app’s script if defined, e.g.
npm run build-multipart-field-prod.
Feature overview
| Area | What you get |
|---|---|
| Create | Drag-and-drop + multi file picker, accept filtering, local previews before upload, remove from pending selection. |
| Update | Lists saved files (with server previewUrls when preview() is set), remove updates the kept list; new files append. Submit merges kept paths + new uploads into one array. |
| Storage (default) | Each upload stored on disk under path; model attribute = string[] of paths. Paths removed on edit are deleted from the disk as well as dropped from the array. |
| Security | Kept paths from the client are intersected with the model’s previous paths so arbitrary paths cannot be injected. |
| Form layout | Saved and new files render as compact cards in a wrapping row (side by side until wrap). 64×64px thumbnails (.multipart-field-thumb in field.css). |
| Filenames | Long names show start…end with extension preserved; full name on hover (title). |
| Spacing | Extra gap and margin between file cards. |
| Index | Shown by default (showOnIndex = true): document icon + badge count; tooltip lists up to five basenames. |
| Detail | Side-by-side previews (when URLs exist), per-file View / Download, fallback toolbar download. |
| Validation | Parent attribute as array; per-upload rules on {attribute}.*; optional {attribute}_existing as JSON string. |
Screenshots
Examples from a Nova Documents resource using MultipartField for the Documents column.
Index
On the resource index, the field shows a document icon and a badge with the file count; hover the cell for a tooltip listing file names (see Detail and index views).
Detail
With preview() configured, stored files render as thumbnails in a row, each with View and Download links.
Create / edit form
The drag-and-drop zone shows the primary prompt, accepted extensions (from accept()), and optional help text (e.g. per-file size rules via help()).
Registering the field in Nova
In your resource’s fields() method, import and add the field:
use Melogail\MultipartField\MultipartField; public function fields(NovaRequest $request): array { return [ // ... MultipartField::make('Attachments', 'attachments') ->accept('.pdf,.jpg,.jpeg,.png') ->disk(config('nova.storage_disk', 'public')) ->path('uploads/attachments'), // ... ]; }
- First argument: Label shown in Nova.
- Second argument: Request attribute name (and usually the model attribute that receives the stored result). The browser submits new files as
attachments[]. - Index table: the field is shown on the index view by default (
showOnIndex = true), usingIndexField(file icon + count). Hide it with->hideFromIndex()if you do not want a column.
Minimal resource example
use Illuminate\Http\Request; use Illuminate\Support\Arr; use Laravel\Nova\Http\Requests\NovaRequest; use Melogail\MultipartField\MultipartField; MultipartField::make('Documents', 'document_paths') ->accept('.pdf,.jpg,.jpeg,.png,.webp') ->disk(config('nova.storage_disk', 's3')) ->path('users/documents') ->rules('nullable', 'array') ->eachFileRules('file', 'max:2048', 'mimes:pdf,jpg,jpeg,png,webp');
With only disk() and path() and no custom ->store(), the field uses the default storage behavior and sets document_paths on the model to an array of stored path strings.
For create vs update rules (e.g. require files only on create), use a NovaRequest closure:
->rules(function (NovaRequest $request) { return $request->isCreateOrAttachRequest() ? ['required', 'array', 'min:1'] : ['nullable', 'array']; })
Database and Eloquent model
The default implementation assigns the field attribute to a PHP array of strings (storage paths). Persist that in one of these ways:
Option A: JSON column (recommended)
Migration:
$table->json('document_paths')->nullable();
Model:
protected $fillable = ['document_paths', /* ... */]; protected function casts(): array { return [ 'document_paths' => 'array', // ... ]; }
Option B: Custom store() only
You do not have to store paths on the same attribute: your store() callback can write related models, a single string, etc., and return the attributes Nova should assign (see Custom store() callback). If you customize storage, you are responsible for reading MultipartField::existingPathsRequestKey($attribute) (e.g. document_paths_existing) and merging kept paths when you need the same edit behavior.
Fluent API reference
| Method | Description |
|---|---|
make(string $name, ?string $attribute = null, ?string $disk = null, ?callable $storageCallback = null) |
Create the field. Optional 3rd/4th constructor args set initial disk and storage callback (same as chaining disk() / store()). Prefer MultipartField::make('Label', 'attr') for clarity. |
disk(?string $disk) |
Filesystem disk name (from config/filesystems.php). If omitted, falls back to config('nova.storage_disk', 'public'). (From Storable trait.) |
path(string $path) |
Directory on that disk (passed to UploadedFile::store()). Default in trait is '/'. |
store(callable $callback) |
Custom persistence. Receives six arguments (see below). |
storeAs(callable $callback) |
Custom filename per file (same arity as Nova’s File::storeAs). Used when storing each upload in the default multi-file flow. |
storeOriginalName(string $column) |
When using default storage, also set $column to the first uploaded file’s client original name. |
storeSize(string $column) |
When using default storage, set $column to the sum of all uploaded files’ sizes (bytes). |
accept(string $accept) |
Sets the HTML accept attribute (e.g. '.pdf,.png' or MIME list). Exposed to Vue as field.accept. |
preview(callable $callback) |
Nova HasPreview API: return a URL string (or null) for the detail/index preview. See Preview URLs. |
rules(...) |
Standard Nova validation (from Field). Use array on the attribute and eachFileRules() for per-file rules. |
eachFileRules(...) |
Registers validation on {attribute}.* (each uploaded file). Pair with ->rules('required', 'array', 'min:1') on create, or nullable on update. |
Static helper:
| Method | Description |
|---|---|
MultipartField::existingPathsRequestKey(string $requestAttribute) |
Returns {attribute}_existing — the form POST key for the JSON array of paths the user chose to keep on update. |
Other Nova Field methods (hideFromIndex, readonly, help, dependsOn, etc.) work as usual.
Editing existing files (update)
On update, the form:
- Hydrates saved paths from
field.valueand shows preview URLs fromfield.previewUrls(same order as paths) when you configure->preview(...). - Sends
{attribute}_existingas a JSON-encoded array of strings — the paths the user still wants after clicking Remove on any card. - Sends new picks as
{attribute}[]as on create.
The default store callback:
- Merges kept paths (validated against the model’s previous paths) then appends newly uploaded paths.
- Computes paths that disappeared from that merged list and
Storage::disk(...)->delete($path)for each.
If the request does not include {attribute}_existing, only new uploads are used as the path list (same as a fresh create) — the Vue field always appends _existing on submit, so normal Nova forms get correct merge behavior.
Default storage behavior
If you do not call ->store() with your own callback, the field:
- Reads kept paths from
{attribute}_existingwhen present (intersected with the model’s stored paths). - Reads all valid new uploads for the request key (e.g.
document_paths/document_paths[]). - Stores each new file with
UploadedFile::store($this->getStorageDir(), $this->getStorageDisk()), orstoreAs(...)ifstoreAs()was configured. - Sets
$this->attribute=> mergedstring[], deletes removed paths from disk, and optionally sets original name / total size columns if you usedstoreOriginalName/storeSize(only from new uploads when those are non-empty).
Nova then assigns those keys on the model (same idea as File returning an array of columns).
Fill runs when there is at least one new upload or the {attribute}_existing key is present — so “only remove files, no new uploads” still persists.
Custom store() callback
Your callback receives six arguments (same order as Laravel Nova’s File field):
function ( \Illuminate\Http\Request $request, \Illuminate\Database\Eloquent\Model|\Laravel\Nova\Support\Fluent $model, string $attribute, // Nova attribute name (usually same as request key) string $requestAttribute, // Request key for files (e.g. 'document_paths') ?string $disk, // Resolved disk from ->disk() / Nova default string $dir // Storage directory from ->path() (Storable) ) { // ... }
Reading uploaded files
Always normalize to a list of UploadedFile instances:
use Illuminate\Support\Arr; $files = array_values(array_filter( Arr::wrap($request->file($requestAttribute) ?? []) ));
Reading kept paths on update
use Melogail\MultipartField\MultipartField; $key = MultipartField::existingPathsRequestKey($requestAttribute); $raw = $request->input($key); $decoded = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : []); // Intersect with $model's previous paths for security, then merge with new stored paths.
Return value (Nova merge rules)
Same contract as File::store():
| Return | Effect |
|---|---|
true |
No model changes from this return path. |
Closure |
Deferred work (Nova handles like File). |
string |
Sets $model->{$attribute} to that string. |
array |
For each key => value, sets $model->{$key} = $value. |
Validation
Uploads are sent as {attribute}[], so Laravel sees file_paths as an array of UploadedFile instances. You must not put the file rule on the main attribute only (that validates the whole array as one file and fails with “must be a file”).
Validate the array on the field, and each element with eachFileRules() (registers {attribute}.* for you):
MultipartField::make('Files', 'attachments') ->rules('required', 'array', 'min:1') ->eachFileRules('file', 'max:5120', 'mimes:pdf,jpg,jpeg,png');
The field also registers {attribute}_existing as nullable|string (JSON body from the form). You may add json or custom rules if needed.
Or pass a single array / callable to eachFileRules:
->eachFileRules(['file', 'max:5120', 'mimes:pdf,jpg,jpeg,png']) ->eachFileRules(fn (NovaRequest $request) => ['file', 'max:10240'])
Use nullable instead of required on the array when uploads are optional (and drop min:1 or use min:0 as appropriate). On update, use nullable|array on the upload key if the user may keep existing files without uploading new ones (see minimal example above).
How the HTTP request looks
The Vue form:
- Appends
{attribute}_existingwithJSON.stringify(string[])— paths the user kept (may be[]). - Appends each new selected file under
{attribute}[].
Examples for attribute document_paths:
document_paths_existing— JSON string, e.g.["uploads/a.pdf","uploads/b.jpg"]document_paths[]— repeated multipart file parts
Laravel exposes new files as:
$request->file('document_paths'); // array of UploadedFile (or single UploadedFile if one file)
End-user experience (form UI)
- Saved files (update): Shown above the drop zone as compact cards: 64×64px thumbnail (or extension chip), short filename (middle replaced with
..., extension kept), “Saved” line, Remove (updates kept list only until save). - New files: Same card layout below the drop zone; shows file size instead of “Saved”.
- Layout: Cards use flex + wrap with horizontal gap and margin so multiple files sit side by side until the row wraps.
- Click the dashed area to open the system file picker (
multipleenabled). - Drag and drop files onto the zone (highlight on drag-over).
- Remove on a card drops that file from the pending selection (new) or from the kept list (existing).
- Readonly fields: no add/remove/drop.
accept: restricts picker and is shown as helper text when set.- Full filename: Hover the label to see the complete name via the native
titletooltip.
Custom field CSS (thumbnails)
Nova’s panel styles often include img { max-width: 100%; height: auto }, which breaks small fixed previews. This package ships resources/css/field.css (built to dist/css/field.css) with:
.multipart-field-thumb— fixed 4rem × 4rem box..multipart-field-thumb img— absolutely positioned,object-fit: cover,max-width/max-height: none !importantso previews stay thumbnail-sized..multipart-field-thumb__placeholder— centers the extension label for non-images.
Rebuild CSS after edits:
cd nova-components/MultipartField && npm run prod
Preview URLs (preview())
The field uses Nova’s HasPreview trait, so you customize the preview the same way as File::make()->preview(...).
Callback signature
preview(function (mixed $value, ?string $disk, mixed $resource): ?string { // Return an absolute or relative URL Nova can load in an <img> / iframe, or null for no preview. })
Arguments match Nova’s preview resolver:
| Argument | Meaning |
|---|---|
$value |
The field’s resolved value (after resolve()). With default multipart storage this is usually a string[] of disk paths; with a custom store() it may be a string, array, or something else. For previewUrls, the callback is invoked once per path with that path as the first argument (string). |
$disk |
The field’s storage disk name (getStorageDisk()), same as for File. |
$resource |
The underlying model (or resource wrapper Nova passes through). Often unused. |
Nova serializes:
previewUrl— fromresolvePreviewUrl(): your callback receives the full field value (e.g. the wholestring[]of paths). Handy for a single URL derived from the first path (same idea as download).previewUrls— built by calling yourpreview()callback once per stored path, with that path as the first argument (astring). The form uses this list on update so each saved file can show an image thumb; the detail view uses it for multiple previews.
Write your callback so it works when $value is either the full array (for previewUrl) or a single path string (for each previewUrls entry), e.g. $path = is_array($value) ? ($value[0] ?? null) : $value;
Example: private disk / S3 temporary URL
use Illuminate\Support\Facades\Storage; MultipartField::make('Multiple Files', 'file_paths') ->disk(config('nova.storage_disk', 's3')) ->path('users/documents') ->preview(function ($value, $disk) { $path = is_array($value) ? ($value[0] ?? null) : $value; if (! is_string($path) || $path === '') { return null; } return Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(5)); });
Example: public public disk
->disk('public') ->preview(function ($value, $disk) { $path = is_array($value) ? ($value[0] ?? null) : $value; return $path ? Storage::disk($disk)->url($path) : null; });
Default behavior
The package constructor sets a no-op preview (null URL) until you chain ->preview(...). If you do not call preview(), Nova will not show image URLs for this field until you add URLs via customization.
Form vs detail preview
- Create/update form: Local object URLs for new picks; server
previewUrlsfor existing paths whenpreview()is configured. preview(): Used when Nova displays stored values (detail, index meta, and update form for saved files).
Detail and index views
Detail (DetailField.vue)
- When
previewUrlsis non-empty: horizontal flex wrap of cards (thumbnail up to ~120px, View / Download per file). - Images use Nova
ImageLoaderwhen the URL looks like an image extension; otherwise a “File n” placeholder with download link. - Fallback: single
previewUrl/thumbnailUrl, then raw value, then em dash. - Optional toolbar download when previews are empty but the field is downloadable.
Index (IndexField.vue)
- Document icon + badge with file count.
- Tooltip: “N files” plus up to five basenames (paths truncated with “+N more” if needed).
- Respects field text alignment (
left/center/right).
Download and delete
The field registers Nova download and delete behaviors similar to File:
- Download: uses the first path in the stored array (or the only string) to stream a download. If there are no paths, responds 404.
- Delete: removes every path in the stored array (or a single string path) from the configured disk, then clears the field-related columns (including optional original name / size columns).
If you need different download behavior (e.g. ZIP of all files), replace the download logic via Nova’s field customization patterns or disable download and handle downloads elsewhere.
Building frontend assets
After changing resources/js or resources/css under this package:
cd nova-components/MultipartField npm run dev # development npm run prod # production
Commit updated dist/ files if your deployment does not build Nova tools in CI.
Troubleshooting
| Issue | What to check |
|---|---|
| Field shows as empty / broken in Nova | Run npm run prod in the package; clear browser cache; confirm FieldServiceProvider is loaded. |
| Previews huge / not thumbnail-sized | Ensure dist/css/field.css is loaded (rebuild after resources/css/field.css changes). The .multipart-field-thumb rules override Nova img styles. |
Attempt to read property on null on save |
Ensure disk exists in config/filesystems.php and nova.storage_disk is valid if you rely on defaults. |
| Validation always fails | Use per-file rules via eachFileRules(); ensure max size aligns with PHP upload_max_filesize / post_max_size. On update, avoid `required |
| Model not saving paths | Attribute must be $fillable (or explicitly allowed). For JSON storage, use a json column and array cast. |
Custom store() not running |
Fill runs when there is at least one upload or {attribute}_existing is present. Implement merge + disk cleanup yourself if you replace the default callback. |
| Edit form shows no thumbnails for saved files | Configure ->preview(...) so Nova serializes previewUrls for each stored path. |
Summary
- Add
MultipartField::make('Label', 'attribute')to your Nova resource. - Point
disk()andpath()at where files should live, or implementstore()for full control (and handleexistingPathsRequestKey()if you need the same edit semantics). - Default behavior sets
attribute=>string[]of stored paths; merges kept + new on update; deletes removed paths from disk; use a JSON + array cast on the model for persistence. - Validate with
arrayon the attribute,eachFileRules()for per-file rules, and consider different rules for create vs update. - Request includes
{attribute}_existing(JSON) and{attribute}[](files). - Use
preview()so detail, index, and update form can show URLs per file (previewUrls). - Rebuild
dist/(JS + CSS) whenever Vue or field CSS changes.
For implementation details, see src/MultipartField.php, resources/js/components/FormField.vue, DetailField.vue, IndexField.vue, and resources/css/field.css.


