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 Table Field Laravel Package

outl1ne/nova-table-field

Nova Table Field adds an editable table field to Laravel Nova, letting you manage rows and columns of structured data directly inside resource forms. Configure columns, row limits, and formatting to store lightweight tabular data without extra models or migrations.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm the licence before the package. This is a Nova field, not a standalone grid. Without a Nova install in the app, composer require outl1ne/nova-table-field gives you nothing you can render. If Nova is already paid for and in use, this is a config-level addition; if you're introducing Nova because of this field, stop and re-scope.

Install, then read the source — it is the documentation.

composer require outl1ne/nova-table-field:0.1.*   # pin first; drop the patch wildcard once verified
ls vendor/outl1ne/nova-table-field
cat vendor/outl1ne/nova-table-field/README.md
grep -rn "class .* extends Field" vendor/outl1ne/nova-table-field/src
ls vendor/outl1ne/nova-table-field/resources/js

The whole point of that five-minute read is to answer three questions the README will not reliably answer for your version: what column type values the Vue component actually implements, what chainable methods exist on the field class, and whether the JS bundle targets your Nova major. Guessing the column schema from blog posts is how you end up forking.

The storage contract. One attribute, one JSON column, one array cast. That is the entire data layer.

// database/migrations/xxxx_add_rate_card_to_products.php
$table->json('rate_card')->nullable();
// app/Models/Product.php
protected $casts = ['rate_card' => 'array'];

Everything else in this package is UI over that column. Internalise that and the rest of your day-to-day decisions become ordinary Laravel decisions: how do I validate it, how do I normalise it, what reads it.

Register the field in the resource.

use Outl1ne\NovaTableField\Table;

public function fields(NovaRequest $request): array
{
    return [
        Table::make('Rate card', 'rate_card')
            ->columns([
                ['label' => 'Region',  'value' => 'region',  'type' => 'text'],
                ['label' => 'Min qty', 'value' => 'min_qty', 'type' => 'number'],
                ['label' => 'Price',   'value' => 'price',   'type' => 'number'],
            ])
            ->onlyOnForms(),
    ];
}

columns() is your form schema; the JSON blob is the data. Nothing keeps them in sync — not the field, not Nova, not the database. Treat columns() as a versioned schema from the first commit.

First day's deliverable. Pick one real grid — a shipping rate card, a per-region tax table, tiered discount bands on a Plan. Create two rows, save, and verify the persisted shape immediately:

php artisan tinker
>>> Product::first()->rate_card

You want [['region' => 'eu', 'min_qty' => 1, 'price' => 10], ...] — a list of associative arrays with sequential integer keys. If you see {"0": {...}} or a missing key, fix it in the normalizer now; every consumer, export, and future migration inherits that shape.

Implementation Patterns

Normalise in a saving hook, not in the field. The browser payload is not trustworthy: abandoned rows, non-sequential keys, empty strings, string-typed numbers. The model is the right seam because it also covers seeders, imports, console commands, and saveQuietly()-adjacent tooling you'll write later.

protected static function booted(): void
{
    static::saving(function (Product $product) {
        $product->rate_card = collect($product->rate_card ?? [])
            ->filter(fn ($row) => filled($row['region'] ?? null))
            ->map(fn ($row) => [
                'region'  => (string) $row['region'],
                'min_qty' => (int) ($row['min_qty'] ?? 0),
                'price'   => round((float) ($row['price'] ?? 0), 2),
            ])
            ->sortBy('min_qty')
            ->values()          // list, not map — non-negotiable
            ->all();
    });
}

This hook is your cheapest enforcement point for every invariant: types, ordering, dedupe-by-key, rejecting overlapping bands with a ValidationException. Everything downstream — DTOs, exports, a future relational migration — can then assume a canonical shape.

Validation goes in Resource::rules() with wildcards. The field carries whole-attribute rules at best; per-cell rules are plain Laravel array validation.

