remipou/nova-page-manager
Nova Page Manager is a Laravel Nova package for managing CMS-style pages from your admin panel. It provides page resources with slugs, publishing controls, and customizable content/templates, making it easy to maintain static or editable site pages without a full CMS.
1. Install and publish
composer require remipou/nova-page-manager
php artisan vendor:publish --provider="Remipou\NovaPageManager\NovaPageManagerServiceProvider"
php artisan migrate
Publish tags vary by release — read vendor/remipou/nova-page-manager/README.md before running vendor:publish, and diff the config file against the package default so you know what is actually configurable (route prefix, middleware, model class, view namespace).
2. Register it in Nova
The package exposes either a Nova Tool or a Resource depending on version. Check NovaServiceProvider:
// If a Tool
public function tools()
{
return [ new \Remipou\NovaPageManager\NovaPageManager ];
}
// If a Resource
public function resources()
{
return [ new \Remipou\NovaPageManager\Page ];
}
Run php artisan nova:publish if the package ships compiled JS assets — otherwise the menu entry renders blank.
3. Read the migration first
Before writing any content, open the package migration and note the actual columns (slug, title, content, template, published, sort, meta fields). Everything you build — routes, navigation, sitemaps — keys off those columns, and guessing them is the fastest way to burn an afternoon.
4. First use case
Create an "About" page in Nova with slug about, publish it, and wire a catch-all frontend route:
Route::get('/{slug}', [PageController::class, 'show'])
->where('slug', '.*')
->name('page.show');
public function show(string $slug)
{
$page = Page::where('slug', $slug)->published()->firstOrFail();
return view($page->template ?? 'pages.default', compact('page'));
}
That single pattern — Nova for editing, one controller for rendering — covers 80% of day-to-day use of this package.
Keep developers out of the content loop. The value of the package is that a marketer edits /terms in Nova while you never touch a Blade file. Push layout, header/footer, and SEO components into your app layout and let the page resource own only the body copy.
Template switching per page. Add a template select field in the Nova resource if the package doesn't ship one, then resolve the view dynamically:
$view = match ($page->template) {
'landing' => 'pages.landing',
'legal' => 'pages.legal',
default => 'pages.default',
};
return view($view, compact('page'));
This is the cleanest way to get multiple page shapes out of a single resource without one giant @if in Blade.
Cache rendered pages, invalidate on save. Static pages are read constantly and written rarely — the ideal cache target:
// Page model
protected static function booted()
{
static::saved(fn ($p) => Cache::forget("page:{$p->slug}"));
static::deleted(fn ($p) => Cache::forget("page:{$p->slug}"));
}
public function show(string $slug)
{
$html = Cache::rememberForever("page:{$slug}", function () use ($slug) {
$page = Page::where('slug', $slug)->published()->firstOrFail();
return view($page->template ?? 'pages.default', compact('page'))->render();
});
return response($html);
}
Build navigation from the same table. Sorted, published pages make a free menu source:
View::composer('layouts.app', function ($view) {
$view->with('nav', Page::published()
->orderBy('sort')
->get(['title', 'slug']));
});
Cache this too — it runs on every request.
Seed pages for repeatable environments. Legal pages, cookie policies, and landing copy should exist on every fresh install and in CI:
Page::updateOrCreate(
['slug' => 'privacy'],
['title' => 'Privacy Policy', 'content' => view('legal.privacy')->render(), 'published' => true]
);
updateOrCreate on slug (not id) makes the seeder idempotent across environments.
Draft previews. Wrap an authenticated preview route around the same controller, bypassing the published scope:
Route::middleware('auth')->get('/preview/{slug}', [PageController::class, 'preview']);
Sitemap integration. Because slugs live in the DB, the sitemap must be generated, not hand-written:
Route::get('/sitemap.xml', fn () => response()
->view('sitemap', ['pages' => Page::published()->get()])
->header('Content-Type', 'text/xml'));
Multi-locale (if supported). If the schema has a locale column, treat (slug, locale) as the natural key and scope the controller by app()->getLocale(). If it doesn't, use slug prefixing (/en/about) rather than fighting the package.
Age is the headline risk. Last release August 2022 means it predates Nova 4/5 and Laravel 10/11 conventions. Check composer.json constraints in vendor/ before assuming compatibility; expect to either pin Nova/Laravel, fork the service provider, or add a replace/conflict entry. Budget an hour for this in a greenfield upgrade.
Do not trust the register method. Whether it's a Tool or a Resource changed across versions and is the single most common "installed but nothing appears in the sidebar" cause. Verify in the vendor source, not the README.
Route ordering will bite you. A {slug} catch-all must be registered after every other route, and it must not swallow login, admin, or Nova's own /nova path. Constrain it:
->where('slug', '^(?!nova|admin|api|login).*$')
Or scope it under a prefix (/p/{slug}) and save yourself the debugging.
Slug uniqueness with soft deletes. If the model soft-deletes, a plain unique(slug) index prevents reusing a slug after deletion. Use a composite unique with deleted_at or hard-delete; otherwise editors get an opaque SQL error when re-creating an old page.
Sanitize stored HTML. A WYSIWYG field renders straight into Blade. Either use {!! $page->content !!} with a deliberate, documented trust boundary, or pipe through a sanitizer. Never render editor-supplied HTML from a lower-trust role.
Publishing state leaks if you forget the scope. If published is a boolean rather than a published_at timestamp, remember drafts are only hidden where you explicitly scope — the Nova index and the frontend query are independent. Write a test asserting a draft returns 404.
Overrides get clobbered by republishing. Custom views belong in resources/views/vendor/nova-page-manager/, not in the package path. Same for the config — never edit vendor/. Running vendor:publish --force during an upgrade will overwrite anything you put in the wrong place.
No factories. Package models rarely ship test factories. Write your own immediately; without one, your feature tests will end up bypassing the package and stop testing the thing you integrated.
Debugging checklist for a missing page:
php artisan route:list | grep page — is your controller even registered?Str::slug() on save — inspect the DB.published true, and deleted_at null?config('nova-page-manager.route_prefix') — did a published config override the default?php artisan route:clear && php artisan config:clear after touching the config.Extension points to prefer. Extend the package's Page model in your app (app/Models/Page.php) and repoint the config's model key, rather than patching the vendor class. Same for the controller — bind your own controller in the config or route and leave the package's file untouched, so composer update never conflicts with your work.
How can I help you explore Laravel packages today?