backpack/pagemanager
Backpack PageManager gives your Laravel app a ready-made admin interface for managing static pages. Create pages from templates, edit content and SEO fields, control slugs and routes, and let clients update site content without touching code. Built on Backpack for Laravel.
Install and wire up (5 minutes):
composer require backpack/pagemanager
php artisan vendor:publish --provider="Backpack\PageManager\PageManagerServiceProvider"
php artisan migrate
Then add the public catch-all route at the bottom of routes/web.php:
Route::get('{slug?}', '\Backpack\PageManager\app\Http\Controllers\PageController@index')
->where('slug', '.*');
The admin CRUD is registered for you at /admin/page (sidebar item, list, create/edit with the standard Backpack UI). The package gives you a pages table with template, name, title, slug, content, extras (JSON) and extend.
Look here first:
config/backpack/pagemanager.php (older releases: config/laravel-page-manager.php) — this holds page_templates, the heart of the package. Each entry has a name, a view, and an array of Backpack fields.vendor/backpack/pagemanager/src/app/Http/Controllers/PageController.php — ~15 lines. Read it once; it defines the whole public contract (find by slug → abort(404) → render the template view with $page).vendor/backpack/pagemanager/src/app/Models/Page.php — a plain Eloquent model with CrudTrait.First real use case: create an "About" page. Add a standard template to the config pointing at pages.standard, create resources/views/pages/standard.blade.php that @extends('layouts.app') and prints {!! $page->content !!}, then create the page in /admin/page. That's the entire loop — config → blade → admin.
Template with custom fields. Anything you add under fields becomes an admin form input and is persisted into the extras JSON column, so you get per-template metadata without touching the schema:
'page_templates' => [
'standard' => [
'name' => 'Standard',
'view' => 'pages.standard',
'fields' => [
['name' => 'meta_title', 'label' => 'Meta Title', 'type' => 'text'],
['name' => 'meta_description', 'label' => 'Meta Description', 'type' => 'textarea'],
['name' => 'hero_image', 'label' => 'Hero', 'type' => 'image', 'crop' => true],
],
],
'contact' => [
'name' => 'Contact',
'view' => 'pages.contact',
'fields' => [['name' => 'map_embed', 'label' => 'Map', 'type' => 'textarea']],
],
],
In the blade view: $page->extras['meta_title'] ?? $page->title. If you prefer attribute access ($page->meta_title), add an accessor on a subclassed model rather than sprinkling array lookups through views.
Meta tags / layout integration. Don't hardcode SEO fields per template view. Resolve them once in the layout:
<title>{{ $page->extras['meta_title'] ?? $page->title ?? config('app.name') }}</title>
and render the sections from pages.* into named yields.
Custom data in the page. The public route is yours, so the cleanest override is a controller that resolves the view from config and injects extra data — no vendor file editing:
class PageController extends Controller
{
public function index($slug = null)
{
$page = Page::where('slug', $slug)->firstOrFail();
$template = config('backpack.pagemanager.page_templates')[$page->template] ?? abort(404);
return view($template['view'], [
'page' => $page,
'related' => Post::latest()->take(3)->get(),
]);
}
}
Point the catch-all route at this controller instead. For truly global data (nav, footer), a View::composer('pages.*', ...) is even less invasive.
Seed pages for reproducible deploys. Pages are content, but the structure of key pages (home, about, legal) usually belongs in a seeder:
Page::updateOrCreate(['slug' => 'about'], [
'template' => 'standard',
'name' => 'About',
'title' => 'About Us',
'content' => '<p>…</p>',
'extras' => ['meta_title' => 'About Us'],
]);
Reuse the model elsewhere. Page is a normal Eloquent model — use it for a sitemap (Page::pluck('slug')), a footer menu (Page::whereIn('slug', [...])), or Route::has-style link generation. Caching the query is worthwhile because the catch-all hits the DB on every request:
$page = Cache::rememberForever("page:{$slug}", fn () => Page::where('slug', $slug)->firstOrFail());
Bust it in a saved/deleted model observer keyed on the old and new slug.
{slug} / .* route must be the last route in web.php. Put it above anything else and it silently swallows /login, /contact-form POST targets' siblings, and every other GET route. If routes suddenly 404 or render the wrong thing, this is the first thing to check./{username} style route defined after it will never fire.services/web-design) thanks to the .* constraint. That also means a leading slash or trailing slash in the slug breaks matching; normalize on save.standard to contact in the admin swaps the form fields, but the old JSON keys remain in extras. Harmless at render time, confusing during debugging — null-coalesce every extras lookup.view file is not shipped. You create resources/views/pages/*.blade.php yourself; a missing file gives View [pages.x] not found, not a helpful package error.config:cache after editing templates. Template definitions are config; on cached production configs, adding a field in the config file does nothing until you re-cache. Same for the view path.status/published_at column via migration plus a global scope on a Page subclass — don't expect the package to grow them.about, en/about) or add a locale column and scope the controller query by app()->getLocale().Route::crud('page', PageCrudController) itself. To customise it, copy the controller into app/Http/Controllers/Admin/PageCrudController.php and register it under a different URI (e.g. pages) in routes/backpack/custom.php, or extend the model instead of the form.name, not title. If you want slugs from the title, adjust the slug field in your own CRUD controller; otherwise train editors to fill name meaningfully (it's what appears in the admin list).extras must be cast to array on any model subclass, or Eloquent hands you a raw JSON string and every view blows up with "Trying to access array offset on string".abort(404) — no logging, no hint it was PageManager. When a page "doesn't exist" but the row is in the DB, check for trailing whitespace in the slug and whether the route is actually reached (php artisan route:list).^4.0, ^6.0, …) to match your installed Backpack and re-read the config after every upgrade.How can I help you explore Laravel packages today?