public static function rules(NovaRequest $request): array
{
    return [
        'rate_card'           => ['array', 'max:200'],
        'rate_card.*.region'  => ['required', 'string', 'in:eu,us,apac'],
        'rate_card.*.min_qty' => ['required', 'integer', 'min:0'],
        'rate_card.*.price'   => ['required', 'numeric', 'min:0'],
    ];
}

The max:200 matters more than it looks. Admin-typed grids with no row cap and no pagination are one bad paste away from a multi-megabyte JSON cell that Nova then tries to serialise on every index request.

Seed the first edit. A blank grid on a fresh record means the ops user types the same three regions manually every time.

Table::make('Rate card', 'rate_card')
    ->default(fn () => [
        ['region' => 'eu', 'min_qty' => 1, 'price' => 0],
        ['region' => 'us', 'min_qty' => 1, 'price' => 0],
    ])
    ->onlyOnForms(),

Keep the raw blob out of detail and index views, and give both a real renderer. Left alone, Nova will dump JSON. Pair ->onlyOnForms() with a purpose-built Text field on the read side.

Table::make('Rate card', 'rate_card')->onlyOnForms()->hideFromIndex(),

Text::make('Rate card')
    ->displayUsing(fn ($rows) => collect($rows ?? [])
        ->map(fn ($r) => "{$r['region']} ≥ {$r['min_qty']}: {$r['price']}")
        ->implode(' · '))
    ->onlyOnDetail(),

One field factory, many resources. If three resources need a "region/price" grid, don't copy columns() three times — you'll rename a key in two of them.

final class RateCardField
{
    public static function make(string $name = 'Rate card', string $attribute = 'rate_card'): Table
    {
        return Table::make($name, $attribute)
            ->columns(self::COLUMNS)
            ->onlyOnForms();
    }

    public const COLUMNS = [
        ['label' => 'Region',  'value' => 'region',  'type' => 'text'],
        ['label' => 'Min qty', 'value' => 'min_qty', 'type' => 'number'],
        ['label' => 'Price',   'value' => 'price',   'type' => 'number'],
    ];
}

One array, one normaliser trait, one DTO. That trio is your package.

Consume through a DTO outside Nova. The single highest-leverage habit when the system of record is JSON: nothing outside the model touches ->rate_card directly.

final class RateCard
{
    /** @param array<int, RateRow> $rows */
    public function __construct(public readonly array $rows) {}

    public static function fromArray(?array $raw): self
    {
        return new self(array_map(
            fn ($r) => new RateRow($r['region'], (int) $r['min_qty'], (float) $r['price']),
            $raw ?? []
        ));
    }

    public function priceFor(string $region, int $qty): ?float { /* domain logic lives here */ }
}
// app/Models/Product.php
public function rateCard(): RateCard { return RateCard::fromArray($this->rate_card); }

When finance eventually wants these rows in the warehouse, the migration is a loop over RateCard, and the consumers already speak the typed shape.

Stub with make:resource --model first. The field is small enough that resource-generation tooling gets you 80% of the way; you're mainly adding columns(), rules(), and onlyOnForms().

Version the stored shape in the row. Add a _v key or a sibling rate_card_version column. Renames will happen; a version marker turns the backfill into a match statement rather than archaeology.

['region' => 'eu', 'min_qty' => 1, 'price' => 9.5, '_v' => 2]

Feature-test the endpoint, not the Vue component. This is the pattern that keeps the field honest across Nova upgrades.

$this->actingAs($admin)->putJson("/nova-api/products/{$product->id}", [
    'rate_card' => [['region' => 'eu', 'min_qty' => 1, 'price' => 9.5]],
])->assertOk();

$this->assertSame(
    [['region' => 'eu', 'min_qty' => 1, 'price' => 9.5]],
    $product->refresh()->rate_card
);

Add a companion test that posts a bad row and asserts a 422 — that's the one that surfaces the "user loses their grid" problem below.

Scope it with field permissions from day one. Business rules living inside a JSON blob are exactly the thing a support role should not be editing.

