Skip to content

Sites and the projection

One operator runs many sites from one database. Pages, posts, products and categories exist once, in their own tables, with no site column. What a site shows is decided by rules and written to a derived table, site_documents, which everything public reads from.

The code lives in app-modules/content-management/src/Sites/ and app-modules/content-management/src/Paths/. For the exhaustive reference, see app-modules/content-management/ARCHITECTURE.md; for tinker recipes, the same module’s README.md.

Layer Tables Written by
Source of truth pages, posts, products, post_categories, product_categories, post_tags, site_content_rules, site_field_overrides, site_path_configs, site_path_overrides, site_content_canonical_terms, seo_templates The admin and importers, synchronously
Derived site_documents SiteDocumentIndexer and BulkSiteDocumentIndexer only

site_documents can be truncated and rebuilt at any time with ddev artisan cms:reindex. Never edit it to fix something: change the source and reindex. Search documents are not stored here; catalog’s search index builds its own from the source and this table (see search).

One row per (site, source), unique on site_id, source_type, source_id. source_type is the morph handle of the source: content (page, post, product) or taxonomy (post_category, post_tag, product_category).

Columns Meaning
is_available The gate. Everything that serves or lists filters on it
resolved_title, resolved_slug, resolved_description Display fields with per-site overrides and merge tags applied
resolved_meta_title, resolved_meta_description, resolved_meta_robots SEO after the row override or site template; see SEO templates
resolved_path, resolved_path_status The URL on this site, and resolved, conflicting or unaddressable
available_resolved_path Generated column: the path only when the row is available and resolved. Holds the unique index that stops two live rows sharing a URL
resolved_primary_term_type, resolved_primary_term_id The term that won for this row’s path and breadcrumbs
resolved_parent_id, depth The site’s collapsed tree shape for hierarchical sources
resolved_item_count For a term: its directly classified content available on the site, after the content type’s listing rule (products must be in stock)

Which source column fills which resolved_* column is declared per model in indexableFields():

public static function indexableFields(): array
{
return [
'resolved_title' => 'title',
'resolved_slug' => 'slug',
'resolved_meta_title' => 'meta_title',
'resolved_meta_description' => 'meta_description',
];
}

The indexer never branches on a concrete class, so a new type needs no indexer change.

The indexer writes a row for every entity on every site. A site with no content rules still has a row per entity, all with is_available = false. This is deliberate:

  • Size does not matter here. The business runs a handful of sites, so the table is the catalog size times a small number.
  • Multiplicity is invisible to reads. Every read is for one site and every useful index leads with site_id (for example sd_site_type_avail_idx on site_id, source_type, is_available). A sparse table would produce the same query plans.
  • Flipping availability is cheap. A rule change updates flags on existing rows instead of inserting and deleting, which would have to rebuild paths and rerun collision checks.

The corollary is an invariant: do not put cross-site queries on hot paths. A query with no site_id predicate cannot use those indexes. Cross-site aggregates belong in admin screens or reports.

site_content_rules is the only mechanism deciding what a site carries. There is no ownership column on content and no second override table. Staff edit rules on a site’s Content Rules page (SiteResource\Pages\ManageSiteContentRules); the business view is in content rules.

Field Meaning
mode ContentRuleMode::Include or Exclude. Availability is Include minus Exclude, so Exclude always wins
target_type A content type or taxonomy handle. Always set
target_id One row or term, or null for all of the type
include_descendants Also reach the subtree of a hierarchical target
include_classifiables Taxonomy rules only: also carry the content classified under the term

Two facts come out of this one table, and they do not drag each other around:

  • Term page visibility (SiteHierarchyResolver) reads rules whose target is that taxonomy and ignores include_classifiables. A site can publish an empty category page.
  • Content availability (SiteContentResolver::availableContentIds()) reads rules targeting the content class, plus taxonomy rules that classify it and set include_classifiables. The result is then narrowed to publicly visible rows (scopePubliclyVisible()), so a draft is unavailable on every site.

Content under a term never makes the term visible. Visibility comes from rules alone.

Front page exception. The entity in Site.front_page_type and front_page_id is available regardless of rules (provided it is publicly visible) and gets resolved_path = ''. Assigning a front page is treated as stronger intent than the rule net. SiteObserver reindexes the old and new front page when the assignment changes.

The category and page trees are global, and each site sees them collapsed to what it carries. SiteHierarchyResolver::collapseTreeFor() is a pure function returning id => [parent_id, depth] for the visible set; flat sources get a degenerate map. The indexer writes that to resolved_parent_id and depth, and hierarchical paths walk the same chain. So a team page under about is about/team on a site carrying both, and team on a site that does not carry about. Read a site’s tree through SiteTaxonomyTrees, which builds it from one site_documents query per request.

SiteDocumentIndexer::index($site, $source) BulkSiteDocumentIndexer::rebuild($site, $class)
Scope One entity on one site One type on one site
Used by IndexSiteDocumentJob, cms:reindex --type --source-id RebuildSiteDocumentsForTypeJob, cms:reindex
Collision check A query per entity In memory across the batch
Writes One upsert, then PathHistoryReconciler Chunked upserts and a set-based history diff, in one transaction

Both go through resolvePayload(), the compute seam. index() is the correctness oracle: a bulk rebuild must produce the same rows and canonical terms, and BulkSiteDocumentIndexerTest asserts it. If you change what a row contains, change resolvePayload() and both paths follow.

