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

optimistdigital/nova-table-field

A Laravel Nova field that renders editable tables directly in your resources, letting you manage rows and columns of data without leaving the Nova admin panel. Ideal for storing structured, repeatable data in a single field.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites

You need an existing Laravel + Laravel Nova application. This field delivers value only inside a Nova resource form — do not adopt Nova to get this field. Confirm your installed Nova major before anything else:

php artisan nova:version
composer show laravel/nova
composer show optimistdigital/nova-table-field   # see what's actually resolvable

The repository lives at outl1ne/nova-table-field, so the Composer package name you declare may differ from the repo path (the maintainer renamed from optimistdigital to outl1ne). Check Packagist for the name and tags that match the Nova major you run — the release metadata on this package is a little anomalous, so verify against tags and the commit log rather than trusting the README's compatibility table.

Install

composer require optimistdigital/nova-table-field

Then confirm compiled frontend assets ship in the vendor directory:

ls vendor/optimistdigital/nova-table-field/dist/

If dist/ is populated, you do not need a Node build step in production. If it is empty, you must build assets yourself — a real operational cost worth knowing before the PR.

One migration, one cast, one field

// database/migrations/xxxx_add_pricing_tiers_to_plans_table.php
Schema::table('plans', function (Blueprint $table) {
    $table->json('pricing_tiers')->nullable();
});
// app/Models/Plan.php
protected $casts = [
    'pricing_tiers' => 'array',
];
// app/Nova/Plan.php
public function fields(NovaRequest $request)
{
    return [
        // ... other fields
        Table::make('Pricing Tiers', 'pricing_tiers')
            ->columns([
                // column definitions — see Implementation Patterns
            ]),
    ];
}

Where to look first

  1. src/ in the vendor directory — the field class and any column class is the authoritative API. The builder methods (->columns(), row limits, column types) are defined there, and the README may lag the code.
  2. dist/js/ — tells you whether assets are prebuilt.
  3. The migration and cast above — this is the entire persistence layer.
  4. composer.lock — pin the field and Nova together and commit the lockfile.

First use case

Start with something small, internal, and non-revenue: a "shipping zones" or "rate card" table on a settings resource. Create a record, add two or three rows, save, and immediately verify what landed in the database:

dd(Plan::first()->getRawOriginal('pricing_tiers'));

You should see a JSON array of row objects keyed by your column attribute names. If you see a double-encoded JSON string, your cast and the field are disagreeing — fix that before going further.


Implementation Patterns

Treat the column schema as code, and name it once

The column schema lives in the field definition, so it is versioned with your repository. That is the package's main structural advantage over a JSON textarea — but only if you keep the schema in one place and reuse it.

// app/Nova/Fields/PricingTiersTable.php
namespace App\Nova\Fields;

use Outl1ne\NovaTableField\Table;
use Outl1ne\NovaTableField\TableColumn;   // verify class name in vendor/src

class PricingTiersTable extends Table
{
    public function __construct($name = 'Pricing Tiers', $attribute = 'pricing_tiers')
    {
        parent::__construct($name, $attribute);

        $this->columns([
            TableColumn::make('Label', 'label')->width('1/3'),
            TableColumn::make('Monthly price', 'monthly_price'),
            TableColumn::make('Currency', 'currency'),
            TableColumn::make('Notes', 'notes'),
        ])
        ->minRows(1)
        ->maxRows(10);
    }
}

Wrapping it in a subclass means the same schema is used on the edit form, on a duplicate action, and in any tests you write. It also gives you a single file to diff when you change columns.

Keep attribute keys (label, monthly_price) immutable. Change labels freely; renaming a key silently orphans every existing row's value. If you must rename a key, ship a data migration in the same PR that walks the JSON and rewrites the key.

Validation is entirely yours — write it first

There are no database constraints behind this column. The field's UI constrains nothing an HTTP client can't bypass. Put the rules on the field or in the FormRequest, and make them explicit about shape, types, and cardinality.

Table::make('Pricing Tiers', 'pricing_tiers')
    ->columns([...])
    ->rules([
        'array',
        'max:10',                       // hard row ceiling
        'min:1',
    ])
    ->creationRules(['required'])

For per-cell rules, wildcard notation in the FormRequest is the most reliable place — Nova field rules() with wildcards works but is harder to debug:

public function rules()
{
    return [
        'pricing_tiers'                  => ['required', 'array', 'min:1', 'max:10'],
        'pricing_tiers.*.label'          => ['required', 'string', 'max:60'],
        'pricing_tiers.*.monthly_price'  => ['required', 'numeric', 'min:0'],
        'pricing_tiers.*.currency'       => ['required', 'in:EUR,USD,GBP'],
        'pricing_tiers.*.notes'          => ['nullable', 'string', 'max:255'],
    ];
}

Two things worth doing beyond the basics:

  • Reject unknown keys. A pricing_tiers.* array rule accepting anything lets a client inject arbitrary keys into your JSON contract. Add a custom rule that compares array_keys($row) against your known column list.
  • Enforce uniqueness inside the table if keys matter (distinct across pricing_tiers.*.currency), because nothing else will.

Build the read path before the write path

Do not let controllers or Blade templates reach into raw JSON. Add typed accessors to the model so the shape is defined in one place:

