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.
Two layers of data
Section titled “Two layers of data”| 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).
What a row holds
Section titled “What a row holds”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.
Why a full entity by site matrix
Section titled “Why a full entity by site matrix”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 examplesd_site_type_avail_idxonsite_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.
Availability: content rules
Section titled “Availability: content rules”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 ignoresinclude_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 setinclude_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.
Collapsed trees
Section titled “Collapsed trees”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.
The two indexers
Section titled “The two indexers”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.
Asking for a reindex: the Reindex facade
Section titled “Asking for a reindex: the Reindex facade”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.
Observers
Section titled “Observers”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.
Bulk work
Section titled “Bulk work”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.
Commands
Section titled “Commands”| 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).
Reacting to the projection
Section titled “Reacting to the projection”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.
Finding the current site
Section titled “Finding the current site”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.