For Developers

Multisite Architecture

11 min read

> **What you'll learn:** How the multisite system works internally, how Site is a core model, how settings inheritance works, and how to build multisite-awar...


Overview

TallCMS has a two-layer site architecture:

  1. Core: Every TallCMS installation has at least one Site record. Standalone = one site. Site model, settings service, and Site resource live in core (packages/tallcms/cms/).
  2. Multisite plugin: Adds multiple sites, domain resolution, ownership, site switching, domain verification, plans/quotas, and templates (plugins/tallcms/multisite/).

The plugin extends core — it does not own the Site model or settings infrastructure.


Database Schema

tallcms_sites (core)

ColumnTypeNotes
idbigIncrements
namestringPublic brand name
domainstring, uniqueNormalized domain (lowercase, no protocol/port)
themestring, nullableTheme slug override
localestring, nullableLocale override
uuiduuid, uniqueStable public identifier
is_defaultbooleanFallback site (exactly one)
is_activebooleanEnable/disable
metadatajson, nullableExtensibility

Multisite plugin adds to tallcms_sites:

ColumnTypeNotes
user_idunsignedBigInteger, nullableSite owner
is_template_sourcebooleanTemplate authoring flag
domain_verifiedbooleanBackward-compat TLS flag
domain_statusstring(20)pending, verified, failed, stale
domain_verified_attimestamp, nullableLast successful verification
domain_checked_attimestamp, nullableLast check attempt
domain_verification_notestring, nullableHuman-readable result
domain_verification_datajson, nullableObserved DNS records

tallcms_site_setting_overrides (core)

Per-site setting overrides. site_id + key unique composite.

ColumnType
site_idFK to tallcms_sites
keystring
valuetext
typestring

Content scoping

ResourceScopedMechanism
tallcms_pagesPer-sitesite_id FK + SiteScope global scope
tallcms_menusPer-sitesite_id FK + SiteScope global scope
tallcms_postsUser-owneduser_id FK, no site scope
tallcms_categoriesUser-owneduser_id FK, no site scope
tallcms_mediaUser-owneduser_id FK, no site scope

Settings Architecture

Two-Level Model

Settings use a global-default + per-site-override model:

SiteSetting::get('contact_email')
  → Check per-site override (tallcms_site_setting_overrides)
  → Fall back to global default (tallcms_site_settings)

Settings Service (Admin Writes)

All admin settings writes go through SiteSettingsService with explicit site IDs:

$service = app(SiteSettingsService::class);

// Read for a specific site (override → global fallback)
$service->getForSite($siteId, 'contact_email', $default);

// Write an override for a specific site
$service->setForSite($siteId, 'contact_email', 'hello@example.com');

// Remove override (site resumes inheriting global)
$service->resetForSite($siteId, 'contact_email');

// Check if a site has an override
$service->hasOverride($siteId, 'contact_email');

// Read global default (no site context)
$service->getGlobal('contact_email', $default);

No admin write path uses ambient session context. The site ID is always explicit.

Frontend Reads

Frontend code uses SiteSetting::get() which resolves site context automatically:

  • Admin requests (tallcms.admin_context attribute): reads from session site
  • Frontend requests: reads from domain-resolved site
  • Console/boot: returns global default

Global-Only Keys

Some settings are installation-scoped and never per-site:

// Explicit keys
SiteSetting::$globalOnlyKeys = [
    'i18n_enabled', 'default_locale', 'hide_default_locale', 'i18n_locale_overrides',
    'code_head', 'code_body_start', 'code_body_end',
    'code_head_audit', 'code_body_start_audit', 'code_body_end_audit',
    'seo_rss_enabled', 'seo_rss_limit', 'seo_rss_full_content', 'seo_sitemap_enabled',
];

// Prefix-based
SiteSetting::$globalOnlyPrefixes = ['seo_'];

SiteSetting::set() automatically routes global-only keys through setGlobal().

site_name Alias

site_name is a Site model field (tallcms_sites.name), not a setting override:

  • SiteSetting::get('site_name') resolves from Site.name for the current site
  • SiteSetting::set('site_name', ...) writes to Site.name for the current site
  • Fallback chain: current site name → global site_name setting → default site name → config('app.name')

Admin Save Loop Pattern

When saving settings on a Site edit page, the loop preserves inheritance:

