amplify/cms
A Laravel CMS package for managing pages, posts, and media through a database-driven admin interface. It aims to drop into existing Laravel applications, keeping content modeling simple and extensible, with support for custom content types and a quick, convention-based setup.
Install and publish. The package is a module for the Amplify plugin ecosystem, so it expects an Amplify host app (module loader, panel, and asset pipeline already in place). Inside such an app:
composer require amplify/cms
php artisan vendor:publish --provider="Amplify\Cms\CmsServiceProvider"
php artisan migrate
Publish both config and migrations before migrating — the published config is where content types, route prefixes, and middleware are declared.
Where to look first, in this order:
config/amplify/cms.php (or config/cms.php after publishing) — this is the contract for everything else: route prefix, middleware stack, content-type definitions, view namespace, cache settings.src/Models/ — the Eloquent models you'll actually query (Page, Post, Block, or similar). Read the relationships and scopes before writing any query; the package almost certainly ships published/unpublished scopes you should reuse rather than reimplement.routes/ and src/Http/Controllers/ — confirm what routes the package registers for you versus what you're expected to register. Run php artisan route:list --path=cms immediately after install.database/migrations/ — read the schema. Knowing which columns are nullable and which indexes exist saves hours later.resources/views/ — the default templates are your override baseline.First use case: get a single page rendering end-to-end before touching anything else. Create one record (via tinker or the admin panel), hit the public route, and confirm the view resolves. This single loop validates config publishing, migrations, route registration, middleware, and view namespace in one shot — every one of which is a common failure point in modular packages.
$page = \Amplify\Cms\Models\Page::where('slug', 'about')->firstOrFail();
echo $page->title;
Only after that loop is green should you start customizing content types or templates.
Treat config as the content model. Amplify modules tend to be config-driven rather than class-driven. Define content types in the config array rather than subclassing models, and keep the config in version control with a comment block explaining each key. If you find yourself overriding models to add fields, first check whether the config supports it — fighting a config-driven package with subclassing is the most common source of upgrade pain.
Route registration order is the whole game. CMS packages usually register a catch-all slug route. Register your specific routes (/contact, /search, /sitemap.xml) before the CMS wildcard, and constrain the wildcard:
Route::get('/{slug}', [PageController::class, 'show'])
->where('slug', '[a-z0-9\-\/]+')
->middleware('web');
Register the CMS routes last in RouteServiceProvider::boot(), or the catch-all will shadow your application's own routes.
Render through a view resolver, not hardcoded views. Centralize template selection so editors can pick a template per page without a deploy:
public function show(string $slug)
{
$page = Page::published()->where('slug', $slug)->firstOrFail();
return view($page->template ?: 'cms.page', compact('page'));
}
Wrap firstOrFail in a fallback that returns your themed 404, not the package default.
Blocks: eager load, then render via a registry. Page builders ship block relations; the N+1 is inevitable if you loop lazily.
$page = Page::published()->with('blocks')->where('slug', $slug)->firstOrFail();
@foreach ($page->blocks as $block)
@includeFirst(["cms.blocks.{$block->type}", 'cms.blocks.fallback'], ['block' => $block])
@endforeach
includeFirst gives you a safe fallback for block types an editor creates before you've built the partial — otherwise a typo in the admin takes down the page.
Cache at the response layer, invalidate on model events. CMS reads vastly outnumber writes.
$html = Cache::remember("cms.page.{$slug}", now()->addHour(), fn () => view(...)->render());
Then hook invalidation, not just on saved but on deleted and on any status change (an unpublish that leaves a stale cached page is a real production incident):
static::saved(fn ($page) => Cache::forget("cms.page.{$page->slug}"));
static::deleted(fn ($page) => Cache::forget("cms.page.{$page->slug}"));
Preview drafts behind signed URLs. Authenticated preview should use URL::temporarySignedRoute, not a query-string token or a session flag — it survives being pasted into Slack and expires on its own.
Seed for tests. Build a small factory helper that creates a published page and one of each block type, then use it in feature tests. You want a test asserting /about returns 200 with real HTML; that test catches route-shadowing, view-namespace, and cache regressions in a single assertion.
Wildcard route swallowing the admin panel. The single most common post-install bug. Symptom: /admin renders a 404 page from your CMS theme. Fix by ordering and by excluding reserved prefixes in the wildcard regex (admin|api|livewire|_debugbar).
Unique slug index vs. soft deletes. If the migration puts a plain unique index on slug and the model soft-deletes, you can never reuse a deleted page's slug. Either the package uses a composite index on (slug, deleted_at) or you'll need to mutate the slug on delete. Check the migration before assuming.
Config cache silently ignoring your edits. Modular packages often merge their own config defaults at boot. After editing published config: php artisan config:clear, then verify with php artisan tinker --execute="dump(config('amplify.cms'));". If your changes vanish, the package's mergeConfigFrom is winning and you need to override at the right key depth, not the top level.
Migration timestamp collisions. Published migrations carry the package's timestamps. If you have a migration creating a pages table from an earlier attempt, you'll get "table already exists" with no obvious cause. Drop and re-migrate in a fresh database before debugging anything else.
Stale cache on status transitions. saved fires on unpublish, but the cached public page is keyed by slug — an editor who unpublishes sees the change, visitors don't. Invalidate on any status transition, and consider a short TTL as a backstop so a missed event self-heals in an hour rather than forever.
vendor:publish drift. Note in your README which published files are customized and which are pristine. On upgrade, re-publish to a temp directory and diff — silently overwriting a customized template or config is the classic upgrade disaster with config-heavy modules.
Debugging checklist, in order: php artisan route:list --path=cms → php artisan about (confirm the provider registered) → check the view namespace resolves (view()->exists('cms.page')) → check the DB has rows and the published scope isn't excluding them → check cache keys. Ninety percent of "it renders nothing" reports resolve in the first two steps.
Extension points to prefer: model events over rewriting models, view overrides over controller overrides, config over subclassing, and middleware over patching the provider. If the package exposes a block-renderer registry or a template-resolver contract, bind your own implementation in a service provider rather than editing vendor files — this is what keeps a 0-star package survivable.
On maturity. With zero stars and no dependents, treat this as pre-1.0 in practice regardless of the version string. Pin the exact version in composer.json, read src/ directly when docs are thin (they will be), and keep your integration surface small: config, published views, and model events. If it becomes load-bearing, fork it into your own namespace so an abandoned upstream doesn't block your roadmap.
How can I help you explore Laravel packages today?