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 Gutenberg Laravel Package

genealabs/nova-gutenberg

Adds a Gutenberg block editor field to Laravel Nova, letting you build rich content with WordPress-style blocks inside your admin panel. Stores block content as JSON/HTML and renders it on the front end, bringing flexible page building to Nova resources.

View on GitHub
Deep Wiki
Context7

Getting Started

Preconditions. Laravel Nova (the paid admin panel) must already be installed and working. This package is a Nova field — it does nothing outside a Nova resource form. Check the composer.json constraints in the 0.6.1 tag against your PHP and Nova versions before anything else; the package is archived, so no one is going to widen those constraints for you.

Minimal install.

composer require genealabs/nova-gutenberg

Publish the field's front-end assets (the exact tag/provider is in the package's service provider — read it, don't guess):

php artisan vendor:publish --provider="GeneaLabs\NovaGutenberg\FieldServiceProvider"

Then add a storage column. Use a dedicated json column rather than overwriting whatever currently holds the content:

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

Cast it on the model so the field receives an array rather than a string:

protected $casts = [
    'content' => 'array',
];

And register the field:

use GeneaLabs\NovaGutenberg\Gutenberg;

public function fields(Request $request)
{
    return [
        Gutenberg::make('Content', 'content')
            ->rules('required'),
    ];
}

Where to look first. Before writing any application code, read three things in vendor/genealabs/nova-gutenberg/:

  1. The Gutenberg field class — this tells you the real API (make(), withFiles(), stacked(), options setters). The README is a marketplace blurb; the source is the documentation.
  2. The service provider — for asset publish tags and config publishing.
  3. dist/js/field.js (or equivalent) — just to see what it loads and whether it fetches anything at runtime.

First use case. Pick one internal, low-risk resource — a "Pages" or "Articles" model in a staging environment — and replace a Textarea field with Gutenberg. Save a few blocks with paragraphs, a heading, a list, and an image. Then dump the raw JSON column (dd($page->content)) so you understand exactly what shape you now own. That single exercise answers most of your integration questions: how blocks are keyed, how attributes nest, and how images are referenced.

Do this before designing anything else, because the JSON schema becomes your system of record.

Implementation Patterns

Treat JSON as canonical, HTML as derived. The single most important pattern: never render block JSON at request time on a public page. Persist a rendered, sanitized HTML cache alongside it.

// pages table
$table->json('content')->nullable();
$table->longText('content_html')->nullable();
$table->timestamp('content_rendered_at')->nullable();

Re-render on save via an observer, not in the controller:

class PageObserver
{
    public function saving(Page $page): void
    {
        if ($page->isDirty('content')) {
            $page->content_html = app(BlockRenderer::class)->render($page->content ?? []);
        }
    }
}

This gives you rollback safety (the JSON is untouched if your renderer throws), cheap page loads, and a single chokepoint for sanitization.

Write the renderer yourself. The package hands you block JSON; it does not credibly hand you a hardened HTML pipeline. A block renderer is a match over blockName — paragraph, heading, list, image, quote, embed — with a sanitizer (HTMLPurifier via mews/purifier, or Symfony's HtmlSanitizer) applied to any innerHTML you echo, and an allowlist for embed URLs. This is the piece with real security weight and it is maybe 150 lines. Keep it in app/Support/Blocks/ and unit-test it with fixture JSON.

Escape at the boundary, not everywhere. In Blade:

{{-- content_html is already sanitized by the renderer --}}
{!! $page->content_html !!}

Every other path — previews, API responses, emails — must go through the same renderer. Do not add a second, divergent rendering path; that is how unsanitized output leaks.

Field composition inside Nova. Keep the Nova field lean and push logic into the model:

Gutenberg::make('Content', 'content')
    ->onlyOnForms()
    ->rules(['required', new ValidBlockDocument])
    ->help('Blocks save as structured JSON. Preview updates on save.'),

On index/detail views, don't dump the JSON blob. Show a derived summary:

Text::make('Blocks', fn () => count($this->content ?? []))
    ->onlyOnIndex(),

Text::make('Excerpt', fn () => Str::limit(strip_tags($this->content_html), 80))
    ->onlyOnIndex(),

Migrating from a textarea or WYSIWYG. Do it as a one-off artisan command, not a field change. Split legacy HTML on paragraph boundaries into paragraph blocks, run each through your sanitizer, write to the new content column, and leave the old column in place for at least one release cycle behind a feature flag. Expect fidelity loss on tables, nested lists, and inline styles — accept it and tell stakeholders up front.

Multi-locale, multi-variant. One JSON column per locale (content_en, content_fr) is more honest than a nested blob, because Nova's field bindings map to a single attribute. Same for channel variants — if you need the same content rendered to email, store the block JSON once and render twice with different renderers, rather than forking the data.

Media strategy. Decide early how image blocks reference files: absolute URL to a CDN, or a Nova file path. Absolute URLs survive deployments and storage moves; relative paths to storage/ do not. Whatever you pick, document it, because a storage:link change or a move to S3 will break every existing block.

Testing. Fixture-driven. Keep a handful of real block-document JSON files in tests/fixtures/blocks/, assert the renderer output for each, and assert a full round-trip through the model's casts. This is your only regression safety net for Nova upgrades.

Vendor it. Because upstream is archived, mirror the pinned commit into a private VCS or repositories entry in composer.json rather than depending on Packagist. Then patching a Nova 5 incompatibility is a branch, not a fork-and-pray.

Gotchas and Tips

The package is archived — pin and own it. Use an exact version ("genealabs/nova-gutenberg": "0.6.1"), commit the lock file, and vendor the source. There is no upstream fix coming for any Nova break, any Gutenberg advisory, or any bug you find. Budget a named owner.

JSON double-encoding is the classic failure. If the model doesn't cast the attribute to array, the field may receive a JSON string and the editor will either fail to hydrate or you'll save "\"{...}\"". Conversely, if you cast to array and then json_encode() before assignment, you get the same problem. Rule: the field reads and writes a PHP array; the cast does the serialization.

Empty is ambiguous. Fresh records hand you null, an editor who deletes everything hands you [] or [["blockName" => null, ...]] (Gutenberg's empty-block placeholder). Normalize in the renderer and treat all three as "no content" — otherwise you'll ship empty <p></p> wrappers and blank excerpts.

Asset 404s after deploy. The field's JS/CSS live in public/vendor/... after publishing. Common causes of a broken editor in production: publish step missing from your deploy pipeline, php artisan config:cache run before publish, or a CDN/asset host that doesn't include the Nova vendor path. Symptom is a blank field with a console 404. Add the publish command to deploy and add a smoke test that loads a resource form.

CSP will bite you. Gutenberg's bundle is large and has historically used inline styles and dynamic evaluation. If you run a strict Content-Security-Policy on the admin panel, expect script-src violations and inline-style blocking. Test the editor under your real headers, not just in local. You'll likely need a nonce or a relaxed policy scoped to Nova routes.

There is no autosave and no revision history. A failed save or a mis-click loses the editor's work. Say so in the help text, and consider a lightweight client-side draft (localStorage) or an explicit "save draft" action if content volume justifies it. Don't promise versioning this package doesn't provide.

Sanitization is yours, definitively. The docs don't describe a sanitization layer, and stored block HTML rendered on public pages is a direct XSS vector — especially through core/html (classic/raw HTML) and core/embed blocks. Either strip core/html from the allowed block set, or sanitize its innerHTML with a strict allowlist. Run a render-failure alert, too: one malformed JSON document should not take down a content page — wrap rendering in a try/catch that falls back to the last good content_html.

Querying JSON is painful. whereJsonContains works on MySQL 8 / Postgres but is slow at scale and can't power Nova's search box usefully. If you need searchable content, add extracted columns (title, excerpt, plaintext) populated in the observer, or index externally. Don't discover this after launch.

Large documents hit request limits. Long-form pages with many embeds produce multi-hundred-KB JSON payloads. Watch post_max_size, upload_max_filesize, and MySQL max_allowed_packet. Symptom: saves silently truncate or 413 in production while working fine locally.

Nova upgrade protocol. Every Nova major/minor bump is a gated change: load one resource form, create a block, save, reload, render. If field registration or asset loading moved, patch your vendored copy. Keep that checklist written down — it's the whole point of the fork.

Extension points. Realistically: the field class (options, validation, resolve/fill behavior), the published config/block allowlist, and the JS entrypoint if you need custom blocks. Custom blocks mean rebuilding the JS bundle with Gutenberg packages — significant effort and a second thing to maintain. Exhaust the default block set first; custom block types are rarely worth it on an archived base.

Debugging workflow. When the editor won't load: browser console (asset 404 vs. JS error), then network tab on save (inspect the actual request payload — is it JSON or a string?), then dd() in the field's resolve() to see what PHP is handing to the client. That order resolves most issues in under ten minutes.

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.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route