gwd/laravel-nova-flexible-content
A flexible content field for Laravel Nova that lets editors build pages from repeatable, configurable content blocks. Define layouts in PHP, then compose and reorder them in the admin panel for structured, ACF-style page building.
Reality check first. This package has 1 star, 0 dependents, and a single maintainer. Treat it as a "read the source before you commit" dependency, not a drop-in. Before wiring it into a client project, open src/ and resources/js/ on GitHub and confirm:
composer.json constraints for laravel/nova and laravel/framework).dist/js/field.js) or expects you to npm install && npm run prod inside the package.If any of those are unclear, compare against whitecube/nova-flexible-content or outl1ne/nova-flexible-content — both are actively maintained and cover the same "ACF-style flexible blocks in Nova" use case. Only pick this one if you have a specific reason (fork, existing data, licensing).
Install (typical shape — verify against the README):
composer require gwd/laravel-nova-flexible-content
php artisan vendor:publish --provider="Gwd\FlexibleContent\FieldServiceProvider"
php artisan migrate # only if the package ships a migration; most flexible-content fields store JSON on your own model
Most flexible-content fields are not their own table — they serialize block data into a JSON/TEXT column on your existing model. Add that column yourself:
Schema::table('pages', function (Blueprint $table) {
$table->json('content')->nullable();
});
And cast it:
protected $casts = ['content' => 'array'];
Where to look first, in order:
README.md — the only doc you get.src/*.php — find the Field subclass; its component() string tells you which Vue file is loaded.resources/js/components/ — read the block/layout rendering component. This is where you'll learn the real shape of the JSON it produces, which is what your Blade templates must consume.composer.json — Nova version constraint.First use case: a Page model with repeatable layout blocks.
use Gwd\FlexibleContent\FlexibleContent; // verify namespace
FlexibleContent::make('Content')
->addLayout('Text', 'text', [
Text::make('Heading'),
Textarea::make('Body'),
])
->addLayout('Image + Text', 'image_text', [
Image::make('Image', 'image_disk_path'),
Textarea::make('Caption'),
]);
Then render:
@foreach ($page->content as $block)
@includeIf("partials.blocks.{$block['layout']}", ['block' => $block])
@endforeach
The layout key (or whatever the package names it — confirm by saving one block and dd()-ing the column) is your dispatch key.
Keep layout definitions DRY across resources. Don't paste the same ->addLayout(...) chain into a Page resource, a Post resource, and a Landing resource. Extract a static method:
class ContentBlocks
{
public static function make(string $attribute = 'Content'): FlexibleContent
{
return FlexibleContent::make($attribute)
->addLayout('Text', 'text', [...])
->addLayout('Hero', 'hero', [...])
->addLayout('Gallery', 'gallery', [...]);
}
}
Now a schema change is one edit, and every resource follows along.
Mirror layouts on the frontend with a component map, not @includeIf alone. @includeIf silently swallows typos, so a missing partial renders nothing and you ship a blank page. Prefer an explicit map:
// config/block-views.php
return [
'text' => 'blocks.text',
'hero' => 'blocks.hero',
'gallery' => 'blocks.gallery',
];
@foreach ($page->content ?? [] as $block)
@php $view = config("block-views.{$block['layout']}"); @endphp
@if ($view && View::exists($view))
@include($view, ['block' => $block])
@endif
@endforeach
This gives you graceful degradation for orphaned layouts — critical when you remove a layout from the Nova field but old rows still hold that block type.
Model helper for block access. Add a blocks() accessor that normalizes the payload so templates don't need to know about the package's internal wrapper keys:
public function blocks(): Collection
{
return collect($this->content ?? [])
->filter(fn ($b) => isset($b['layout']))
->values();
}
Seeding and fixtures. Flexible content is awkward to seed by hand. Build the array shape once by saving through the Nova form, then copy the resulting JSON into your factory:
'content' => [
['layout' => 'text', 'attributes' => ['heading' => 'Hi', 'body' => '...']],
],
Write a factory state per layout (->withHero(), ->withGallery()); you'll reuse them constantly in feature tests that assert a block renders.
Validation of nested fields. Nova validates sub-fields inside a flexible content field inconsistently depending on the package version. If nested rules aren't firing, add a custom rule at the model level that iterates blocks and validates each attributes array with Validator::make(). This is more reliable than trusting field-level ->rules() inside a nested layout.
Media inside blocks. Store image paths as plain strings in the block attributes and resolve them via Storage::url() at render time — do not rely on the package's Image field resolution unless you've tested both the create and update flows. Nested media fields are the single most common source of orphaned files and stale paths in flexible-content implementations.
Content migration. When you add a layout, old rows are unaffected — they simply don't contain it. When you rename a layout key, write a one-off artisan command:
Page::whereJsonContains('content', ['layout' => 'old_key'])
->each(fn ($p) => $p->update(['content' => /* remap */]));
Run it in a migration or a guarded command, never ad-hoc in tinker on production.
The maintenance risk is the headline risk. One star and zero dependents means: no issue triage, no PR review, and no fix arriving when Nova ships a breaking change. Mitigate by forking the repo into your VCS, pinning the commit hash in composer.json, and budgeting a day to maintain it yourself. Prefer a path or VCS repository entry over Packagist so a surprise deletion upstream doesn't break composer install in CI.
Nova 5 / Vue 3 incompatibility is the most likely failure mode. If the field appears blank in the admin UI with a Vue is not defined or a silent console error, that's the cause. There is no fix short of porting the components. Check nova:version against the package's composer.json before you spend time debugging anything else.
Rebuild assets if the UI doesn't update. If you edit the package's Vue files (or the fork), run npm run prod inside the package directory and php artisan nova:publish. Nova caches the compiled field JS aggressively; a stale dist/field.js will make you think your changes aren't taking effect.
JSON column vs TEXT. $table->json() gets you whereJsonContains / whereJsonLength and MySQL-side validation. Use it. On MariaDB or older MySQL, JSON is aliased to LONGTEXT and you lose the operators — then you're back to LIKE '%"layout":"hero"%', which is fragile and unindexable.
Don't query by string matching on block content. For "find all pages with a hero block," use whereJsonContains('content', ['layout' => 'hero']). If that's a common query, denormalize: a has_hero boolean or a block_types JSON column maintained in a model observer. Full-text search over serialized block JSON is a trap.
Nested field quirks. Fields inside layouts don't always respect ->hideFromIndex(), ->sortable(), or ->rules() the way top-level Nova fields do — the package is rendering them in its own Vue context, not Nova's standard field wrapper. Test index and detail views explicitly; don't assume create/update working implies the rest works.
Orphaned block types break rendering, not saving. Nova will happily save a page containing a layout you've since removed from the PHP definition. Your frontend must handle unknown layout values — hence the explicit view map plus View::exists() guard above. Add a test: seed a block with layout => 'nonexistent' and assert the page still returns 200.
Debugging checklist, in order:
dd(Page::find($id)->getRawOriginal('content')) — confirm the actual JSON key names (layout vs type vs block).laravel.log.php artisan nova:publish — fixes a surprising number of "field not showing" reports.component() name matches a file registration in resources/js/field.js.Extension points to know before forking. The Field class's component() method, the Vue block component in resources/js/components/, and any service provider bindings are the three seams. If you need custom layout previews, conditional layouts (e.g., show "Gallery" only when a page type is selected), or collapsible block reordering, you'll likely be editing the Vue component directly rather than using a documented API.
Escape hatch. If you hit a wall, migrating to whitecube/nova-flexible-content is usually feasible because both store arrays of {layout, attributes}-shaped blocks in a JSON column. Write the migration command first, keep the Blade templates identical, and only then swap the field definition — the frontend shouldn't need to change at all.
How can I help you explore Laravel packages today?