Pages
Namespace Hilms\Pages. The informational pages a learning platform still needs — the front page, a course listing, a students’ own page, a privacy policy, whatever else an installation writes — built from the same blocks as a lesson.
It is not a content management system: there is no page tree and no nesting, and a page is reached from a menu an editor builds (Navigation) rather than from a hierarchy.
Every page is the editor’s own. None is created by the application, none has an address written into the code, and any of them may be renamed, unpublished or deleted. What the application links to is a choice — three settings — and not a property of a row.
The model
Section titled “The model”Hilms\Pages\Models\Page: title (160), slug (160, unique), language and translation_group, template, status, audience, meta_description (160).
It implements Blockable in the Page context, so the editor offers every block but the question and the quiz, and UsesAssets, answering takesCourseMaterial() with false: a page is read by anyone, so its blocks may show only public library files, served straight from their public address (Media).
Scope published(); methods isPublished(), isForMembers(), templateKey(), drawsOwnHeading(), url(), canBePreviewedBy(?User) (Update:Page) and isVisibleTo(?User). It is Translatable through HasTranslationGroup and a MenuLinkable through LinkedFromMenus, and it is audited on title, slug, language, translation group, template, status and audience.
Two details worth knowing:
url()is rendered in the page’s own language, so an English page is linked to under the English prefix whoever asks. It answersroute('home')while the page’s group is the one chosen as the home page andpages.showotherwise, reading that from the cached snapshot rather than from a settings query per call.- Every write forgets the destinations snapshot (
static::saved,static::deleted), because every page holds a link the application may be drawing.
Slug behaviour is deliberately not the catalog’s: a slug that was typed — by an editor, or by the starter content — is kept, and only an empty one is generated from the title.
PageStatus is draft or published. PageAudience is everyone or signed-in, a CHECK constraint on the column and a select in the form’s Publishing section.
Who may read it
Section titled “Who may read it”A page for members runs Laravel’s own auth and verified middleware where the page is read rather than where the route is registered — who a page is for is a row, not a route:
app(Authenticate::class)->handle($request, fn (): null => null);
$response = app(EnsureEmailIsVerified::class)->handle($request, fn (): null => null);It is the middleware itself that runs, so a guest is sent exactly where every other guarded address sends them, with the address they asked for kept, and somebody who has not verified their email lands on the notice.
A draft answers 404 for a visitor and renders with a preview notice for anybody who may preview it.
Destinations
Section titled “Destinations”The application links to three pages, and it asks for them by name rather than by address:
Destination::Courses->url(); // ?stringHilms\Access\Enums\Destination names Home, Courses and StudentHome and resolves through Hilms\Access\Contracts\Destinations. access binds RootDestinations — the site’s root, and nothing for the other two — as the floor, and pages binds Hilms\Pages\Support\PageDestinations over it. That is why the contract lives in access: it sends people somewhere after they sign in but sits below the module that keeps the pages.
A destination nobody named answers nothing, and the link is simply not drawn: not every installation wants a page listing every course. Every consumer is written to expect null:
| Consumer | Draws |
|---|---|
Category::url() |
The listing with ?category=<slug>, or nothing |
<x-catalog::latest-courses> |
Its “all courses” link |
| The course player’s breadcrumb | Its link back to the listing |
<x-learning::enrolled-courses> |
Its “browse the courses” button |
Hilms\Access\Support\LandingUrl |
Where somebody without a panel lands after signing in |
CourseCompleted |
The button in the mail, falling back to the course itself |
| The hero and call-to-action patterns | The address their button is laid out with |
PageDestinations keeps one forever-cached snapshot of scalars — which translation group was chosen for each destination, and the slug each published sibling is read at — forgotten when a page is written or deleted and when the choice is saved. A warm page therefore costs no query at all. It reads the sibling written in the language being read, falls back to the default language’s, and links only to published rows, because a link into a draft is a link into a 404.
SitePagesSettings (group site_pages) is the choice itself: home, courses and student_home, each the translation group of a page or nothing at all. A group rather than a row, because a page is written once per language and the link follows the language being read. The “Site pages” section of Settings (ManageSitePages, administrators only, Settings) edits the three as nullable selects over the published pages, one entry per group of siblings named by the default language’s title, validated against the same list.
Templates
Section titled “Templates”A page names the Blade file it is drawn with. Hilms\Theme\Templates\PageTemplates is the registry, and it lives in theme because a template is a file of the theme; a PageTemplate is a key, a label resolved late (a translation key, or a closure for a label that is built) and a view.
$templates->register(new PageTemplate( key: PageTemplates::DEFAULT, label: 'pages::pages.templates.default', view: 'pages::show',));pages registers default, and that is the whole set today; a module or a theme may register more from its own provider. Registration happens while providers boot and reads no database. The panel offers what is registered and validates against the same list, and a page naming a template nobody registers any more falls back to the default, so removing a module never takes a page down with it.
The default template fills no rail slot, so a page is drawn in the shell with one column — the arena and the footer under it — and nothing beside them: the course page and the lesson player are the two addresses that bring a side column (Theming). A template that wants one fills the slot itself.
Because only pages can build a Page, it hands the registry a maker (PageTemplates::sampling()) that the design catalogue draws every template view with — that is how another module declares a page-kind contract view without importing pages.
Routing
Section titled “Routing”Route::localize(function (): void { Route::get('/', [PageController::class, 'home'])->name('home');
Route::any('{slug}', [PageController::class, 'show']) ->where('slug', '[a-z0-9-]+') ->fallback() ->name('pages.show');});/ is the one address the installation keeps, because half the application links to the front door by name. Everything else is the fallback, which Laravel matches only when nothing else did, so a page can never shadow a route whatever the order of registration. One lowercase segment: a deeper path such as /api/v1/nope never reaches the controller and keeps the 404 it always had.
Both are registered twice, with a language prefix and without, so /en/about-us is the English page and /o-nas the default language’s (Languages).
home() renders the page chosen as the home page in the language being read, the default language’s when that language has none, and — when nothing is chosen, or nothing readable is there yet — an unsaved Page carrying the site’s own name, with its media and blocks relations set so a strict model has nothing to fetch. The front door never comes back blank.
show() answers 404 for any method but GET and HEAD, for an unknown slug, and for a draft the visitor may not preview. Two redirects sit in front of the render:
- the chosen home page asked for at its own slug goes to
/in its language, with a temporary redirect, because which page is the home page is a setting an editor may change; - a page whose language is not the one being read goes to its sibling in that language when there is one the visitor may see, and otherwise renders as it is, in its own language.
Siblings are announced through Alternates while the page renders, so the switch and the hreflang links point at each sibling’s own address.
Reserved slugs
Section titled “Reserved slugs”Hilms\Pages\Support\ReservedSlugs::all() is every routed address of exactly one segment (the optional language prefix aside), every locale a prefix may name — a page called en would sit behind the English home page — plus the paths the web server answers (build, css, fonts, js, storage, vendor).
Only a single segment can hide a page, so /courses/{course} leaves courses free and an editor may take it; admin, account, login, design and up stay refused. Because the theme module’s /design routes are always registered — the controller answers 404 unless the catalogue is switched on — design is reserved on every installation.
Hilms\Pages\Rules\NotReservedSlug refuses those and anything starting with livewire-, whose endpoint prefix carries an installation-specific hash. A page whose slug were reserved would simply be unreachable, which is worse than refusing the slug while it is typed.
Headings
Section titled “Headings”A page has exactly one <h1>. A block that answers drawsPageHeading() draws it wherever in the tree it sits, so a page header inside a grid cell counts exactly as much as one at the top; Page::drawsOwnHeading() walks the flattened tree to find out, and page-head draws the page title when nothing does.
The blocks that serve pages only
Section titled “The blocks that serve pages only”- Page header (
Hilms\Pages\Blocks\PageHeaderBlock) — the quiet opening: a heading, an optional lead and an alignment. Draws the page’s h1. - Hero (
HeroBlock) — the loud one: a heading, a lead, an optional image, up to two links and a sign-up button. Also draws the h1. The links are validated byHilms\Navigation\Rules\LinkTarget, because the value goes straight into anhref. The sign-up button appears to a signed-out visitor while registration is open; a signed-in one is offered their account instead. - Latest courses and the course listing live in
catalog, my courses inlearning(Catalog, Learning). The last two answeronce(), and in the editor both draw a compact placeholder rather than querying on every preview.
Starter content
Section titled “Starter content”Hilms\Pages\Support\StarterPages holds five pages: a home page (a hero pointing at the listing, then the latest courses), a course listing (a page header and courses-list), a students’ page (a page header and my-courses, for members), a privacy policy and terms of service (a page header, a callout saying the text is a template, then the sections). Each is built in a given language from pages::starter.*, and takes the slug that language’s title gives it. Where a pattern already lays out what a starter page needs, the page reads that pattern rather than repeating it: the catalogue page an installation is handed is the one an editor would insert themselves.
Hilms\Pages\Actions\SeedStarterPages writes them only onto an installation with no pages at all, published, in the default language, through SyncBlocks, inside Activity::withoutLogging(), and points the three destinations at them. A second run creates nothing, and an editor who deleted every starter page but kept one of their own is never handed them back. It belongs to hilms:install alone: an upgrade never hands an installation content.
The legal pages are templates, not legal advice: the text carries […] placeholders for the administrator, the shop, the retention periods and the contact address. Their addresses are what Google and Meta are given before an OAuth app may leave testing mode.
The listing blocks belong to modules above pages, so they are named as the strings they are ('latest-courses', 'courses-list', 'my-courses'): pages imports neither the catalogue nor the learning side.
The panel
Section titled “The panel”The Pages resource is a table of title, slug, language (where there is more than one), status badge, audience (toggleable) and when it changed, filtered by status, audience and language. A “View” action opens the page in a new tab, and every page may be deleted. There is no bulk delete: it would authorise deleteAny once.
The form is three columns. The left two hold details (title, slug with NotReservedSlug and a uniqueness check, search-engine description) and, on the edit page only, the block editor for the page context — the canvas sits beside the sidebar and never under it, so Save and the outline stay in view for as long as the blocks go on. The sidebar holds the editor actions, the document outline, publishing (status, audience, template, language), the translations block and the danger section.
Nothing is locked but the language of a row that has siblings, which carries validatedWhenNotDehydrated(false), because Filament validates a disabled field it never writes.
Permissions come from PagesPermissionSeeder with the sortable set.
app-modules/pages/tests/Feature: slug rules including the typed-slug case, the reserved-slug rule against the live route table, the fallback route and its 404s under both address shapes, the home page per language and its unsaved fallback, the redirect from the home page’s own slug, drafts and previews, the members-only guard, the destinations and their snapshot, the starter pages and their idempotency, TranslatePage, the page-only blocks, and the panel resource with its query-count invariance.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.