public function pricingTiers(): Collection
{
    return collect($this->pricing_tiers ?? [])
        ->map(fn (array $row) => new PricingTier(
            label: $row['label'] ?? '',
            monthlyPrice: (float) ($row['monthly_price'] ?? 0),
            currency: $row['currency'] ?? 'EUR',
            notes: $row['notes'] ?? null,
        ));
}

Normalize null to [] here (or via a model attribute default) so downstream code never has to branch on null-vs-empty.

If anything ever needs to filter, sort, join, or aggregate on these values, stop and model it relationally. The realistic intermediate is a generated/indexed column for the one or two values you actually query (MySQL JSON_EXTRACT, Postgres jsonb expression index) — a bridge, not a destination.

Displaying vs. editing

The field renders in both form and detail views. Use Nova's standard visibility modifiers so it does not bloat index tables:

PricingTiersTable::make()->hideFromIndex(),

If you want a compact read-only summary on the index or detail page, add a separate computed field that formats the collection, and leave the table field for the form. The table renderer is an editor, not a report.

Sensible defaults for new records

Give new records a usable starting state rather than an empty table, and set it at the model level so it applies to seeders and programmatic creation too:

protected $attributes = [
    'pricing_tiers' => '[{"label":"Standard","monthly_price":0,"currency":"EUR"}]',
];

A single default row prevents the "why is my table empty and unsavable" support question when minRows(1) is set.

Ship an export path at the same time as the field

The most common follow-up request is "can I get this into a spreadsheet." A small Nova action that flattens rows to CSV takes an hour and preempts a ticket that otherwise routes through an engineer:

public function handle(ActionFields $fields, Collection $models)
{
    // map each model's pricing_tiers into rows, write to storage, return download
}

This is also your escape hatch: an export plus a documented shape turns a future normalization migration into a mechanical exercise.

Testing

You do not need browser tests to cover most of this. Test the cast and the accessor directly:

it('normalizes missing values', function () {
    $plan = Plan::factory()->create(['pricing_tiers' => null]);
    expect($plan->pricingTiers())->toBeEmpty();
});

Then write one validation test per rule, and one integration smoke test that hits the Nova resource endpoint and asserts a saved payload round-trips. Keep that smoke test tagged so you can run it deliberately when you bump Nova — it is the cheapest early warning that the field has broken against a new major.


Gotchas and Tips

  • Verify the package name and version before adopting. The repo path (outl1ne/…) and the Composer name (optimistdigital/…) diverge, and the recorded release date is anomalous. Check Packagist tags, recent commit activity, and open issues. Run composer require in a throwaway app first and confirm the field renders on the exact Nova version you run.
  • A Nova major is a program-level event, not a routine bump. Nova's frontend breaks between majors (the Vue 3/Inertia move in Nova 5). A stale third-party field blocks the entire Nova upgrade, not just this feature. Pin the field and Nova together, commit the lockfile, and add "smoke-test this field in staging" to the upgrade checklist.
  • Prebuilt assets matter. If dist/ isn't committed in the released tag, production builds need a Node toolchain. Check before the deploy pipeline breaks.
  • Null vs. empty array will bite you. Decide on one representation and enforce it. Half your rows will be null if you let Nova save an untouched empty table; guard with ?? [] everywhere or set a model default.
  • Double-encoding happens when the field expects a JSON string but the model casts to array (or vice versa). Symptom: "[\"{\\\"label\\\"...\"]" in the database. Diagnose with $model->getRawOriginal('column') and $model->getAttribute('column') side by side.
  • Renaming a column key silently drops data. The label is cosmetic; the key is the contract. Keep keys stable, or ship a backfill migration alongside the rename. Write the JSON shape into the repo as a versioned document so this is a mechanical decision, not an archaeology exercise.
  • Escape user input in any custom rendering. Text and textarea columns are free-text. If you render table values outside the Nova field — emails, PDFs, a public page — escape them as you would any other user input. Do not {!! !!} it.
  • No row-level concurrency control. The entire table is one column; two Nova users editing the same record means last-write-wins over the whole blob. Fine for one internal owner per record, wrong for anything collaborative.
  • Row count degrades the admin UI. Treat a few tens of rows as the ceiling and enforce it with max: validation. If someone asks for "a few hundred," that is a signal the data wants a relation.
  • Sorting and filtering don't exist. This is not a filterable Nova field. Do not promise index-page search or a filter dropdown on these values — build a read model instead.
  • Clear caches after field changes. php artisan optimize:clear (and nova:cache-clear where applicable) after editing the field class, or you will debug a stale asset that isn't there.
  • Debug from the endpoint, not the UI. When save fails silently, check storage/logs/laravel.log and the network tab for the Nova field update request — validation errors from wildcard rules surface there with the offending index.
  • Extension point: subclass the field. If you need a custom column type or a different default layout, extend the field class in your app rather than editing vendor/. That keeps you mergeable if you later need to fork.
  • Have an exit plan and an owner. Roughly 40 stars, zero dependents, one maintainer, no SLA. Name an internal owner, keep the JSON shape documented, and pre-decide whether you fork-and-vendor or replace with a hand-written field if upstream goes quiet. Because the data is plain JSON behind a standard cast, exit is a migration you control — that is precisely why the "cheap now" trade is genuinely cheap.
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.
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
noith/settings