novius/laravel-nova-visual-composer
A visual page composer for Laravel Nova that lets you build and arrange content blocks with a drag-and-drop interface, so editors can assemble layouts without touching code.
Verify compatibility before anything else. The last release (3.1.0, July 2022) only claims Laravel 9 support, and it landed in the middle of the Nova 3/Vue 2 → Nova 4/Vue 3 transition. Nova 5 is not covered. Check three things on a throwaway branch:
composer require novius/laravel-nova-visual-composer --dry-run
laravel/nova constraint?resources/js/ in the vendor directory target Vue 2 or Vue 3? That decides whether the field hydrates in your Nova version.dist/ folder? If not, the package's npm run prod becomes part of your pipeline — an ongoing cost, not a one-off.A field component that fails to load can submit an empty payload over existing content without an error, so establish this before pointing the field at anything real.
Install, publish, and store.
composer require novius/laravel-nova-visual-composer
php artisan vendor:publish --provider="Novius\LaravelNovaVisualComposer\...ServiceProvider"
You get a config file (the block registry) and, usually, assets. Confirm both in the same command and note them for the deploy runbook.
The field writes JSON to one column:
Schema::table('pages', function (Blueprint $table) {
$table->json('content')->nullable();
});
protected $casts = ['content' => 'array'];
Cast it — auto-decoding keeps your renderer and tests honest, and malformed JSON fails predictably instead of quietly.
Attach the field to one resource.
use Novius\LaravelNovaVisualComposer\VisualComposer; // confirm FQCN in vendor/
public function fields(Request $request)
{
return [
Text::make('Title')->rules('required'),
Slug::make('Slug'),
VisualComposer::make('Content', 'content')->hideFromIndex(),
];
}
Register two blocks in the config — no more. The registry is your content model: key, label, and which Nova fields appear inside the block.
'blocks' => [
'rich_text' => [
'label' => 'Rich Text',
'fields' => [Textarea::make('Body')->rules('required')],
],
'image' => [
'label' => 'Image',
'fields' => [
File::make('Image')->disk('public'),
Text::make('Caption'),
Text::make('Alt'),
],
],
],
First use case: one Page resource, rich_text + image, round-tripped through Nova and out through a Blade page. Save a page, dd($page->content), and freeze whatever payload shape you see (typically a type discriminator plus a fields object). Add schema_version to each stored block on day one — it costs nothing now and is the only cheap way to backfill later.
Where to look first in the source: the published config (small enough to read in twenty minutes), the field class, and the Vue component. Those three are the entire surface area and the only real extension points.
Gate the field by capability. Editors who shouldn't touch layout shouldn't see layout:
VisualComposer::make('Content', 'content')
->hideFromIndex()
->canSee(fn ($request) => $request->user()->can('compose-layouts'));
Write your own renderer — the package ships an editor, not a renderer. One Blade component that dispatches per block type. The View::exists() guard is the whole design:
{{-- resources/views/components/page-content.blade.php --}}
@foreach ($blocks as $block)
@php
$type = data_get($block, 'type');
$view = "blocks.{$type}";
$data = data_get($block, 'fields', []);
@endphp
@if (View::exists($view))
@include($view, $data + ['block' => $block])
@endif
@endforeach
Unknown types get skipped silently for visitors. That single guard turns "a removed block type" from a 500 into a cosmetic gap.
Make nesting recursive, not flat. If you use columns, sections, or accordions, the payload is recursive. Let the partial recurse:
{{-- resources/views/blocks/section.blade.php --}}
<section class="section">
<x-page-content :blocks="$block['fields']['children'] ?? []" />
</section>
Flattening nesting into a second rendering path is how the two paths drift.
Validate on save, but never on render. Add a custom rule (or NovaRules) that walks the payload: every block must have a known type and a fields array, and the document caps at ~100 blocks. Reject unknown types loudly for the editor; skip them quietly for the visitor. That asymmetry is deliberate.
Keep reusable templates in version control. If the package's template feature is in play, treat saved templates as JSON fixtures — seeders or config files — not database-only artifacts. Otherwise staging and production drift and you can't rebuild after a bad migration.
One rendering path, two layouts. If there's a preview, back it with a real front-end route that renders the same partials with a preview-only wrapper. A separate preview template will diverge within a sprint.
Test the block contract once. A feature test that seeds a known block, an unknown block, and a null block, then asserts a 200 and the expected output, catches most regressions — including ones a Nova upgrade introduces.
Cache high-traffic fragments. Wrap the loop (@cache) or add a cached accessor on the model. JSON columns and per-request recursion both creep.
Route media blocks through your existing pipeline. An image block stores whatever your File field stores. The block partial must resolve through your conversion/thumbnail service, or editors will upload 4MB originals straight onto the page.
Decide the localization shape before the first save. There is no per-locale support out of the box. If content is multilingual, use a per-locale column or a translatable cast from the start — retrofitting locale into a frozen payload means backfilling every page.
Silent overwrite is the failure mode to design against. If the Vue component doesn't load — Nova upgrade, unpublished asset, an unrelated JS error — the field can submit an empty payload over real content with no error. Mitigate with a revisions table or column backup, a saved observer that warns when a previously non-empty column is written empty, and a staging check on every Nova upgrade.
Blank field in the admin is almost always assets. Add vendor:publish next to migrate in the deploy script and smoke-test one resource edit page post-deploy. If assets must be built, that goes in the pipeline and stays there.
Guard the decode. Manual edits, imports, and partial saves produce malformed JSON. Wrap decode in try/catch, treat failure as an empty list, and log. Build php artisan content:repair-blocks to scan for invalid JSON and orphaned types — you will run it.
Schema evolution is entirely yours. Renaming a block key or moving a field means a backfill command over stored payloads, every time. Never rename in place. Keep schema_version per document, and keep the renderer skipping unknown types forever — old rows will outlive your config.
Fork it, don't depend on it. 14 stars, zero dependents, no release since July 2022. Upstream issues are yours regardless, and Nova support will not touch third-party fields. Copying the field class, Vue component, and config into your own namespace is roughly a day of work and removes most of the abandonment risk. Pin the versions you forked from.
Write the internal doc. Documentation is README-level; the vendor source is the real spec. Capture the block contract, the payload schema, the renderer's skip-unknown behavior, and the publish step. Onboarding a second developer otherwise means handing them the vendor directory.
Document size has a ceiling. Fine for pages, less so for documents with hundreds of nested blocks — save payloads and render time both climb. Measure once you have a page with 50+ blocks before assuming it scales.
Custom editor UX is a fork decision, not a config change. Per-block conditional fields, dynamic defaults, or bespoke validation UX mean patching the Vue component. Scope that as fork-and-own work before someone promises it to product.
Put a Nova upgrade task on the calendar. Every Nova/Laravel major upgrade gets a staging pass on this field specifically, plus 2–3 days of maintenance budget per quarter. That is the honest carrying cost of an unmaintained dependency you've chosen to own.
How can I help you explore Laravel packages today?