melogail/multipart-field

A Laravel Nova field.

Maintainers

Package info

github.com/melogail/nova-multipart-field

Language:Vue

pkg:composer/melogail/multipart-field

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-04-01 10:41 UTC

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

  1. Requirements
  2. Installation
  3. Feature overview
  4. Screenshots
  5. Registering the field in Nova
  6. Minimal resource example
  7. Database and Eloquent model
  8. Fluent API reference
  9. Editing existing files (update)
  10. Default storage behavior
  11. Custom store() callback
  12. Validation
  13. How the HTTP request looks
  14. End-user experience (form UI)
  15. Custom field CSS (thumbnails)
  16. Preview URLs (preview())
  17. Detail and index views
  18. Download and delete
  19. Building frontend assets
  20. Troubleshooting
  21. Summary

Requirements

  • Laravel Nova v5 (aligned with the APIs used by this field)
  • PHP 8.1+ (package composer.json constraint; use the version your Laravel app requires)
  • The field is registered as a local Composer package from nova-components/MultipartField (see your app’s composer.json repositories and require entry)

Installation

From your Laravel application root (paths may match your project):

  1. Require the path repository package (if not already in composer.json):

    {
      "repositories": [
        {
          "type": "path",
          "url": "./nova-components/MultipartField"
        }
      ],
      "require": {
        "melogail/multipart-field": "*"
      }
    }
  2. Install dependencies:

    composer update melogail/multipart-field
  3. The package’s service provider should be auto-discovered (FieldServiceProvider registers the Nova scripts). If not, register Melogail\MultipartField\FieldServiceProvider manually in bootstrap/providers.php.

  4. 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).

MultipartField on a Nova resource index table

Detail

With preview() configured, stored files render as thumbnails in a row, each with View and Download links.

MultipartField on a Nova resource detail page

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()).

MultipartField on a Nova create or edit form

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), using IndexField (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:

  1. Hydrates saved paths from field.value and shows preview URLs from field.previewUrls (same order as paths) when you configure ->preview(...).
  2. Sends {attribute}_existing as a JSON-encoded array of strings — the paths the user still wants after clicking Remove on any card.
  3. 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:

  1. Reads kept paths from {attribute}_existing when present (intersected with the model’s stored paths).
  2. Reads all valid new uploads for the request key (e.g. document_paths / document_paths[]).
  3. Stores each new file with UploadedFile::store($this->getStorageDir(), $this->getStorageDisk()), or storeAs(...) if storeAs() was configured.
  4. Sets $this->attribute => merged string[], deletes removed paths from disk, and optionally sets original name / total size columns if you used storeOriginalName / 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:

  1. Appends {attribute}_existing with JSON.stringify(string[]) — paths the user kept (may be []).
  2. 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 (multiple enabled).
  • 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 title tooltip.

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 !important so 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 — from resolvePreviewUrl(): your callback receives the full field value (e.g. the whole string[] of paths). Handy for a single URL derived from the first path (same idea as download).
  • previewUrls — built by calling your preview() callback once per stored path, with that path as the first argument (a string). 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 previewUrls for existing paths when preview() 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 previewUrls is non-empty: horizontal flex wrap of cards (thumbnail up to ~120px, View / Download per file).
  • Images use Nova ImageLoader when 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() and path() at where files should live, or implement store() for full control (and handle existingPathsRequestKey() 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 array on 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.

nova-multipart-field