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 Flexible Content Laravel Package

wmt/nova-flexible-content

View on GitHub
Deep Wiki
Context7

Getting Started

First, decide whether you should install it at all. The repository is archived, has no dependents and no recent activity beyond a final release tag. Treat it as vendored code, not a maintained dependency: pin an exact version, diff it against the upstream whitecube/nova-flexible-content fork lineage, and plan to own the code (fork into your org or commit it to packages/) if you build on it. Check the composer.json constraints for laravel/nova before installing — a mismatch can leave Composer resolving an old tag that silently doesn't work with Nova 4/5.

composer require wmt/nova-flexible-content:1.x.x   # exact pin, no caret

Schema. The field serializes an array of layout blocks to a JSON string. A plain text (or json) column is enough:

Schema::table('pages', function (Blueprint $table) {
    $table->text('content')->nullable();
});

Add content to $fillable (or leave it unguarded) — the field writes through normal mass assignment on save.

First field. The API mirrors the whitecube package: a Flexible field holding Layout definitions.

use Whitecube\NovaFlexibleContent\Flexible;
use Whitecube\NovaFlexibleContent\Layouts\Layout;
use Laravel\Nova\Fields\{Text, Textarea, File};

Flexible::make('Content')
    ->addLayout(Layout::make('text', 'Text block')->fields([
        Text::make('Heading'),
        Textarea::make('Body')->rules('required'),
    ]))
    ->addLayout(Layout::make('image', 'Image')->fields([
        File::make('Image')->disk('public'),
        Text::make('Caption'),
    ]))
    ->button('Add block')
    ->collapsed()
    ->confirmRemove();

First real use case: a page builder where marketing edits ordered, heterogeneous blocks (hero, text, gallery, CTA). Store it on the page model, render it in Blade per layout name, and you have a repeatable content system without a separate blocks table.

Implementation Patterns

Inline layouts vs. layout classes. For two or three blocks, inline Layout::make(...) is fine. Once layouts have rules, computed previews or are reused across resources, promote them to classes:

namespace App\Nova\Flexible\Layouts;

use Whitecube\NovaFlexibleContent\Layouts\Layout;
use Laravel\Nova\Fields\Textarea;

class TextLayout extends Layout
{
    protected $name = 'text';      // stored key — never change
    protected $title = 'Text block';
    protected $limit = 1;          // singleton block

    public function fields()
    {
        return [Textarea::make('Body')->rules('required')];
    }
}

Then ->addLayout(TextLayout::class) or ->addLayouts([TextLayout::class, GalleryLayout::class]). Keep $name stable forever; it is the value persisted in the JSON.

Dynamic/translated definitions. Use the closure form so labels resolve per request:

Flexible::make('Content')->layouts(fn () => [
    Layout::make('text', __('Text block'))->fields([...]),
    Layout::make('cta', __('Call to action'))->fields([...]),
]);

Field-level ergonomics.

  • ->collapsed() — start blocks folded; essential past ~5 blocks.
  • ->button('Add block') — label the add control in the editor's language.
  • ->confirmRemove() — cheap protection against accidental block deletion.
  • ->fullWidth() — the editor is unusably narrow in the default 1/3 column.
  • ->menu('dropdown') — swap the button grid for a select when you have many layouts.
  • ->limit(n) on a Layout caps instances of that layout; on the field it caps total blocks. Verify the exact semantics against the tag you pinned — limits prevent adding, they never trim existing data.

Reading content in PHP. The model attribute resolves to a collection of Layout value objects:

foreach ($page->content as $layout) {
    $layout->layout;                 // 'text'
    $layout->key;                    // uuid, stable per block
    $layout->heading;                // attribute via __get
    $layout->getAttribute('body');
    $layout->attributes;             // raw attribute array
}

Filter and dispatch by name rather than by position — editors reorder blocks freely:

$blocks = $page->content->filter(fn ($layout) => $layout->layout === 'cta');

Blade rendering. One partial per layout, resolved by name, with a safe fallback so deleting a layout definition doesn't 500 the site:

@foreach ($page->content as $layout)
    @includeIf('partials.flexible.'.$layout->layout, ['block' => $layout])
@endforeach

Alternatively, map names to components in a match inside a single Blade component — this makes unknown layouts explicit instead of silently skipped.

Writing content programmatically. Simplest reliable path is the raw JSON shape the field itself stores:

$page->content = json_encode([
    [
        'layout' => 'text',
        'key' => (string) Str::uuid(),
        'attributes' => ['heading' => 'Hello', 'body' => '<p>World</p>'],
    ],
]);
$page->save();

