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.
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.
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.
// 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
]),
];
}
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.dist/js/ — tells you whether assets are prebuilt.composer.lock — pin the field and Nova together and commit the lockfile.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.
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.
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:
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.distinct across pricing_tiers.*.currency), because nothing else will.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.
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.
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.
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.
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.
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.dist/ isn't committed in the released tag, production builds need a Node toolchain. Check before the deploy pipeline breaks.null if you let Nova save an untouched empty table; guard with ?? [] everywhere or set a model default.array (or vice versa). Symptom: "[\"{\\\"label\\\"...\"]" in the database. Diagnose with $model->getRawOriginal('column') and $model->getAttribute('column') side by side.{!! !!} it.max: validation. If someone asks for "a few hundred," that is a signal the data wants a relation.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.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.vendor/. That keeps you mergeable if you later need to fork.How can I help you explore Laravel packages today?