Table::make('Rate card', 'rate_card')
    ->onlyOnForms()
    ->canSee(fn (NovaRequest $request) => $request->user()->can('manage-pricing'))
    ->readonly(fn () => ! auth()->user()->hasRole('finance'));

Gotchas and Tips

Renaming a value key silently orphans every existing row. Change 'value' => 'price' to 'value' => 'unit_price' in columns() and the grid renders blank cells for all historical data — no error, no warning, no migration prompt. The rename and the backfill must land in one commit:

Product::query()->whereNotNull('rate_card')->chunkById(200, function ($products) {
    foreach ($products as $p) {
        $p->rate_card = collect($p->rate_card)->map(function ($row) {
            if (array_key_exists('price', $row) && ! array_key_exists('unit_price', $row)) {
                $row['unit_price'] = $row['price'];
                unset($row['price']);
            }
            return $row;
        })->values()->all();
        $p->saveQuietly();   // deliberate: skip the observer that already expects the new shape
    }
});

Make the saveQuietly() decision explicit and write it in the PR description. Silent-quiet and unexpected-quiet are how backfills half-apply.

Blank rows and sparse rows reach the server. Users click "add row", don't fill it in, and save. Without filter() + values(), Postgres/MySQL store {"0": {...}, "2": {...}}, collect()->map() preserves those keys, and any subsequent json_encode produces an object, not an array. Downstream code that iterates or spreads it explodes in ways that don't reproduce locally. Always ->values().

Postgres jsonb reorders keys — do not assert on raw strings. MySQL json will reject malformed payloads at write time (useful, until it throws during a bulk backfill you thought was fine). Postgres normalises key order and strips duplicate keys. Compare decoded arrays in tests, never string equality.

The 422 path can cost the user their grid. Nova's form state recovery on validation failure is version-dependent. Test it in a real browser: add three rows, break a rule in row two, submit. If the table comes back empty, you have a support-ticket generator. Mitigate with client-side validation feedback or accept it and document it — but verify rather than assume.

->sortable() on this field does not do what you want. Sorting a Nova index by a JSON attribute means raw json_extract / ->> operators, and the syntax diverges between MySQL and Postgres. Same for filters and search. If sorting or filtering on these rows becomes a requirement, that's not a "write some raw SQL" ticket — that's the signal to normalise into a real table.

Supported column types are whatever the bundled Vue component ships. No date picker, no currency formatting, no reactive dependsOn, no per-column validation message — unless you've verified it in the JS source. During the spike, grep the component for the type switch and price the gap. MIT licensing means forking is real, but only if someone on the team has actually read that component and can own the fork.

Pin the version and record your matrix. Forty stars, a single maintainer, zero dependents, and a Vue component that tracks Nova majors. Pin exactly for the first release, note Nova + PHP + Node versions in an ADR, and add a CI job that bumps Nova to surface breakage on your schedule rather than during an unrelated upgrade.

Debug at the boundary, not in the UI. When the field misbehaves, the fastest path is what Nova actually posted.

$model->getRawOriginal('rate_card');   // what's really in the column, bypassing casts/accessors
$this->assertSame($expected, $model->refresh()->rate_card);

// in the observer, temporarily
logger()->info('rate_card.in', ['rows' => $product->rate_card]);

Most "the field is broken" reports resolve to a shape mismatch between columns() and the stored JSON, an accessor mutating the value, or a cast missing entirely.

Row count is a UI concern you own. There's no pagination, no server-side row limit, no virtual scrolling. Cap it in validation (max:200), and if a resource legitimately needs thousands of rows, that's a normalised table with a Nova HasMany, full stop.

->onlyOnForms() is not optional hygiene, it's load-bearing. Index views serialise every field for every row. A fat JSON blob on a 50-per-page index is a slow admin panel that nobody attributes to this field.

Budget the exit on day one. Keep the normaliser, the DTO, and the _v version marker in place from the first commit. The realistic regret is "finance wants these rows in the warehouse" 18 months in — with those three pieces, that's a console command with a validation report; without them, it's a rewrite of every read path.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource