outl1ne/nova-page-manager
A Laravel Nova package for creating and managing pages with flexible, block-based content. Build dynamic page layouts, edit SEO fields, and organize site content from a clean Nova interface, ideal for marketing sites, CMS-driven apps, and any project needing structured pages without a full CMS.
Install (well under an hour):
composer require outl1ne/nova-page-manager
php artisan vendor:publish --tag=nova-page-manager-config
php artisan vendor:publish --tag=nova-page-manager-migrations
php artisan migrate
Publish per tag rather than all-at-once, so a later vendor:publish doesn't overwrite views or config you've customized. Then register the package in app/Providers/NovaServiceProvider.php — follow the README of your pinned version, since this is the step that has historically changed between majors (a NovaPageManager::make() entry in tools() in some versions, explicit Page/Region resource registration in resources() in others).
Where to look first, in order:
config/nova-page-manager.php — the most important file in the package. Page/Region model bindings, route registration, locale handling, and view resolution all live here. Almost every customization starts with a config edit.vendor/outl1ne/nova-page-manager/src/ — the package is thin. Twenty minutes of reading answers most "does it support X" questions faster than the docs do.First use case — a marketing page end to end:
Home, slug home, choose a page template.hero region with the fields your template expects.GET /home renders the default Blade view — confirm the wiring works before writing any application code.Day-one decision: does the package own the public route, or does your app? Start with the package route for the tightest feedback loop while you validate the data model; take routing back the moment you need middleware, layouts, or caching. Most teams end up owning it.
Pattern 1 — package routing as scaffolding. The catch-all /{slug} route gives instant "create a page, see it in the browser" feedback. Perfect for about, terms, privacy. Disable it once you need auth-gated content, per-section middleware, or a real cache layer.
Pattern 2 — app-owned routing (what you ship). Turn the package route off and resolve pages yourself:
Route::get('/{slug}', function (string $slug) {
$page = Page::where('slug', $slug)
->where('published', true) // add this yourself — see Gotchas
->with('pageRegions.region') // always eager-load
->firstOrFail();
return view('pages.show', compact('page'));
})->where('slug', '.*');
Eager-loading pageRegions.region is non-negotiable. N+1s here are invisible until a page has twenty regions, then they're everywhere.
Pattern 3 — one map from region type to view. Never scatter @includeIf strings across templates. Keep the mapping in config so a new block type requires no Blade edits:
// config/regions.php
return [
'hero' => 'regions.hero',
'rich-text' => 'regions.rich-text',
'footer-cta' => 'regions.footer-cta',
];
@foreach ($page->pageRegions as $pageRegion)
@php($view = config("regions.{$pageRegion->region->type}"))
@if ($view && view()->exists($view))
@include($view, ['region' => $pageRegion])
@endif
@endforeach
The guard matters: an unknown type should degrade to nothing, not 500 the page.
Pattern 4 — regions as a shared component library. Regions are first-class and reusable, which is the real day-to-day win over hand-rolled CRUD. A footer-cta or sidebar edited once appears on many pages; you maintain one Blade partial per region type. Create the region in the Region resource first, then attach — not the other way around.
Pattern 5 — bind your own models via config. Need extra columns, casts, accessors, or relationships? Don't subclass inline — point the config at your models:
// config/nova-page-manager.php
'page_model' => App\Models\Page::class,
'region_model' => App\Models\Region::class,
Keep your Page extending the package model so the Nova resource keeps functioning. This is the cheapest extension point; exhaust it before considering a fork.
Pattern 6 — cache the read path, bust on save. The DB is never the bottleneck at hundreds-to-low-thousands of pages, but you don't want the public site touching these tables per request:
// App\Observers\PageObserver
public function saved(Page $page): void
{
Cache::forget("page:{$page->slug}");
if ($page->wasChanged('slug')) {
Cache::forget('page:'.$page->getOriginal('slug')); // don't forget the old one
}
}
Render with Cache::remember("page:{$slug}", 3600, fn () => view('pages.show', ...)->render()) and register the observer in a service provider — the package does none of this.
Pattern 7 — add a published flag before anyone else touches it. There is no publish workflow. Add the column via your own migration, filter on it in the resolver, expose a Nova toggle, and (if you want scheduling) a published_at. Add a signed preview route that bypasses the filter — editors will ask in week two.
Pattern 8 — seed content for local dev. Pages authored in staging don't exist in a fresh clone. Seed a minimal page set (one page per template) or take a DB dump, or every new developer sees 404s and assumes the install is broken.
Pattern 9 — brownfield migration. If you already have pages/content_blocks, write a one-off mapping migration (slug/title/body → page + region rows), keep the legacy table read-only for one release as a rollback path, and cut over when the renderer matches. Content is cheap to move early and expensive after editors have invested in it.
Pattern 10 — localization is a data concern, not a package feature. The package supports per-locale content, but routing locale into the resolver is yours. Keep the locale in the URL prefix or a session key, pass it into every query, and define a fallback locale so a half-translated page doesn't render empty regions.
Saves go live instantly. No drafts, no revisions. This is the biggest operational surprise for a non-technical team — add published before handover, or unreviewed copy ships.
Sanitize rich content on render, not just on save. Whatever field holds HTML will eventually carry a <script> — a paste, a compromised account, a code-view toggle. Purify at render time; save-time-only leaves historical content dirty.
Slug changes silently break URLs and caches. Enforce uniqueness in validation, write a 301 on change, and bust the cache for the old slug too:
public function updating(Page $page): void
{
if ($page->isDirty('slug')) {
Redirect::create([
'from' => '/'.$page->getOriginal('slug'),
'to' => '/'.$page->slug,
'code' => 301,
]);
}
}
Region-type → view mismatches fail blank, not loudly. A missing view renders an empty gap and the editor reports "the page is broken." Guard with view()->exists() and, outside production, render a visible placeholder naming the missing view.
Missing region = blank section, always. Decide a fallback per type (empty-state copy, default image) inside the partial. Editors detach regions to "test something" and forget to reattach.
Watch the catch-all route. /{slug} will shadow or be shadowed by your routes depending on registration order. If GET /login starts returning a page-manager 404, that's why. Owning routing explicitly deletes this whole class of bug.
Config caching hides your changes. php artisan config:cache locally, or a forgotten config:clear after editing nova-page-manager.php, makes model bindings look inert. When a binding "isn't working," clear config first.
Extend additively; don't rewrite packaged migrations. Adding columns to pages via your own migration is fine, but a future package migration altering the same table can collide. Keep your schema deltas isolated.
Pin the exact version and record the constraint matrix. Laravel / PHP / Nova versions in your README, plus a CI job that installs against the pinned Nova version — a breaking Nova major should fail in CI, not in the editor's admin panel. A changelog line like 6.0.3's "Fixed saving with MergeValue inputs" is exactly the kind of bug that only surfaces when someone hits Save on a Flexible Content field; know whether your version has it.
Pre-agree the fork trigger. Nova ships a major roughly annually and third-party compatibility lags. The decision to make now is not "will we fork" but "at what point" — e.g. if compatibility trails by more than one release cycle, vendor into packages/ and own it. MIT licensing makes that cheap; deciding under pressure makes it expensive.
Nova form performance degrades with many regions per page. Test with a realistic page (20+ regions) before committing. The bottleneck, if any, is the editor form, not the database. If it's slow, split long pages across templates rather than piling more fields into one region.
Write the editorial process down. Ramp-up is asymmetric: a Laravel dev is productive in hours, editors need fifteen minutes of Nova training — but with no drafts or revisions, process documentation matters more than tooling. Record who publishes and how.
How can I help you explore Laravel packages today?