Routing
Public routing is a dispatcher over the projection.
There are no per-type routes. A URL is looked up in site_documents, and every page, post, product
and category renders through one controller. Staff manage URLs as described in URLs and
redirects.
The route file
Section titled “The route file”routes/web.php wraps everything in SetActiveSiteTheme, with the signed preview route first. All
the rest sits inside EnsureSiteIsServing, which answers 404 for an Inactive site and 503 for a
Maintenance one. It is sorted before SubstituteBindings in bootstrap/app.php, because the
catch-all’s binding would otherwise answer an unknown address first. Those errors use Laravel’s plain
pages, not the site’s. The customer panel and the form endpoints use the same middleware (the panel
also on its Livewire requests, the forms through config/packstub-form-builder.php). Inside that group, the routes register in this order:
| Route | Handler |
|---|---|
quote-cart/* |
QuoteCartController, with EnsureVisitToken |
search, search/suggest, search/page/{page} |
SearchController |
robots.txt |
RobotsTxtController |
/{routable} (pattern [a-zA-Z0-9\-_\/]+) |
SiteDocumentController::show |
/ |
FrontPageController |
| Fallback | abort(404) |
The preview route, __preview/{document} (SiteDocumentPreviewController::show), sits before that
group, so staff can preview a site that isn’t Active yet.
The fallback gives addresses the catch-all’s pattern refuses (any with a dot, such as
/old-page.html) the middleware that the site’s own 404 page needs.
Resolving a path
Section titled “Resolving a path”CmsServiceProvider::bindRoutableParameter() registers the routable binding, which does all the
status handling before the controller runs:
- Resolve the host to a
Site(SiteResolver). None: 404. - Pagination: strip a trailing
/page/N, but only if the rest of the path is a live taxonomy. - Live route: an available
site_documentsrow at (site,resolved_path). Return it to render. - History: a
path_historyrow at (site, path).Gone: 410.Redirect: 301 to the entity’s current path, looked up now. If it has no available row with a path, 410.
- Nothing matched: 404.
Live rows always win over history, and PathHistoryReconciler deletes any history row at a path
that becomes live.
$resolveLive = fn (string $path): ?SiteDocument => SiteDocument::query() ->forSite($site) ->available() ->where('resolved_path', $path) ->first();The binding throws its redirects and aborts before route middleware has run, so
Http\StorefrontErrorPage (hooked in bootstrap/app.php) activates the site’s theme itself to draw
errors.not-found with the real status and a noindex. Requests wanting JSON, hosts that are no
site, and routes outside the storefront group get Laravel’s plain page.
path_history: 301s and 410s
Section titled “path_history: 301s and 410s”path_history is keyed on (site_id, path) and holds old paths only. It points at the entity
polymorphically (pathable_type, pathable_id) and stores no target URL.
| Event | What happens | Code |
|---|---|---|
| A path changes | The old path becomes Redirect |
PathHistoryReconciler::reconcile() |
| An entity is deleted | Its last path becomes Gone, and its Redirect rows flip to Gone |
PathHistoryReconciler::retire() |
| Staff add one | Manual redirect to a chosen document, or mark as gone | ManageSitePathHistory |
Because a redirect resolves the current path at request time, there is no chain and no sweep. Rename something five times and all five old paths 301 straight to the latest.
The empty path is never recorded, so / is either a live front page or a 404.
Rendering
Section titled “Rendering”FrontPageController exists only because the catch-all cannot match the empty string. It finds the
available row with resolved_path = '' and hands it to SiteDocumentController. The front page is
otherwise not special at resolution.
SiteDocumentController::render() is deliberately thin:
- Loads
$document->source. - Dispatches
BeforeSiteDocumentRender. Cross-cutting setup belongs in subscribers, such asInitializeSiteDocumentSeofor meta tags. Catalog’s out-of-stock redirect also hangs off this. - Resolves the template and layout.
- Renders with
$document,$site,$template,$layoutand the source under the camel-cased handle ($page,$product,$postCategory).
The view name is the source handle (page.blade.php, post_category.blade.php). There is no
index action and no per-type directory, because every URL renders exactly one document. Sub-views
belong in Blade components.
Pagination
Section titled “Pagination”Only taxonomy listings paginate through the path, since they list the content they classify. For
content, /page/2 could be part of a real slug and is never stripped.
| Request on a taxonomy | Result |
|---|---|
/category/pens |
Page 1 |
/category/pens/page/3 |
Page 3. The binding sets Paginator::currentPageResolver |
/category/pens/page/1 |
301 to the bare path |
/category/pens?page=3 |
301 to /category/pens/page/3, keeping other query parameters |
Build links with Support\PaginationPath: url($page) gives the path-style URL and basePath()
gives page 1 of the current listing without a query string. The segment comes from
content-management.pagination.segment; SearchController’s route uses the same one.
Why top-level routes need care
Section titled “Why top-level routes need care”The catch-all takes any path made of letters, digits, hyphens, underscores and slashes, and it only matches GET. Two rules follow:
- Register before it. A new storefront GET route after
/{routable}is never reached. Put it inside theSetActiveSiteThemegroup and above the catch-all, as search and the quote cart are. - Claim the root. Explicit routes silently win over content. If a page’s generated path is
search, the page shows as live on Active Paths but visitors get the search page.
Support\ReservedPathRoots lists first segments a path may not start with (admin, api, login,
storage, livewire, horizon and others). SitePathConfig and SitePathOverride refuse to save
them, and PathGenerator refuses to emit them. Check whether a new route’s root belongs on that
list. It does not currently include every top-level storefront route, so don’t assume it is
complete.
Use an underscore prefix for machine endpoints that no slug would want, as __preview and the form
builder’s _forms do.
Generating paths
Section titled “Generating paths”Paths\Services\PathGenerator::generate($site, $entity) returns a GeneratedPath (path and status)
and has no side effects. Order of precedence:
- A
SitePathOverriderow for (site, type, id): used verbatim. - The site’s
SitePathConfigfor the type: strategy,prefix,strategy_config. - With no config row:
SlugOnlywith no prefix.
There is no addressable marker to opt into. Every projected entity gets a path unless the generator cannot build one. A thing that should have no URL is a widget or block, not a content type.
Strategies
Section titled “Strategies”Paths\Enums\PathStrategy, dispatched with a match, no per-strategy classes. Staff choose them on
the site’s Path Generation page (ManageSitePathStrategies).
| Strategy | Shape | Requires |
|---|---|---|
SlugOnly |
<prefix>/<slug> |
Nothing |
HierarchicalSlugChain |
<prefix>/<ancestor slugs>/<slug> |
The type implements Hierarchical |
WithCanonicalTerms |
<prefix>/<term ancestors?>/<term slug>/<slug> |
The content implements Classifiable |
WithCanonicalTerms reads strategy_config:
| Key | Meaning |
|---|---|
canonical_taxonomy_handles |
Ordered list. The first taxonomy with a usable term for this entity on this site wins. Only TermsCanBeCanonical taxonomies are eligible |
include_hierarchy |
Include the term’s ancestors in the path |
fallback_to_slug |
When no term resolves: SlugOnly if true (default), Unaddressable if false |
SitePathConfig validates these on save, so the generator trusts its config. Hierarchical chains
always use the site’s collapsed tree, never the raw global parents.
Slugs and overrides
Section titled “Slugs and overrides”- Slugs are unique per type across the whole table, so a collapsed tree cannot fold two pages onto one path.
- Per-site slug: a
slugfield override (PathGenerator::slugOf()) changes the segment and keeps the strategy’s structure. The admin checks it for shape and per-site uniqueness. - Per-site path: a
SitePathOverridereplaces the whole path. Unique per (site, entity) and per (site, path). This is the SEO escape hatch, kept in one table so it can be audited.
An override can still clash with a strategy-generated path. That falls to the first claimant rule.
Canonical terms
Section titled “Canonical terms”Paths\Services\CanonicalTermResolver chooses a per-site primary term for Classifiable content,
stored in site_content_canonical_terms. A manual preference is honoured while the term is visible
on the site, a fallback is used otherwise, and the preference is kept so it comes back if visibility
returns. The result drives both the path and breadcrumbs. index() re-resolves terms before
generating the path, so a classification change lands in one pass.
Previews
Section titled “Previews”Admin and sites are on different domains, so a preview takes two steps.
SiteDocumentPreviewController::issue(), an authenticated admin route gated by the entry’s view
policy, mints a temporary signed URL at the click and redirects to it on the site’s domain. show()
renders that document through the normal path regardless of is_available, only for a valid
signature and only on the matching site, with noindex and no-store headers. Subscribers that send
visitors elsewhere check $event->preview so previews still draw.
Debugging a URL
Section titled “Debugging a URL”- Active Paths (
ManageSiteRoutes) shows every row’s path, status and availability on a site. - Path History (
ManageSitePathHistory) shows redirects and gone paths. ddev artisan route:list --except-vendorshows what sits ahead of the catch-all.ddev artisan cms:reindex --site=<domain> --type=<handle> --source-id=<id>rewrites one row.