optimistdigital/nova-page-manager
Built for Laravel Nova, this package lets you create, organize, and edit site pages with customizable templates, multilingual content, SEO fields, and slugs—all from the Nova admin panel. Includes block-based content editing, page structure control, and easy translation management.
Confirm the install identity before you write a line of code. This package has been published under two names: optimistdigital/nova-page-manager (namespace OptimistDigital\PageManager) and its current home outl1ne/nova-page-manager (namespace Outl1ne\PageManager). Most blog posts and Stack Overflow answers still reference the old one, so a copy-pasted import will produce Class "OptimistDigital\PageManager\..." not found in a project where the install is perfectly healthy. Run composer show | grep page-manager, note the namespace, and stick to it everywhere.
composer require outl1ne/nova-page-manager
php artisan vendor:publish --provider="Outl1ne\PageManager\PageManagerServiceProvider"
php artisan migrate
Prerequisites, checked in this order: a working Laravel Nova install (this is a Nova tool, not a standalone CMS), the Nova major version the package's composer.json supports, and the package's own PHP/Laravel constraints. If Nova isn't rendering other resources correctly, fix that first — every error you see afterwards will be misattributed.
Then read config/nova-page-manager.php immediately. This file is the real API. Templates, blocks, regions, locales, default locale, and the region resolver all live there. Everything you will do day-to-day is either editing this file or consuming what it produces. The published file also gives you the migrations for pages, page translations, and regions.
Register the two Nova resources so editors get a UI:
// app/Providers/NovaServiceProvider.php — boot()
Nova::resources([
\Outl1ne\PageManager\Nova\Page::class,
\Outl1ne\PageManager\Nova\Region::class,
]);
First use case: one page, end to end, in an afternoon. Declare one template with two blocks and one region:
// config/nova-page-manager.php (shape abbreviated — match your published version)
'locales' => ['en'],
'default_locale' => 'en',
'templates' => [
'default' => [
'label' => 'Default Page',
'blocks' => [
'hero' => [
'title' => 'Hero',
'fields' => [
\Laravel\Nova\Fields\Text::make('Heading'),
\Laravel\Nova\Fields\Image::make('Background'),
],
],
'text' => [
'title' => 'Text',
'fields' => [
\Laravel\Nova\Fields\Textarea::make('Body'),
],
],
],
],
],
'regions' => [
'header' => 'Header',
],
Create a page in Nova with slug about, template default, one hero and one text block, then publish it. The package deliberately does not route or render anything for you — that is the single most important thing to internalise on day one. You own the frontend:
// routes/web.php
Route::get('/{slug?}', [PageController::class, 'show'])->where('slug', '.*');
// app/Http/Controllers/PageController.php
public function show(?string $slug = null)
{
$page = Page::findBySlug($slug ?: '/'); // adjust to the resolver your version exposes
abort_unless($page?->isPublished(), 404);
return view('pages.show', compact('page'));
}
{{-- resources/views/pages/show.blade.php --}}
@extends('layouts.app')
@section('content')
@foreach ($page->blocks as $block)
@includeIf("blocks.{$block['key']}", ['block' => $block])
@endforeach
@endsection
Add resources/views/blocks/hero.blade.php and text.blade.php. When that loop renders real content, you have validated the whole chain: config → Nova editing → JSON storage → your Blade components. Do that spike before anyone commits a marketing site to this.
Treat templates and blocks as a code-reviewed component library, not a page builder. Every block key in config should map 1:1 to a Blade partial under resources/views/blocks/. Adding a block type should be a single PR that touches three things — config, partial, test — and nothing else. Keep that directory as the canonical inventory, and add a test that walks every configured template's blocks and asserts the matching partial exists. Without it, @includeIf fails silently and you ship a page with invisible sections instead of an exception.
Never call the model from a view or route directly. Wrap it in one thin application-owned class. The package's real selling point is the exit ramp; a repository keeps the exit a one-file change:
// app/Support/Pages/PageRepository.php
class PageRepository
{
public function findByPath(string $path, ?string $locale = null): ?Page
{
return Cache::remember(
"page:{$locale}:{$path}",
now()->addHour(),
fn () => Page::findBySlug($path, $locale),
);
}
}
Resolve locale once in middleware, pass it in explicitly, and let the repository own published-state filtering and the 404. The controller stays four lines and every future content-delivery decision has one home.
Invalidate on save, never on deploy. Content lives in MySQL as structured JSON, so your deploy pipeline never touches it — which means a deploy cannot fix a stale cache. Register a model observer on saved and deleted that forgets page:* and region:*, plus the translation models. Otherwise "publishing didn't work" tickets are cache tickets.
Regions belong to the layout, resolved once per request. Resolve header/footer/promo slots in a View::composer('layouts.app', ...) scoped by locale, cached separately from pages. If your version's region resolver is page-aware, decide deliberately whether a region edit is global or per-page — mixing both in one site is a reliable support-ticket generator.
Ship preview before you ship anything else. Editors do not trust a draft/publish toggle they cannot see through. Add an authenticated preview route that skips the published check, gate it on Auth::guard('nova')->check() or a signed URL, and link it from the Nova resource. This is the highest-value twenty lines you will write on top of the package.
Emit SEO metadata once, in the layout. Put title/description/OG fields on the template (or a shared SEO block group), then output them from layouts.app with fallbacks to the page title and a site-wide default. Derive the canonical URL from the slug, not request()->url(), so it stays stable behind proxies and query strings.
Localization: fall back, don't fabricate. Look up the requested locale, then default_locale, then 404. Never fall back to a locale you never seeded — you will render empty pages that look published. And keep user-visible strings out of block partials; route them through translation files.
Migrate and back up with artisan commands. Content is plain JSON in your own tables, so php artisan pages:import export.json is straightforward. More importantly, write php artisan pages:export before you need it — that dump is both your backup and your migration path off the package.
Extend the Nova resources, never edit them. Subclass:
class Page extends \Outl1ne\PageManager\Nova\Page
{
public function fields(NovaRequest $request): array
{
return [...parent::fields($request), /* your columns */];
}
}
Register your subclass in NovaServiceProvider instead of the vendor class. Use the config's resolver/class hooks for region and page resolution. Anything under vendor/ is lost on the next composer update.
composer show, pin the major version in composer.json, and grep the app for stale OptimistDigital\PageManager imports before debugging an install that was never broken.php artisan config:cache in staging before you depend on it. Templates place Nova field objects inside config arrays, and config caching serialises values into a PHP file. If your pinned versions don't survive that, you find out in a deploy. If it breaks, move template definitions into a service provider or a small registry class resolved at runtime.hero_v2) and write a one-off migration command when you rename — or accept the data loss knowingly.variant field.php artisan tinker plus a raw dump of the JSON column settles it in thirty seconds.about to /company/about is a redirect project, not a config change. Whatever you pick, add a redirects table or middleware while the site is small.outl1ne/nova-file-manager or spatie/laravel-medialibrary, and store relative paths in block fields rather than full CDN URLs — domains change, stored content shouldn't have to.php artisan view:clear away, and it will look like a data bug.pages:export current, and keep block partials free of package-specific calls so a fork or migration stays a contained project rather than a re-platforming.How can I help you explore Laravel packages today?