The bulk indexer streams sources with chunkById and keeps only light payload arrays across the whole type, because URL collisions must be resolved globally. Every statement is chunked to stay under MariaDB’s placeholder limit. upsert bypasses Eloquent casts, so payload enums and dates are flattened to scalars first.

URL uniqueness is first claimant wins. If an available, resolved row already holds a path, the incumbent keeps it and the newcomer becomes Conflicting with a null path. An established URL never breaks because something newer collides. In bulk, the lowest source id wins, which matches what an id-ordered loop would do. The unique index on available_resolved_path closes the race between check and write: index() catches the violation and demotes the loser. Conflicting rows show on the site’s Active Paths page, naming the URL wanted and who holds it.

All code goes through WebMoves\ContentManagement\Sites\Facades\Reindex. The method name says whether the work happens now or in the background, because that matters for correctness.

Method Does When
Reindex::now($target, $site = null) One entity, every site or one Inline
Reindex::queue([$targets]) Many entities, every site, deduped Background
Reindex::termsOf($content) The terms a row is classified under (item counts) Background
Reindex::subtree($root) Descendants of a hierarchical row, plus content on them for a term Background
Reindex::classificationChanged($content, $termsBefore) The row, plus exactly the terms added or removed Row inline, terms background
Reindex::type($handle, $site = null) Set-based rebuild of a whole type Background
Reindex::muted(fn) / Reindex::isMuted() Silence save-triggered reindexing n/a

A target is a content row, a term, or ['type' => $handle, 'id' => $id]. The jobs behind these are implementation details; do not dispatch them directly.

IndexSiteDocumentJob is pinned to the sync connection, so Reindex::now() needs no worker and a page is correct as soon as the save returns. The other jobs need a queue worker, so with no worker running, a content rule change appears to do nothing. See queues and scheduling.

CmsServiceProvider::registerAutoReindexObservers() attaches these once the morph map and dynamic relations exist. Registered types are observed automatically.

Observer On Effect
ContentModelObserver Every registered content type Create or projection-dirty update: the row inline. See below for cascades and deletes
TaxonomyModelObserver Every registered taxonomy Create: the term inline. Dirty update: the term and its directly classified content inline
SiteContentRuleObserver Rule saved or deleted Rebuild the target’s type on that site; for a taxonomy rule with include_classifiables, also the content type
SiteFieldOverrideObserver, SitePathOverrideObserver Saved or deleted The one row
SitePathConfigObserver, SeoTemplateObserver Saved or deleted Rebuild that (site, type)
SiteObserver Front page changed The old and new front page

Dirty gate. updated only reindexes when isProjectionDirty() says a projection column changed (ProjectsToSiteDocument::projectionColumns()): the indexableFields() sources, parent_id, deleted_at, index_status and the publish-state columns. Repricing thousands of products fans out nothing for the rows themselves. A change to a listing-rule column (listingConstraintColumns(), a product’s stock) or to publish state also refreshes the attached terms’ counts.

Cascades. A change to parent_id, slug or deleted_at alters the URLs of everything below, so the node is reindexed inline and Reindex::subtree() handles the unbounded rest on a worker (CascadeReindexSubtreeJob, dispatched after commit so the worker reads the new values). Old paths become 301s individually.

Deletes, soft or forced. The observer captures descendants and attached terms in deleting, because foreign key cascades will have removed them by deleted. In deleted it drops the entity’s rows and retires its paths inline, then queues the captured targets.

Saving ten thousand rows through observers would fan out once per row per site. Run the writes muted, then rebuild once:

Reindex::muted(function () use ($products): void {
foreach ($products as $product) {
$product->save();
}
});
Reindex::type('product');

For admin table actions there is a harness: implement WebMoves\ContentManagement\Bulk\BulkOperation (serializable, as it rides the queue) and hang a RegisteredTypeBulkAction on the table. BulkOperationJob handles muting, choosing a targeted or whole-type reindex, and notifying the user. Muting never silences delete cleanup.

Command Does
cms:reindex Full rebuild of every site, or one with --site=<id or domain>. Takes a few minutes on the full catalog
cms:reindex --type=product --source-id=42 One row on the chosen sites. Its terms’ counts are not refreshed
cms:publish-due Scheduled every five minutes. Reindexes rows whose published_at has arrived, plus their terms, as nothing saves a row at that moment

A full rebuild runs five passes per site: content, taxonomies, prune canonical terms that are no longer eligible, content again (term-based paths need the taxonomy rows), taxonomies again (item counts need the content rows).

SiteDocumentsIndexed fires after index(), unindex() and rebuild(), with siteId, sourceType, sourceIds and wholeType. The bulk path writes with no model events, so this is the way to mirror the projection elsewhere; catalog’s search index follows it. Listeners in a module’s Listeners folder are auto-discovered, so do not also register them with Event::listen(), or they fire twice.

SiteResolver maps the request host to a Site by exact match on sites.domain. It memoises per request and caches briefly across requests; SiteObserver flushes it on save. A host matching no site is a 404 on the storefront. In the admin and on the console there is no current site, so pass one explicitly.

In views and listings, read $document->resolved_title rather than $page->title, and link with getRelativeUrl() so a link keeps the request’s scheme and port. Keep getUrl() for what must be absolute, such as canonical tags.