digital-creative/nova-json-wrapper
A Laravel Nova field wrapper for editing and displaying JSON columns with familiar Nova form fields, making structured or nested JSON data easier to manage directly from the admin panel.
Install
composer require digital-creative/nova-json-wrapper
No service provider or config publish is required — it is a single Nova field class.
Where to look first
src/JsonWrapper.php. The field is a thin Field subclass that renders a child field list and serializes their values into one attribute.composer.json for the Nova constraint. This package was last tagged in Feb 2021, so it targets Nova 3.x. Confirm it loads under your Nova version before building anything on top of it.null) drives everything else you write.First use case
You have a model with a settings (or meta) JSON column that you currently edit with a raw Textarea (or a KeyValue field), and you want real Nova inputs for each key.
// app/Nova/Resource.php
use DigitalCreative\NovaJsonWrapper\JsonWrapper;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Fields\Boolean;
public function fields(Request $request)
{
return [
JsonWrapper::make('Settings', 'settings')
->fields([
Text::make('Tagline'),
Textarea::make('About'),
Boolean::make('Show Banners'),
]),
];
}
// app/Models/Page.php
protected $casts = [
'settings' => 'array',
];
// migration
$table->json('settings')->nullable();
That is the whole loop: migrate the column, cast it as array, wrap the fields, done. Start there before layering validation or conditional logic.
Treat the wrapper as your resource's “structured blob” editor
The strongest use case is a resource with 10–30 loosely related settings (SEO block, feature flags, display options) that would otherwise pollute the main table with dozens of columns. Group them into one or two wrappers (seo, display) and keep relational or frequently-queried data in real columns.
Split by concern, not by field type
JsonWrapper::make('SEO', 'seo')->fields([
Text::make('Title'),
Textarea::make('Description'),
Image::make('OG Image'),
]),
JsonWrapper::make('Display', 'display')->fields([
Boolean::make('Show Author'),
Select::make('Layout')->options([...]),
]),
This keeps JSON payloads readable in the DB and lets you evolve one group without touching the other.
Validation belongs on the child fields
Put rules on the inner fields so the wrapper inherits them:
Text::make('Title')->rules('required', 'max:70'),
Textarea::make('Description')->rules('max:160'),
If the package does not forward child rules in your Nova version, fall back to validating in the FormRequest / rules() method using dot notation:
public function rules(Request $request)
{
return [
'seo.title' => ['nullable', 'string', 'max:70'],
'seo.description' => ['nullable', 'string', 'max:160'],
];
}
Formatting / hydrating legacy data
If you are migrating from flat columns, resolve defaults before the field renders so old records don't show empty forms:
public function resolve($resource, $attribute = null)
{
// Backfill the JSON shape for legacy rows
$resource->seo = array_merge([
'title' => $resource->legacy_seo_title,
'description' => $resource->legacy_meta_description,
], $resource->seo ?? []);
parent::resolve($resource, $attribute);
}
Keep the wrapper out of index/detail views
JSON blobs don't summarize well. Restrict it to forms:
JsonWrapper::make('Settings', 'settings')
->fields([...])
->onlyOnForms(),
Then surface the important keys as ordinary Text fields with ->onlyOnIndex() and computed ->resolveUsing() callbacks if you need column-level reporting.
One wrapper per JSON column
The package merges the wrapped fields into a single attribute. Don't try to point two wrappers at the same column in the same form — order of resolution gets ambiguous. Model the data as one wrapper per column.
Feature-flag the shape
Because the column is schemaless, guard consumers with defensive accessors:
public function getSeoTitleAttribute(): ?string
{
return data_get($this->seo, 'title');
}
Then views, APIs, and Nova all read through the accessor rather than reaching into raw JSON.
Nova version compatibility is the big one. Last release Feb 2021 means Nova 3 only. Nova 4/5 changed field internals (asset registration, Field lifecycle, Vite build). If you're on Nova 4+, expect it to break or need a fork; budget a day to either patch src/JsonWrapper.php or replace it with a custom field. Verify before you standardize on it.
The cast is mandatory and must be array (or json). Without $casts = ['settings' => 'array'] the field receives a string on fill and writes a JSON-encoded string of a string. Symptom: nested double-encoding like "{\"title\":\"...\"}". If you already have bad data, write a one-off migration that json_decodes any string values before switching the cast.
Nullables and empty payloads. ->nullable() on the migration; otherwise first save fails on a record with no JSON. Also decide whether an all-empty wrapper writes null or {} — check what the field emits on fill and normalize in a model mutator if consumers depend on one shape.
Validation rules are the most common failure point. Child ->rules() may not propagate through the wrapper depending on the Nova version. Always assert in a feature test:
$this->actingAs($user)
->put("/nova-api/pages/{$page->id}", ['seo' => ['title' => str_repeat('a', 100)]])
->assertStatus(422);
If the request passes when it shouldn't, move rules to dot-notation in the resource rules().
Dependent/conditional fields often misbehave. ->dependsOn() and ->hideWhen() inside a wrapper rely on field-attribute naming that the wrapper rewrites (seo.title, not title). If reactivity silently stops working, check the rendered field's attribute in the browser (Vue devtools) — the wrapper may not be passing through the prefixed name.
Index/sort/search do not apply. The wrapper has no real column ordering or searchable. ->sortable() will throw or misbehave. Keep it onlyOnForms().
Debugging checklist when values don't save:
dd($model->getAttributes()) after save() — is the column set, and is it valid JSON?dd($model->getCasts()) — is the cast registered?dd($field->meta()) in a Tinker / Nova route to inspect the resolved value.seo[title], so your rules() uses seo.title.Extension points to lean on. ->resolveUsing() and ->fillUsing() are inherited from Nova's Field and let you reshape data without forking:
JsonWrapper::make('Settings', 'settings')
->fields([...])
->fillUsing(function ($request, $model, $attribute) {
$model->{$attribute} = array_filter(
$request->input($attribute, []),
fn ($v) => $v !== null && $v !== ''
);
}),
This is the cleanest place to strip empty strings so the JSON stays tidy.
No nested wrappers. Grouping inside grouping produces brittle attribute paths. Use Panel for visual grouping and keep a single wrapper per column.
Pin the dependency and document the fork. With 12 stars and no releases since 2021, assume you may own this code. Commit a composer.json constraint (or vendor a patched copy under app/Nova/Fields/JsonWrapper.php) and keep a short note in your repo explaining why it exists. That beats discovering a Nova upgrade break during a deploy.
How can I help you explore Laravel packages today?