Skip to main content

Documentation

No results found.
Features

Pages

A WebProCMS public page is three things layered on top of one another: a body (the row structure a visitor's browser renders), settings (name, SEO fields, publish state, gates — the facts that decide whether and how the page serves), and co...

A WebProCMS public page is three things layered on top of one another: a body (the row structure a visitor's browser renders), settings (name, SEO fields, publish state, gates — the facts that decide whether and how the page serves), and copy (the actual words and images inside the body's rows). Each layer has its own storage and its own invalidation path, and conflating them is the most common way a page bug gets mis-diagnosed.


What a page is

Layer Lives in Owned by
Body (row/item structure) The page blade (resources/views/pages/**/⚡*.blade.php) plus a RowDocument sibling file the editor parses for structure (see docs/rowdoc-format.md) Structural editor actions — insert/delete/reorder rows and items
Settings (name, SEO, publish state, gates) One row in the pages table, keyed by slug (App\Models\Page, read and written through PageSettings) The editor's Page Settings modal, the Pages list's publish/draft/unlisted toggles, and every page-creating writer (clone, move, theme install, content-type generation, …)
Copy (headlines, body text, images, button labels) content_overrides rows, compiled into a per-page sidecar for render The page editor's content fields — see "Content storage model" in CLAUDE.md

A page's blade field="..." attributes are stale insert-time template defaults, never the live copy — see the read-page-content skill before quoting what a page says.

Promoted content-item pages are the exception: a content-type record with has_custom_page = true has no pages row at all. Its name, SEO fields and publish state live on the ContentItem itself (meta_title / meta_description columns, the record's own status). PageSettings::find() returns null for one, and every reader that might touch a promoted page checks the record first.

Status vocabulary

A page's status column is one of four values, and that one column decides both whether the page answers its URL and whether it's advertised:

Status Serves its URL? Sitemap / nav / listings? Route line
published Yes Yes Plain line, no state token
unlisted Yes No — reachable by link, deliberately not promoted Plain line, no state token
draft No — 404s No Line present, carries page.draft
unpublished No — 404s No Line present, carries page.draft

draft and unpublished enforce identically — both chain the same page.draft middleware token — and exist as separate values only so the editor and the Pages list can show the right verb for how the page got there. There is exactly one not-serving form: the route line always stays in place; what changes is whether it carries page.draft. (Before the pages table, unpublishing could also mean removing the line entirely, which produced two independent "is this page down?" answers that could disagree — see the page-editor skill.)

Page::scopeServing() (published + unlisted) answers "does this page respond"; Page::scopeListed() (published only) answers "should this page be advertised."

Gates, and why the route line is derived, not a store

Four more settings live on the row but are enforced on the route line, because their middleware (auth, role:, page.member-plan:, and the cache group itself) has to run before CacheResponse and before any database connection — requires_login, required_role, member_plan_slug, is_cached. A member-gated page is always forced into the uncached section regardless of its is_cached value, because a cached response would serve one member's page to everyone.

The route line is a derived artifact, never hand-written for a page with a row. PageRouteSync::syncFromRow() is the only writer: it reads the row's status, redirect and gates, and rewrites the line's section/token only when they disagree (PageRouteSync::drift() gates every write, so a healthy install's resync is a no-op). Everything that changes a page's state — the editor's Page Settings save, the Pages list's toggles, a page move, a publish/unpublish action — writes the row first and then calls syncFromRow(). Writing the row is enough on its own, too: PageSettings::upsert() (and any save followed by PageSettings::afterWrite()) that changes a column the line is derived from (PageRouteSync::ROW_COLUMNS — status, redirect, the login / role / member-plan gates, caching) re-derives the lines the page already has, so a status set from tinker or a script serves without a hand-run pages:sync-routes. It never ADDS a missing line — a page's URL and serving form are its creator's call (a detail clone's line is parameterized), so creation flows still finish with syncFromRow(). Nothing else should call PageRouteFile::setPageState() / updateRouteSection() directly for a row-backed page.

Render path

An uncached public render reads page settings with zero file-head reads and one DB round trip total (shared with the existing content_overrides revalidation query):

  • A page's settings compile into its own sidecar (storage/framework/page-data/pages/{slug}.php, written by PageDataCompiler) in the same marker-shaped array the old @page frontmatter held — so every existing reader of that shape kept working unchanged. PagePreload requires the sidecar; PagePreload::settingsForCurrentPage() returns the settings PlainPageController maps onto the layout.
  • PageDataCompiler::signatureBundle() extends the existing content_overrides aggregate with two scalar subselects — this page's own settings updated_at, and a site-wide count/max(updated_at) over live pages rows — so a settings save still invalidates the right sidecar on the next render without a second query.
  • A site-wide file, pages-index.php, carries every live page's settings (minus the redirect keys) keyed by slug, for readers that need another page's settings: breadcrumb ancestor labels, menu-link card previews, and the sitemap's listability check. It also carries an ids map (id => slug, every live row) — what a page:{id} link resolves through (Linking to a page). PagePreload::pagesIndex() loads it once per request and recompiles it only when the token captured during the current page's own revalidation disagrees with the file's stamped token — otherwise the file is trusted as-is. PagePreload::slugForPageId() reads the same load under the same rules; an index file written before the ids map existed counts as stale and recompiles on first read.
  • php artisan pages:compile-data rebuilds both the per-page sidecars and the index on demand (deploy, snapshot restore, an out-of-band DB edit); the render path also compiles on-demand on a miss, so this command is a warm-up, not a requirement.

Linking to a page

A link to a page is stored by the page's id (its pages row id), never by its URL or route name, so it survives a rename or a move with nothing rewritten — the id is turned into the page's current URL at render.

What gets stored. Every picker stores exactly ONE of three forms, never two side by side:

Target Menu item Link field — a *_url override, a mega-menu link, a content-block CTA button
A page with a pages row page_id: 42 page:42
A row-less page — a feature-module page, a promoted content-item page route: 'docs.index' route:docs.index
Anything typed — /about, https://…, mailto:, #top url: '…' the text exactly as typed
  • Typed paths stay literal. /about typed by hand is not bound to the about page and does not follow it through a rename; only a pick from the page list is.
  • One rule, every picker. The menu builder's page picker, the editor's URL/Page field and the content-block CTA select all read PageLinkOptions::options(), whose entries carry page_id (int, or null for a row-less page), and store page:{id} / page_id when it is set, route:{name} / route when it is not. PageLinkToken owns the token grammar (page: + a positive integer, nothing else).
  • Row-less pages keep route names — there is no row to hold an id. A content-type rename (ContentTypeRenamer) still rewrites those names ({type}.index / .show / .taxonomy, promoted pages) in menus and blade literals; it never has to touch an id.

How it resolves — zero queries. link_url('page:{id}') → PagePreload::slugForPageId() (the ids map in pages-index.php, see Render path) → localized_route(page_route_name($slug)). The page_route_name() helper derives a route name from a slug exactly as the page's route line was written (home → home, a folder index → services.index), so nothing stores a name for a row-backed page. Menus resolve through MenuService::nodeHref() / nodeRouteName() / nodeIsCurrent() — x-dl.nav, MenuLinksPreset, breadcrumbs and the fallback footer all read nodes through them.

A dead link renders #, with NO fallback. An id no live row has — the page was deleted, is in the trash, or its theme is parked — renders #; it never falls back to a name, a typed URL or another page (owner decision: a link that is visibly broken lets the admin decide; one that quietly lands somewhere else hides the problem). The pickers label such a value "Deleted page". The SEO scanner reports it as a broken internal link named by the token (page:42) and the design-library dead-link scanner treats it as unresolved; the fix is re-picking the page (the issue's Edit action) — the SEO dashboard offers no "Create redirect" for a token, since nothing ever requests a token as a URL.

Ids are stable. A rename / move (PageSettings::rename()), trash and restore keep the row and its id. PageSettings::refreshFromFile() — a theme switch, a rollback, a feature-page resync — updates the existing row IN PLACE (same id, every settings column replaced from the file); it only creates a row for a slug that has none. Only a permanent delete removes a row. A theme switch parks the row of a page the incoming theme does not ship and brings it back, same id, with its theme (Parked rows).

Across installs, links travel as names. A theme capture, a theme-switch snapshot and a menus export write route names (a page that no longer exists becomes #), including tokens inside JSON override values; installing, switching and importing turn names back into ids once the rows exist. Full detail: docs/theme-authoring.md "Page links: ids never serialize".

The two backfills. Two one-time migrations converted the links that existed before ids, each running its class directly (never Artisan::call()), fail-open, idempotent, skipped under testing, logging counts:

Migration Class Converts
2026_10_02_203413_backfill_menu_page_ids MenuPageIdBackfill a menu page item's route name that resolves to a row → page_id (the route key is removed)
2026_10_02_211124_backfill_link_field_page_ids LinkTokenBackfill a route:{name} in a content_overrides *_url value (any tier) or a content-block CTA button that resolves to a row → page:{id} (updated_at bumped; response cache cleared once)

Names that resolve to no row, typed paths, and route: literals baked into blades are left alone. navigation.seeded_routes (demo-data bookkeeping) stays name-based; its readers compare through MenuService::nodeRouteName().

Parked rows

A row whose page nothing serves is parked under a reserved slug rather than deleted, so its id — what every page:{id} link stores — survives. A valid page slug can never start with _, so there is no collision risk, and Page::scopeLive() hides both prefixes: a parked row is out of serving() / listed() / translationSurface(), the pages index and its ids map (its links render #), the sitemap, search, the Pages list, pages:sync-settings orphan reports and pages:sync-routes.

  • Trash — _trash/{key}. Restoring moves the row back to its real slug and re-derives its route line; a permanent delete removes the row. This mirrors how the page's content_overrides tier is parked during the same trash operation. A permanent delete (one entry, Empty trash, or deletePage()) also drops the page's own global-tier overrides — but only for row slugs no other blade still carries (another live page, a shared row, a parked theme instance, another trashed page), by the daily content-overrides:gc sweep's own row-orphan test (OverrideOrphanScan). A move, trash or delete removes the sidecar compiled at the page's old file; the page compiles a fresh one on its next request.
  • Theme switch — _theme/{instance}/{slug}. When a switch promotes a theme that ships no page at a slug the outgoing theme served, the outgoing row parks under the outgoing instance's name (PageSettings::parkForTheme()). Promoting that instance again restores each of its parked rows whose page it still has — same id — and refreshes it in place from the page's marker; a parked row whose page was deleted from the instance meanwhile is purged then. Deleting the parked instance (ThemeInstances::delete()) purges every row it parked. A row at a slug both themes ship is never parked — it is refreshed in place and keeps its id across the switch. Full sequence: docs/theme-authoring.md "Page settings: the marker is the theme serialization".

Commands

Command What it does When to run it
pages:sync-settings [--write] [--prune] Reconciles the pages table against the live pages tree: reports (or, with --write, creates) rows for in-scope files with none, and reports (or, with --write --prune, deletes) rows whose file is gone. Dry run anytime to check health (created=0 orphaned=0 is a clean install); --write after a bulk file operation that bypassed the normal writers.
pages:sync-routes [--dry-run] Re-derives every live page's route line from its row — section, gates, and the page.draft / page.redirect:… token. Writes only where a line disagrees with its row. --dry-run to audit; without it, after a hand-edited routes/web.php or to heal drift. A healthy install reports changed=0.
pages:strip-markers [--write] Removes the inert {{-- @page {...} --}} marker and the {{-- @unlisted --}} sibling from a live page once its row exists, rewrites the pre-1.7 terminator comment, and moves a seeded page's recorded hash so it still counts as unmodified. Dry run by default; --write strips. Never touches a theme source, a page bundle, or a parked theme instance — those still need the marker (see below). Once, after confirming pages:sync-settings reports a clean install; optionally again after any file operation that might reintroduce a marker (a bundle import, a theme promote).
rows:heal-slugs [--fix] [--path=…] Lists every row where something inside it names a slug other than the row's own (A row reads its own slug) and what --fix would do; --fix repairs them. Without --fix nothing is written. An update runs it once by itself. By hand: the dry run to check an install (No divergent rows found. is clean), then --fix. --path= limits it to one file.
rows:split-shared-buttons [--fix] [--path=…] Lists every row holding two or more prefix-less Buttons items (which share their text — Buttons items in one row) and the prefix each later one would get; --fix splits them. Without --fix nothing is written. An update runs it once by itself. By hand: the dry run to check (No row has two Buttons items sharing their text. is clean), then --fix.

A row reads its own slug

A row names its slug in three kinds of place, and all of them must agree:

  • the ROW marker ({{-- ROW:start:contact-form:Ab12Cd --}}) is its identity, and every editor save keys content_overrides.row_slug on it;
  • each <x-dl.*> tag's slug= attr is the namespace the row renders its copy from;
  • its reads — the first argument of @dlItems / @dlLoop, of a content() call in its PHP or echo blocks, of a slug-taking Resolver:: call — are where a repeater, a loop or a hand-written read gets its values.

When any of them names another slug, that part of the page silently stops following its editor: the owner's saves land under the row's slug, the page keeps reading the other one, and it shows baked defaults (or another row's values) forever. RowSlugConsistency is the one definition of that check. It reports attr drift (the namespace moved — once shipped by a theme capture that froze only the attrs) apart from foreign reads (the namespace is right, but a read names another row — six theme-bundle library rows shipped reading the page row they were captured from, contact-form:iZ9VLY / faqs-accordion:mYcY0q, and every copy placed from them ignored the contact items, "use business info" toggle, labels and FAQ items the owner saved).

What keeps it from happening again:

  • Placement. Every template-placing path (RowDocTemplates::rowFragment, and the shared-row role installer) points every reference in a library row at the row being placed (RowDocOps::forceTemplateSlug). A paste normalizes slug attrs and directive arguments (RowDocPaste).
  • Capture. Saving a section to the library (LibraryRowExporter) forces every reference onto the section's slug before swapping it for __SLUG__, so a saved row can never read another row.
  • The repo guard. RowSlugConsistencyGuardTest fails on a shipped library row naming anything but __SLUG__, a theme page row naming anything but its own marker, or a theme shared row naming anything but its file slug.

Repairing an install — rows:heal-slugs walks the page blades (not editor previews or the trash), layout partials, popups, parked theme instances, shared rows (whose identity is their file slug) and the custom library (whose rows must read __SLUG__). The repair is a tree op per row (RowDocOps::applySlugMap) under the editor's per-file save lock, followed by a sibling-document refresh, a sidecar recompile and a response-cache clear when anything changed. It always points things at the row's own slug:

  • attr drift is always repaired, and content that lives only under the old attr slug is copied onto the row first so the page never blanks;
  • a foreign read is repaired and nothing is copied — the row starts showing what the owner saved under its own slug, which is the point — unless the slug it names is a live row on the install (a ROW marker or a shared-row file), which may be on purpose in hand-written code: that read is left and listed for a person to judge;
  • override rows are never moved or deleted, and a legacy colonless row slug is never guessed at ("repair by hand").

After an update — the release that shipped the fix schedules one run (ScheduledRowSlugHeal, Setting rows.slug_heal): its migration only records that the heal is due, and the 15-minute pages:reconcile-index sweep runs rows:heal-slugs --fix on its next tick — outside the update's maintenance window, so nothing it does can fail the update. A run that fails, or that meets a page mid-save, is tried on the next two ticks, then given up with a log line naming php artisan rows:heal-slugs --fix.

Buttons items in one row

A <x-dl.buttons> without a prefix= reads the row-wide keys (primary_button, secondary_button_url, toggle_secondary_button, buttons_wrapper_classes, …), so two in one row share their text: editing one relabels the other. Every shipped row uses at most one (guarded by FixedKeyComponentsOncePerRowTest), and the Add Item picker gives each Buttons item its own prefix (button_set, button_set_2, …) — with prefix="P" every key is P_… / toggle_P_… and the editor group is P. buttons itself is never handed out while a prefix-less Buttons is in the row: its wrapper keys are buttons_*. A prefix-less Buttons item that leaves its row — copied (even back into the same row), cut and pasted, or dragged to another row — leaves as a prefixed one under its own item's prefix, with its row-wide values (translations too) copied onto that prefix's keys; the source row keeps its own. The full key rule: the design-library skill's components reference.

Repairing an install — rows:split-shared-buttons walks the same trees as rows:heal-slugs. Per row, the first prefix-less Buttons item keeps the row-wide keys; each later one gets prefix="P" (its own marker's prefix when that is free, else the next free buttons_N), its baked attrs move to the prefixed names, and its content rows are copied — every tier, every translation, provenance carried — onto P's keys before the blade is written, so the page renders exactly what it did. Nothing is moved or deleted; a key that already exists under P keeps its value. Per-file editor save lock; a file being saved is left for the next run.

After an update — the release that shipped it schedules one run (ScheduledButtonsSplit, Setting rows.buttons_split), run by the 15-minute pages:reconcile-index sweep exactly like the slug heal: three attempts, then a log line naming rows:split-shared-buttons --fix.

The backfill migration

The first release carrying the pages table ships a one-time migration that backfills a row for every in-scope page — skipped under the testing environment (tests run against the real pages tree, which the migration must not touch), wrapped in a try/catch that logs counts and never fails the update, and never calls Artisan::call() on a console command the same release adds (the in-process updater freezes the command registry before placing new files — see the cms-updates skill). A page's backfilled status is derived in this order: a marker's stored status (or a legacy page's class attributes) → the {{-- @unlisted --}} sibling marker → no route line at all (unpublished) → a page.draft token on the line (draft) → published.

The marker's remaining role

On a running install, the {{-- @page {...} --}} frontmatter marker is inert — nothing writes it to a live page, and a page with a row never reads it. It survives as the theme / page-bundle serialization, because a theme tree isn't a running install and has no pages row to read:

  • Shipped theme sources (resources/themes/{slug}/pages/) and a page bundle's frontmatter carry the marker; an installer turns it into a row (PageSettings::ensureFromFile()).
  • A theme swap re-derives settings from the files it places — PageSettings::refreshFromFile() updates the row already at each slug IN PLACE (same id, every settings column replaced from the marker), so the values of whatever page sat there before never win and no page:{id} link dies; the outgoing theme's rows are serialized back into markers as part of parking it, and a row whose slug the incoming theme does not ship is parked under _theme/… (Parked rows).
  • A parked theme instance carries its pages' settings only in the markers its files carry, and its admin preview reads that marker directly. A _theme/… row it parked holds only the id for the trip back; the marker is what the switch back refreshes it from.

Full detail: docs/theme-authoring.md "Page settings: the marker is the theme serialization."