Generate real UUIDs for key; duplicated or missing keys cause drag/drop weirdness when the record is later edited in Nova. For seeders and factories, wrap this in a small helper so tests and imports produce identical shapes.

API output. JSON-encoding the resolved collection yields { layout, key, attributes: {...} } per block. That's a fine internal contract but leaky as a public API — flatten it in a JsonResource:

'content' => $this->content->map(fn ($block) => [
    'type' => $block->layout,
    'data' => $block->attributes,
])->values(),

Nested flexible content. A Flexible inside a Layout works and is tempting for column/grid builders. Use sparingly: validation paths get deep (content.0.attributes.columns.1.attributes.body), the editor becomes cramped, and querying/migrating the JSON gets harder. Prefer a flat set of layout types covering the common cases.

Queries. On MySQL with a real json column you can query layout presence:

Page::whereJsonContains('content', ['layout' => 'cta'])->get();

With a text column this won't work — you'd need a generated column, a cast plus whereJsonContains on the cast, or a denormalized has_cta flag updated on save. Decide this early; retrofitting search over flexible content is painful.

Gotchas and Tips

Archived means you are the maintainer. No security patches, no Nova 5 fixes, no upstream bug reports. Pin the exact version, review the diff before any bump, and if you need a change, fork and rebuild the compiled JS (dist/) — not just the PHP.

Casts are the #1 source of "my content disappeared." The field expects to resolve a JSON string and fill one. Adding protected $casts = ['content' => 'array'] can produce double-encoded payloads or json_decode(): Argument #1 must be of type string, array given. If a newer tag documents array-cast support, test a full create → edit → save round-trip before trusting it. Same for AsArrayObject and custom casts.

Never rename a layout's name. Layout::make('text') writes "layout":"text" into every row. Renaming to body_text orphans all historical blocks, which then render as unknown and are invisible in the editor. Rename only the title (second argument / $title). Same rule for inner field attributes — renaming body to content silently loses data.

Removing a layout definition doesn't remove stored blocks. Old rows keep the JSON. Render defensively (@includeIf, match with a default) and, when you truly retire a type, ship a one-off migration that decodes, filters and re-encodes the column.

Files inside layouts are not garbage-collected. Deleting a block or the whole model leaves the uploaded file on disk; the field doesn't walk the JSON on delete. Handle it in a model observer:

static::deleting(function ($page) {
    foreach ($page->content as $layout) {
        if ($file = $layout->getAttribute('image')) {
            Storage::disk('public')->delete($file);
        }
    }
});

Also remember that File::make('Image') inside a layout stores a path string in the attributes array — front-end code must run it through Storage::url(), not assume a File model.

Debugging a blank editor. In order: (1) $model->getRawOriginal('content') in tinker — is the JSON there and valid? (2) the Nova detail/index JSON response in DevTools — does it contain content? (3) browser console/network for a 404 on the field's compiled JS bundle; Nova 4+ package assets are registered by the service provider via Nova::script, and a stale/absent dist/ build shows an empty field with no server-side error. (4) storage/logs/laravel.log for decode errors during resolve.

Validation shape. Rules on inner fields validate per block and surface under the block in the editor, but error keys are nested (content.0.attributes.body). If you're asserting validation in tests or building a custom API around the same JSON, replicate that path. Put ->rules('required') on the Flexible field itself for "at least one block" and on inner fields for per-block requirements, and test the empty-array edge case — required on an empty flexible value may not behave as you expect depending on version.

Index and detail views. The resolved value is a collection of objects, so ->displayUsing()-style formatting on the field is awkward and the default index rendering is unhelpful. In practice: ->hideFromIndex() and expose a summary (block count, first heading) via a computed field.

Reordering and key. Sort order is array order — there is no separate position column. Any code that writes blocks (importers, API endpoints) must preserve array order and supply unique key values, or the Nova editor's drag handles will misbehave on the next edit.

Performance. The whole document loads as one attribute; there's no pagination or partial load. Fine for page content, bad if you attach hundreds of blocks or use it for high-volume records. Since it's a JSON blob, it also can't be eager-loaded or indexed conventionally — if you need to list "all pages containing a video block," prefer a denormalized flag over LIKE '%"video"%'.

Testing. In factories, set content to a json_encoded array with UUID keys and assert on $page->content->pluck('layout'). For Nova-level tests, hit the field's resolve/fill path directly rather than through HTTP — archived packages are exactly where an unexpected Nova minor bump breaks serialization, and a unit test on resolve()/fill() catches it fastest.

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.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels