whitecube/nova-page
Laravel Nova package for managing static pages with customizable templates, SEO fields, and flexible content blocks. Lets editors build and organize site pages directly from Nova, with translatable fields, slug handling, and publishing controls for fast, structured page management.
Verify the Nova constraint first — it's the whole adoption decision. v0.4.0 exists only to add Nova 5 support, and it came from a community PR. Before writing any template code, run composer show whitecube/nova-page --all and read the require block. If that release replaced the Nova 4 constraint rather than widening it, you've just been handed a forced Nova upgrade. That's a roadmap decision, not a composer update, so settle it on day zero.
Install and scaffold in one pass:
composer require whitecube/nova-page
php artisan vendor:publish --provider="Whitecube\NovaPage\NovaPageServiceProvider"
php artisan migrate
Depending on which tags you publish, you get the config, the pages migration, and a PageResource. Register the resource in NovaServiceProvider::resources().
Then read four files — they are the entire mental model:
config/nova-page.php — the real API surface. It maps a permanent template handle (default, legal, landing) to a template class and a Blade view, plus locales, routing, and SEO defaults.pages migration — content is a JSON payload keyed by template handle, alongside slug, template name, and a publish flag. There are no per-field columns, and that shape is what makes everything else cheap.Template base class — you subclass it per page archetype and implement the method that returns Nova fields (and the SEO variant). This is where 90% of your daily work happens.PageResource — intentionally thin; resist the urge to put logic here.First vertical slice, half a day: one "Standard page" template, used by About / Terms / Privacy.
// app/Nova/Pages/DefaultTemplate.php
namespace App\Nova\Pages;
use Whitecube\NovaPage\Pages\Template;
use Laravel\Nova\Fields\{Text, Image, Trix, Textarea};
class DefaultTemplate extends Template
{
public function fields()
{
return [
Text::make('Title')->rules('required'),
Image::make('Hero', 'hero')->disk('public'),
Trix::make('Body', 'body'),
];
}
public function seo()
{
return [
Text::make('SEO title', 'seo_title'),
Textarea::make('Meta description', 'seo_description')->rules('max:160'),
];
}
}
Add one templates entry in config pointing at the class and a Blade view, copy the shipped view into resources/views/pages/, create a page in Nova with a slug, publish it, hit the URL. Once that renders end-to-end, every subsequent page type is copy-paste-and-edit. Confirm the exact config array keys against the readme once — they're the only strings you must get precisely right.
Template-as-class is the core pattern and the reason to adopt this. One class per archetype (LandingTemplate, LegalTemplate, TeamTemplate), each owning its field set, its SEO fields, and its view. Content structure lives in code, so adding a field is a deploy, not a migration. A content request like "add a subtitle to legal pages" becomes a fifteen-minute PR touching one class and one Blade file — and that's the day-to-day shape of the whole job.
Treat template handles as permanent database keys. The handle string is what's stored on every row. Renaming a config key orphans every existing page of that type. When you genuinely must change one: add the new handle, run a one-off Page::where('template', 'old')->update(['template' => 'new']), then delete the old key. Never repurpose a retired handle with new meaning.
Blocks for repeatable structure. For landing pages, store an array of typed blocks in a JSON field and render against a whitelist:
@foreach ($page->getContent('blocks', []) as $block)
@includeIf("pages.blocks.{$block['type']}", ['block' => $block])
@endforeach
Declare valid types as a static array or enum on the template class and validate on save. The package stores whatever Nova persisted; your render layer is the only guard against a typo'd block type silently disappearing.
Own your routing. You can let the package register a frontend route, or disable it and write a catch-all controller placed last:
Route::get('/{slug}', [PageController::class, 'show'])
->where('slug', '.*')
->name('page.show');
In an existing app, the second is almost always correct: you control 404 semantics, cache headers, draft previews, reserved prefixes (/admin, /api, /blog), and redirect history.
Never let Blade reach into raw JSON. Add a small presenter (or accessors on the Page model) for the repeated concerns: SEO title falling back to page title, image URL resolution, locale lookup, defaults for every field. This is the single biggest ergonomics win you can add on top of the package — the difference between a copy change touching one file or twenty views.
Localization: build the fallback chain yourself. Requested locale → configured default → '', resolved in the presenter, with hreflang emitted from the locale map. Otherwise missing translations render as empty <title> tags and blank meta descriptions.
Cache rendered HTML, invalidate on save. Public pages are read-heavy and content changes are rare. Key on slug + locale, and hook the model's saved/deleted events to forget the key. Set Cache-Control in your controller — another payoff for owning the route.
Seed for parity, not for prose. A seeder that upserts by slug makes fresh installs and staging reproducible for fixed pages (legal, about). For marketing pages, seed the template + slug only and let content owners fill copy. Never seed production prose into every environment.
Test through feature tests, not factories. None ship. Create pages directly through the model, then assert: published returns 200, draft returns 404, unknown slug returns a real 404, each template renders its expected fields. Add one test that iterates every config template handle and asserts the class and view exist — it catches the most common deploy mistake before a page does.
The catch-all swallows everything. A .* slug route sitting above other routes will eat /login, /blog, and API 404s. Put it last, constrain the pattern, and make sure the controller returns a genuine 404 — "every URL returns 200 with an empty page" is the classic symptom and it wrecks SEO.
Slug changes break inbound links silently. There is no redirect table. Pick a policy before launch: either a page_redirects table checked by the controller before 404, or slug edits become an explicit editorial runbook task. Decide now, not after a campaign URL has been emailed out.
JSON schema drift is your migration problem. Rename a field in your template's field list and existing rows still hold the old key. There are no content migrations. Defensive defaults on every accessor plus a one-off backfill command for shape changes is the honest pattern.
Sanitize rich text at render. Trix output is not a trust boundary. Sanitize with HTMLPurifier or mews/purify at render time, or on save via an observer — otherwise a careless editor account is stored XSS on every page of the site.
config:cache hides new templates. Adding a template key without re-caching (or clearing in dev) presents as "the template doesn't exist." Same story for Nova's asset caches. When a template won't appear, run config:clear && nova:clear before you start debugging the class.
Deleted pages leave their media behind. Removing the row doesn't remove the hero image. Add a deleting observer to prune referenced files if S3 cost or GDPR matters.
No editorial workflow, by design. Draft/published is a boolean — no approvals, scheduling, or versioning. If regulated pages need those, the honest answer is process discipline plus restricted publish permission, not this package. Write that assumption down; it's the trigger for revisiting CMS investment later.
Bugs surface as blank output, not exceptions. Missing translations, renamed JSON keys, and typo'd block types all fail silently. Your presenter-with-defaults is also your debugging tool — log when a fallback fires outside production.
Small surface, single maintainer. 239 stars, zero dependents to absorb a break, one maintainer. MIT plus a codebase you can read in an afternoon makes that acceptable: do that read before production, and keep the "vendor into packages/ via a path repository" contingency in the runbook. Extension points are the config, your Template subclasses, and the published views/resource — there is no plugin system, so subclass and override views rather than patching vendor files. That discipline is what keeps the fork contingency cheap.
How can I help you explore Laravel packages today?