idavoll/page-module
A Laravel package for adding CMS-style pages to your application, with page management, routing, and Blade rendering. It integrates with Eloquent and Laravel conventions to help you create, organize, and serve editable content pages.
This is an AsgardCMS module — it is not standalone. It expects the AsgardCMS platform (asgardcms/platform) and its satellite modules to already be installed and configured. Treat the package as an add-on to an existing Asgard install, not a greenfield dependency.
Minimal setup:
composer require idavoll/page-module
Then:
config/modules_statuses.json (and the Module facade) rather than Laravel's auto-discovery. Either run php artisan module:enable page or set "page": true in that file. The module's service provider will be loaded from there.php artisan module:migrate page (or plain php artisan migrate if the platform is already set to run module migrations globally). This creates the pages and page-translation tables./admin. That is your first real use case: create a page, pick a template, hit "Save", then browse the generated slug on the frontend.Where to look first in the source tree:
Providers/PageServiceProvider.php — registers routes, views, translations, config, permissions, sidebar, and (in older versions) Doctrine bindings. Read this before anything else; it tells you exactly what the module assumes exists.Http/routes.php (or Routes/) — two route groups usually exist: an admin group under /admin/page/... and a frontend catch-all group for rendering a page by slug.Entities/Page.php + Entities/PageTranslation.php — the model shape (id, parent_id, template, is_home, slug, title, body, status, timestamps).Repositories/PageRepository.php — the sanctioned query API (findBySlug, getAll, create, etc.). Prefer this over hitting the entity directly.config/page.php — template list and any feature toggles.First concrete use case: create two or three pages in the admin, set one as is_home, and confirm that GET / renders it via the frontend controller. This validates migrations, routing, translation fallback, and theming all in one pass.
The module ships a repository contract (Interfaces/PageRepository). Consumers should resolve that binding. This survives the move between Doctrine and Eloquent backends that Asgard went through — anything that reaches for Page::where(...) directly will break across those versions.
use Modules\Page\Repositories\PageRepository;
public function __construct(PageRepository $pages)
{
$this->pages = $pages;
}
public function index()
{
return view('home', [
'pages' => $this->pages->getAll(), // active pages, translated
'home' => $this->pages->findBySlug('home'),
]);
}
For custom queries, inject the repository and access the underlying query builder via its ->all() / ->where(...) helpers rather than the entity class.
The frontend controller typically exposes Page::findBySlug($slug) per locale. Two integration styles show up in practice:
/{slug} at your own controller that resolves the page through the repository, decorates it (breadcrumbs, menus, parent_id chain), and returns the module's template view.Route::bind('slug', fn($s) => app(PageRepository::class)->findBySlug($s)).If you need a specific layout per section of the site, don't fight the module: create a page row per section with its own template, and let the module pick the view.
The module stores a template string on each page and dispatches the render through a view that matches it. In practice you'll add a blade per template:
resources/views/vendor/page/templates/default.blade.php
resources/views/vendor/page/templates/landing.blade.php
You publish/override module views with the standard Laravel vendor:publish --tag=views (or, on Asgard, place them in your Theme under views/page/…). Register new templates in config/page.php:
'templates' => [
'default' => 'Default',
'landing' => 'Landing page',
'contact' => 'Contact page',
],
Pages are translatable. The pattern is:
$page = $this->pages->create((object)[
'template' => 'default',
'is_home' => false,
'status' => 1, // published
'en' => ['title' => 'About', 'slug' => 'about', 'body' => '…'],
'fr' => ['title' => 'À propos', 'slug' => 'a-propos', 'body' => '…'],
]);
When reading, use $page->translate(config('app.locale')) and fall back to app()->getLocale() default. Asgard stores the title/slug/body on the translation row, so a page without a translation in the current locale will either return null or the base row depending on version — always test both.
The module fires a standard Asgard event set: PageIsCreating, PageIsUpdating, PageWasCreated, PageWasUpdated, PageWasDeleted, PageIsDeleting. Register listeners in your own EventServiceProvider:
protected $listen = [
\Modules\Page\Events\PageWasCreated::class => [FlushSitemapCache::class],
\Modules\Page\Events\PageWasUpdated::class => [FlushSitemapCache::class],
\Modules\Page\Events\PageIsCreating::class => [ForceDefaultTemplate::class],
];
This is the correct extension point for sitemaps, search indexing, static-site generation, and slug collision handling — do not monkey-patch the repository.
page.pages.index / create / edit / destroy via the platform's permission registry, and admin access is gated on those.Menu module is where page→menu wiring lives; listen to PageWasUpdated and refresh menu entries there rather than embedding menu logic in this module.Featured images are stored through the Mediable trait on the page entity. If you want images on pages, ensure idavoll/media-module (or the Asgard media module for your platform version) is enabled, then call $page->files()->first() or use the module's media picker partial in the admin form.
Do not install this without the platform. "0 dependents" on the index reflects that it's consumed transitively by AsgardCMS bundles, but the module hard-depends on asgardcms/platform, Menu, Media, Setting, and Translation conventions. A bare Laravel app will fail to boot on the service provider.
Version drift is real. Early Asgard releases used Doctrine entities; later ones use Eloquent. The repository interface is stable across that boundary, but any code touching the model directly is not. Check the composer.json constraint on asgardcms/platform first, then check which repository contract is present, and pin your Asgard version in composer.json.
Catch-all route ordering. The frontend route typically registers /{slug?} to render any page. If you add your own routes (a blog, an API) after the module's routes are loaded, they'll be shadowed. Either register your routes in a service provider with ->before('page routes') semantics, or constrain the catch-all via where('slug', '^(?!blog|api).*'). Debugging this is a two-second php artisan route:list — do that first when a page 404s for no obvious reason.
Slug uniqueness. The module relies on the admin form (or PageIsCreating listeners) to normalise slugs. Programmatic creation with $repository->create() will happily insert duplicate slugs unless you check first. Add a listener on PageIsCreating that appends -2, -3, etc.
is_home is global, not per-locale. Setting a second page to is_home can leave two "home" rows. The repository's getHome() may then return either one. Always unset the previous home explicitly in your save handler.
Config caching. Adding a template to config/page.php while php artisan config:cache is active will silently do nothing. Clear config after any module config change — a very common "why doesn't my new template appear?" symptom.
Translation fallback surprises. If you inspect $page->title directly on the entity, you get the base row's value, not the current locale. Use $page->translate($locale) or the module's present() helper. This bites when writing frontend blade files: always pull through the translation object.
Event timing on update. PageWasUpdated fires after the slug has changed. Anything cached under the old slug (sitemap, static generator, CDN) needs the old slug — capture it in PageIsUpdating and stash it on the request or in a short-lived cache keyed by page ID.
Debugging empty frontend pages. In order: (1) route:list for the catch-all, (2) php artisan tinker to confirm findBySlug('your-slug') returns a row, (3) check the row's status column — 0 rows are filtered by the repository's default query and silently return null, (4) check the translation row exists for the current locale, (5) confirm the template key in the DB exists in config/page.php.
Permissions in tests. Feature tests for admin routes must seed the page permissions, or Auth will 403 before your assertion runs. Use the platform's permission seeder, not a raw factory.
Extension points to prefer, in order: events → published views → repository decoration → forking. Forking this module is cheap (it's small), but you lose upstream fixes and, given the project's activity level, you will be maintaining it yourself regardless — so decorate the repository contract in your app if you can, and only fork if you need to change the schema.
License caution. The repo reports NOASSERTION, meaning GitHub couldn't detect a recognised license file. For a production project, get written clarification from the maintainer (or treat it as all-rights-reserved) before shipping. This is the single biggest practical blocker for adopting this module today.
How can I help you explore Laravel packages today?