foreach ($settingKeys as $key => $type) {
    $value = $data[$key];
    $globalValue = $service->getGlobal($key);
    $matchesGlobal = valuesMatch($value, $globalValue, $type);
    $hasOverride = $service->hasOverride($site->id, $key);

    if ($matchesGlobal) {
        // Matches global — remove override to restore inheritance
        if ($hasOverride) {
            $service->resetForSite($site->id, $key);
        }
        continue;
    }

    // Differs from global — create or update override
    $service->setForSite($site->id, $key, $value, $type);
}

Four states:

  • No override + matches global → skip (preserve inheritance)
  • No override + differs from global → create override
  • Has override + matches global → delete override (restore inheritance)
  • Has override + differs from global → update override

Filament Admin Structure

Core (packages/tallcms/cms/)

ComponentPurpose
SiteResourceSingle-record edit page in standalone; base for multisite extension
EditSite (Page)Custom page with settings form; loads/saves via SiteSettingsService
SiteFormTab-based form: General, Branding, Contact, Social, Publishing, Maintenance
GlobalDefaults (Page)Installation-scoped defaults for all 20 site-scoped settings + i18n
SeoSettings (Page)Installation-scoped SEO settings (RSS, sitemap, robots, OG, llms.txt)
CodeInjection (Page)Installation-scoped embed code (head, body start, body end)
PagesRelationManagerPages belonging to the site
MenusRelationManagerMenus belonging to the site

Multisite Plugin (plugins/tallcms/multisite/)

ComponentPurpose
SiteResourceFull CRUD with list/create/edit, ownership filtering
EditSite (EditRecord)Extends Filament EditRecord; saves settings in afterSave()
SiteFormSite + Status tabs (multisite-specific), imports core settings tabs
PagesRelationManagerPages with site-context-aware create action
MenusRelationManagerMenus with inline create
SiteSwitcher (Livewire)"Filter by Site" dropdown for content browsing

The multisite SiteForm imports core's settings tabs:

protected static function coreSettingsTabs(): array
{
    return [
        CoreSiteForm::settingsGeneralTab(),
        CoreSiteForm::brandingTab(),
        CoreSiteForm::contactTab(),
        CoreSiteForm::socialTab(),
        CoreSiteForm::publishingTab(),
        CoreSiteForm::maintenanceTab(),
    ];
}

Navigation Adapts to Mode

  • Standalone: Pages and Menus are top-level nav items (direct access)
  • Multisite: Pages and Menus hidden from top-level nav; accessed through Site resource relation managers

This is controlled by shouldRegisterNavigation() on CmsPageResource and TallcmsMenuResource, which return false when tallcms_multisite_active().


Site Resolution (Multisite Only)

Frontend (Domain-Based)

ResolveSiteMiddleware runs in the web middleware group:

Request → match domain against tallcms_sites.domain
  → found: load site, override theme/view paths/locale
  → not found: 404

Admin (Session-Based)

The "Filter by Site" dropdown stores the selected site in session('multisite_admin_site_id'). This filters content lists (pages, menus) via SiteScope.

Important: The site filter only affects content browsing. Settings writes are always explicit-by-site-id through the Site edit page — they never depend on the session filter.

Context-Aware Resolution

SiteSetting::resolveCurrentSiteId() uses different sources based on request type:

ContextSourceWhy
Admin (tallcms.admin_context attribute)SessionImmune to stale resolver
Frontend (no attribute)Resolver singletonDomain-based
Boot / console (no request)Returns nullGlobal settings

Query Scoping (Multisite Only)

SiteScope

Applied to CmsPage and TallcmsMenu:

ConditionSQL Effect
Site resolved (has ID)WHERE site_id = :siteId
All Sites modeNo filter
Unknown domainWHERE 1 = 0 (empty)
Not resolved (console)No filter

Slug Uniqueness

UniqueTranslatableSlug is site-aware for site-scoped tables and user-aware for user-owned tables. It also excludes soft-deleted records.


Domain Verification (Multisite Only)

Custom domains require DNS verification before TLS certificates are issued. Managed subdomains (*.base_domain) are auto-trusted.

State Machine

[Create site] → Pending
                  ↓ (verify succeeds)
               Verified ←──────────────┐
                  ↓ (re-verify fails)   │
                Stale                   │
                  ↓ (fails again)       │
                Failed                  │
                  ↓ (verify succeeds)   │
                  └─────────────────────┘

Key Classes

ClassPurpose
DomainStatusEnum: Pending, Verified, Failed, Stale
DomainVerificationServiceDNS checks, setup instructions, TLS dispatch
TriggerTlsProvisioningQueued job, 3 retries
ReverifyDomainsScheduled hourly, batched re-verification

Building Multisite-Aware Features

Reading settings for a specific site

// Explicit (admin writes, jobs, commands)
$service = app(SiteSettingsService::class);
$value = $service->getForSite($siteId, 'contact_email');

// Ambient (frontend runtime, views, Blade)
$value = SiteSetting::get('contact_email');

Adding site_id to a new model

  1. Add a nullable site_id FK column with nullOnDelete
  2. Add SiteScope global scope in the multisite service provider
  3. Auto-assign site_id via a creating listener
  4. Update unique constraints to be composite with site_id

Writing installation-scoped settings

For settings that should never vary per site:

// Option A: Add to $globalOnlyKeys in SiteSetting
// Option B: Use setGlobal/getGlobal directly
SiteSetting::setGlobal('my_plugin_setting', $value, 'text', 'my-plugin');
$value = SiteSetting::getGlobal('my_plugin_setting', $default);

SaaS Flow: Tenant Onboarding

When combining Multisite with the Registration plugin, you get a self-serve SaaS stack: a visitor hits /register, gets a user account with the site_owner role, is auto-assigned the default site plan, and lands in their own scoped Filament admin.

The site_owner role is required

The Registration plugin creates new users with the site_owner role by default. That role must exist in the Spatie roles table before the first registration attempt, or the controller aborts with a 500 ("role does not exist").

When it's already there:

  • Fresh installs via tallcms:setup — seeded by ShieldSeeder.
  • Upgrades via php artisan tallcms:update — auto-synced in the cache-clearing step.

When you need to sync it manually:

php artisan tallcms:shield-sync-site-owner

Run this once after:

  • Installing the Registration plugin on a TallCMS install that predates v4.0.14.
  • A git-based deploy where tallcms:update didn't run (CI/CD, Forge, Ploi, etc.) — call it in the post-deploy script, after migrate.
  • Any time you suspect the role is missing (e.g. registration returning 500).

The command is idempotent: it creates the role and any missing permissions, but never touches other roles or existing user-role assignments. See Roles & Authorization for the full permission set and scoping behavior.

Plan assignment at registration

If the Multisite plugin is present, the Registration plugin auto-listens for Laravel's Registered event and calls SitePlanService::ensureAssignment($user) — giving the new tenant their default plan without any manual wiring. If Multisite isn't installed, the listener simply isn't registered (no errors, no overhead).

Auto-onboarding to Template Gallery (Registration ≥ 1.2.0)

When Registration plugin v1.2.0+ is installed alongside Multisite, the plugin's EnsureOnboardingRedirect middleware (registered on the Filament panel) automatically steers a freshly-verified user with no sites to the Template Gallery on their first hit to the panel root. The off-ramp uses SitePlanService::siteCount($user) and canCreateSite($user) to honour plan quotas, and stops as soon as the user owns at least one site.

To wire this up in a host project, add the middleware to your panel provider:

use Tallcms\Registration\Http\Middleware\EnsureOnboardingRedirect;
use Tallcms\Registration\Services\OnboardingResolver;

return $panel
    ->emailVerification(isRequired: fn () => (bool) config('registration.email_verification.enabled'))
    ->homeUrl(fn () => auth()->user()
        ? app(OnboardingResolver::class)->resolveFor(auth()->user()) ?? filament()->getPanel('app')->getUrl()
        : filament()->getPanel('app')->getUrl())
    ->middleware([
        // ...standard panel middleware...
        EnsureOnboardingRedirect::class,
    ]);

Override the redirect target with REGISTRATION_ONBOARDING_REDIRECT_URL=/somewhere, or disable the off-ramp with REGISTRATION_ONBOARDING_ENABLED=false. The plugin and middleware short-circuit silently when Multisite isn't installed.


Remaining Cleanup (Post-Refactor)

The following are known architectural items deferred for future work:

  1. Model duplication: Multisite plugin has its own Site.php and SiteSettingOverride.php that shadow core's instead of extending them. Future fixes should land in one place.
  2. SEO scoping: Currently all seo_* keys are global-only. A future pass should split them: feed/index settings stay global, brand/policy settings (robots.txt, OG image, llms.txt) become site-scoped.
  3. ThemeManager: Still uses SiteSetting::set() for theme_default_preset — could be made explicitly global.

Next Steps

Comments

No comments yet. Be the first to share your thoughts!

Choose Theme