Skip to content

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.

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.

CmsServiceProvider::bindRoutableParameter() registers the routable binding, which does all the status handling before the controller runs:

  1. Resolve the host to a Site (SiteResolver). None: 404.
  2. Pagination: strip a trailing /page/N, but only if the rest of the path is a live taxonomy.
  3. Live route: an available site_documents row at (site, resolved_path). Return it to render.
  4. History: a path_history row 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.
  5. 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 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.

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:

  1. Loads $document->source.
  2. Dispatches BeforeSiteDocumentRender. Cross-cutting setup belongs in subscribers, such as InitializeSiteDocumentSeo for meta tags. Catalog’s out-of-stock redirect also hangs off this.
  3. Resolves the template and layout.
  4. Renders with $document, $site, $template, $layout and 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.

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.

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 the SetActiveSiteTheme group 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.

Paths\Services\PathGenerator::generate($site, $entity) returns a GeneratedPath (path and status) and has no side effects. Order of precedence:

  1. A SitePathOverride row for (site, type, id): used verbatim.
  2. The site’s SitePathConfig for the type: strategy, prefix, strategy_config.
  3. With no config row: SlugOnly with 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.

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 are unique per type across the whole table, so a collapsed tree cannot fold two pages onto one path.
  • Per-site slug: a slug field 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 SitePathOverride replaces 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.

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.

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.

  • 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-vendor shows what sits ahead of the catch-all.
  • ddev artisan cms:reindex --site=<domain> --type=<handle> --source-id=<id> rewrites one row.