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 Nested Form Laravel Package

yassi/nova-nested-form

A Laravel Nova field that embeds nested relationship forms directly inside a parent resource form, letting you create and edit related models inline without leaving the page. It simplifies complex data entry by keeping nested forms and validation within the main Nova workflow.

View on GitHub
Deep Wiki
Context7

Getting Started

Vet the version matrix before anything else. The last tagged release is May 2021, which predates Nova 4's Vue 3 rewrite. Run:

composer show laravel/nova | grep versions
php -v

On Nova 3.x you're in business. On Nova 4/5, stop — the Vue 2 component will not mount, and the failure is often silent (field renders empty, form submits without the nested key, save "succeeds" while wiping children). Treat everything below as a Nova 3 playbook.

Install and pin hard.

composer require yassilah/laravel-nova-nested-form

Then commit composer.lock immediately and add an explicit constraint ("yassilah/laravel-nova-nested-form": "1.0.*") to composer.json. One maintainer, a four-year-old release — you do not want a future composer update surprising you.

Where to look first. Two files, twenty minutes each, before you wire anything:

  • vendor/yassilah/laravel-nova-nested-form/src/NestedForm.php — the field class. Read fillAttributeFromRequest() (how child payload is hydrated off the request) and resolve() (how existing children load into the edit form). These two methods are the entire contract you're depending on.
  • resources/js/components/FormField.vue (or similarly named) — skim the add/remove row state and how rows serialize into the parent form payload. This is where you'll debug when rows vanish.

First use case: an Order with ordered items. Do not start on polymorphic or pivot-heavy relations — they're where this field strains first.

// app/Nova/Order.php
use Yassilah\NestedForm\NestedForm;

public function fields(Request $request): array
{
    return [
        Text::make('Number')->rules('required'),
        BelongsTo::make('Customer'),

        NestedForm::make('Items')
            ->hideFromIndex()
            ->fields([
                Text::make('SKU')->rules('required', 'max:64'),
                Number::make('Quantity')->rules('required', 'integer', 'min:1'),
                Number::make('Unit Price', 'unit_price')->rules('required', 'numeric', 'min:0'),
            ]),

        // Detail screen keeps the native field so the two don't fight over the attribute.
        HasMany::make('Items')->onlyOnDetail(),
    ];
}

Create an order, add two rows, save, reload the edit screen. Rows populate, edits persist → you have a baseline. Now go break it deliberately (see Gotchas).

Implementation Patterns

Form vs. detail split. The standard wiring is NestedForm on forms only, native HasMany on detail only. Running both on the form simultaneously causes duplicate-attribute collisions and confusing save behavior. Detail view gets free pagination, filtering, and per-child authorization for nothing.

Keep child field definitions in a class you own. Don't inline ten fields into Order::fields():

NestedForm::make('Items')->fields(ItemFields::all()),

When (not if) you subclass the field to swap Yassilah\NestedForm\NestedForm for App\Nova\Fields\NestedForm, that swap is a one-line change instead of a scavenger hunt.

One level of nesting is comfortable; two is the ceiling. Order → Items → Options mostly works if eager-loaded. Three levels produces payloads and render times that are hard to reason about, and validation errors that surface against the wrong row. Beyond two levels, model it as separate resources.

Set position in an observer, not the form. There is no reliable sortable drag mode to lean on:

// app/Models/Item.php
protected static function booted(): void
{
    static::creating(function (Item $item) {
        $item->position = $item->order->items()->max('position') + 1;
    });
}

Then return $this->hasMany(Item::class)->orderBy('position'); so both the form and the detail page render stably.

Wrap Nova writes in a transaction at the HTTP layer. The field performs N child writes inside one admin request with no atomicity guarantee. You can fix this without touching the package:

// app/Http/Middleware/WrapNovaWritesInTransaction.php
public function handle($request, Closure $next)
{
    if ($request->is('nova-api/*') && in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
        return DB::transaction(fn () => $next($request));
    }

    return $next($request);
}

Register after Nova's middleware. It converts "half-saved order" from a production incident into a 500 with an intact parent.

Eager-load on the resource. public static $with = ['items', 'items.options']; — lazy-loading children per index row is the classic N+1 here.

Log deletions with an observer. Given the delete semantics below, Item::deleting writing an audit row (acting user, parent id, payload) is the highest-ROI defensive pattern in this integration. It turns silent data loss into a recoverable event.

Test at the HTTP level. There's no meaningful unit surface. Post to the Nova API and assert on the DB:

public function test_nested_items_are_created_with_the_order(): void
{
    $this->actingAs($this->admin)
        ->postJson('/nova-api/orders', [
            'number' => 'ORD-1',
            'customer' => $this->customer->id,
            'items' => [
                ['sku' => 'A-1', 'quantity' => 2, 'unit_price' => 10.00],
                ['sku' => 'B-2', 'quantity' => 1, 'unit_price' => 5.50],
            ],
        ])->assertSuccessful();

    $this->assertDatabaseCount('items', 2);
}

Then write the important one: submit an update containing one of two existing children and assert what happened to the other. That test is your living spec for the delete semantics and the regression guard after any fork.

Gotchas and Tips

Omitted children are deleted on save. This is the field's defining behavior and its defining hazard. A row that fails to render (JS error, a ->hide() that evaluated true server-side but false in the browser, a payload truncated by a size limit) is gone on the next save — no confirmation, no soft delete unless you added it. Mitigations in order of value: soft deletes on the child table, a deleting observer that logs, and a browser test asserting row-count parity between render and submit.

Child policies are generally not evaluated. A user who can edit the parent can typically create and delete children regardless of the child model's policy. Enforce it explicitly:

protected static function booted(): void
{
    static::deleting(function (Item $item) {
        abort_unless(auth()->user()->can('delete', $item), 403);
    });
}

Pair with a feature test: parent-authorized, child-unauthorized user → 403. Don't assume the admin UI hides what the API refuses.

Validation errors on child rows are ugly. Parent errors are clean; nested errors come back as generic toasts or against an unexpected path. When a user says "it won't save and I don't know why," open the network tab and read the errors keys in the nova-api response body — that's where the real row indexing lives. Never debug this from the toast.

File uploads inside nested rows are fragile. If a child needs an image or attachment, spike it on a throwaway branch first. Fallback: a separate detail screen for that child type. Mixing both on one resource is fine.

BelongsToMany and pivot attributes are the weakest path. Pivot columns (quantity, role, timestamps) aren't first-class here. If the pivot carries real data, model it as a first-class child with two belongsTo relations and put NestedForm on that.

Polymorphic relations: keep morph keys out of the field list. For morphMany, don't expose commentable_type / commentable_id — the package sets them from the parent. Exposing them lets users reassign children to another parent.

Debugging checklist when rows disappear or won't persist:

  1. Confirm the relation exists on the model and returns the expected type.
  2. Inspect the nova-api request payload — is the items key present and shaped correctly?
  3. Check storage/logs/laravel.log for mass-assignment or SQLSTATE errors Nova swallows.
  4. php artisan config:clear && php artisan view:clear after any vendor change.
  5. Stale assets? Check the service provider for a publish tag and re-publish.

Extension points, in order of preference.

  1. Subclass in app/Nova/Fields/NestedForm.php and override resolve() to inject computed defaults or filter what loads.
  2. Model observers for authorization, audit, and ordering — these survive a fork.
  3. Editing vendor/ is a deferred fork decision. Decide now: the code surface is one PHP class and one Vue component, so owning a private fork is cheap; discovering you need one after an incident is not.

Write the one-page runbook during the spike. Delete semantics, authorization behavior, transaction wrapping, fork location, who to ping. Half a day of reading won't reproduce what you learned by breaking it.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings