Skip to content

feat(tenancy): multisite support for Acorn - #17

Open
montchr wants to merge 4 commits into
mainfrom
feat/tenancy-sites
Open

montchr wants to merge 4 commits into
mainfrom
feat/tenancy-sites

Conversation

@montchr

@montchr montchr commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Multisite support for Acorn: site keys, per-site providers and config, code discovery, and fleet commands.

Site keys and model (first commit)

  • Tenancy\SiteKey: stable, environment-independent site identifier, stored as site_key blogmeta; a lowercase DNS label. fromHost() uses the same leftmost-label rule as DemapDomains; www is reserved.
  • Database\Models\Site, Tenancy\SiteRepository (find by key or ID), Tenancy\SiteMeta, Tenancy\Domain.
  • site:list, site:assign-keys (--dry-run; --id=N --key=foo [--force]).

Per-site providers and config (second commit)

Tenancy\TenancyServiceProvider, registered via extra.acorn.providers. It does nothing on single-site installs. On multisite, from an $app->booting() callback (after every configured provider registers, before any boots):

  1. Registers the request site's providers, least specific first so later bindings win: app/Sites/{Key}/Providers → active site plugin plugins/{key}/ → providers in config/sites/{key}/app.php → parent theme → child theme (theme providers from composer.json extra.acorn|laravel.providers). Deferred providers keep their deferral.
  2. Also attaches app/Sites/{Key}/View/Composers (a composer extending a shared one replaces it via a container binding), registers app/Sites/{Key}/Console/Commands, prepends resources/views/sites/{key}, and loads routes/sites/{key}.php (skipped when routes are cached).
  3. Applies the config overlay. config/sites/{key}/mail.php loads as sites.{key}.mail (so it is included in config:cache) and overlays mail. Values apply leaf by leaf: maps merge, lists replace, null removes. This makes roots/acorn-mail, roots/acorn-post-types and roots/acorn-prettify configurable per site without changes to them. Timing matters: their mergeConfigFrom() is a shallow merge in register(), so the overlay must come after.
  4. Re-applies the overlay on every switch_blog after Acorn's own handler (so Acorn's cache.prefix/session.cookie survive). Providers do not change on a switch.

Events: SiteInitialized, SiteSwitched (also WP actions kleinweb/tenancy/initialized and kleinweb/tenancy/switched). Optional config/tenancy.php: bootstrappers, and meta_config (blogmeta key → config key).

Commands: site:run "<cmd>" (one wp --url=… acorn process per site), site:check (keys, orphaned site code/overlays, missing providers, theme requirements vs the root lock, cached routes; non-zero exit), site:cache / site:clear (also run by optimize / optimize:clear), make:site {key}. SiteQueue::name($site) gives the queue name for per-site workers.

Changes to existing code:

  • app:tenancy:demap-domains is renamed site:demap-domains; the old name stays as an alias.
  • It now handles more than 100 sites, and uses a site's key as its label when one is set.
  • It runs wp search-replace through symfony/process. The Process facade's illuminate/process isn't installed by Acorn, so that path would have failed at runtime.
  • Tenancy commands move from AppServiceProvider to the new provider.

switch_to_blog() safety (third commit)

Behaviour mirrors Core: code is per process, data follows the switch.

  • Per-blog Laravel cache that follows switches. This works around two Acorn gaps:

    • Acorn's cache.prefix per blog doesn't reach the file store (FileStore::getPrefix() is '').
    • Stores CacheManager has already built never see a later prefix change.

    CacheBootstrapper uses per-blog file directories, re-prefixes built stores in place, and swaps array stores per blog.

  • Nothing escapes the switch_blog handler. Core has already switched its globals by then. Errors, including from SiteSwitched listeners, are reported and skipped. Failures at boot fall back to plain WordPress.

  • Site::run() always unwinds to its starting depth. This holds on exceptions and on callbacks that leave switches unrestored.

  • New InteractsWithSites::forEachSite() for console commands.

  • Other fixes:

    • The request site is detected correctly when Acorn boots mid-switch.
    • SiteInitialized now fires after boot.
    • The site list refreshes on site create, update and delete.
    • site:assign-keys checks for blogmeta support.

Note for deploys: the file cache moves to {path}/blog_{id}, which causes a one-time cold cache. cache:clear now clears only the current blog's file cache; use site:run "cache:clear" for all.

Tested in plain PHP:

  • Cache isolation with a real CacheManager: file, array, prefixed, and own-prefix stores, plus injected repositories.
  • Switching against a stand-in for Core's switch stack: nested switches, keyless blogs, throwing bootstrappers and listeners, and Site::run() unwinding.

Class loading (fourth commit)

The library no longer does any autoloading of its own. Composer autoloads every class for every site:

  • app/Sites/* through the project's App\ mapping.
  • Themes and site plugins through wikimedia/composer-merge-plugin.

Loading a class has no side effects; per-site behaviour comes only from which providers are registered. site:check reports autoload.files in a theme or site plugin, because those run on every site. It also reports PSR-4 prefixes Composer doesn't know, which means a missing merge-plugin include.

Notes for review

  • After revert, a top-level config name that only an overlay introduced is left as null: Laravel's config repository can't unset a top-level key.
  • Per-site routes can't coexist with route:cache; site:check reports it.
  • Packages that copy config at boot (acorn-mail, acorn-prettify) follow the site WordPress loaded, not later switch_to_blog() calls.

Verification

Static checks only; not run against a WordPress install.

  • parallel-lint, php-cs-fixer, phpcs: clean.
  • PHPStan level 8: no errors. The earlier DemapDomains.php:143 error is fixed.
  • SiteKey/Domain and ConfigOverlay exercised in plain PHP against a real Illuminate\Config\Repository. Covered: merge, list replace, null removal, revert, and a runtime cache.prefix change surviving revert.

🤖 Generated with Claude Code

https://claude.ai/code/session_014JvSQuyBbpZMsUNeUjNtWa


Generated by Claude Code

Add a typed model for multisite sites, identified by a stable,
environment-independent key stored as `site_key` blogmeta next to the
`orig_*` meta written by `app:tenancy:demap-domains`.

- `Tenancy\SiteKey`: DNS-label key; derives from a host using the same
  leftmost-label rule as DemapDomains, or from a subdirectory path
- `Tenancy\Domain`: host and path value object
- `Tenancy\SiteMeta`: blogmeta key enum
- `Database\Models\Site`: wraps WP_Site and its blogmeta
- `Tenancy\SiteRepository`: look up sites by key or ID (singleton)
- `site:list`: list sites with key status, or as JSON
- `site:assign-keys`: backfill keys, or set one with --id/--key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JvSQuyBbpZMsUNeUjNtWa
… commands

Add `TenancyServiceProvider`, discovered through `extra.acorn.providers`
and inert on single-site installs. From an application `booting` callback
it registers the request site's code and applies its config, so site
providers register after every configured provider (their bindings win)
and the overlay sees package defaults already merged by
`mergeConfigFrom()`.

Providers register least specific first: `app/Sites/{Key}/Providers`, the
active `plugins/{key}/` site plugin, `providers` in
`config/sites/{key}/app.php`, then the parent and child theme's
`composer.json` providers. Deferred providers keep their deferral. Site
view composers, commands, views and routes are discovered too.

Config overlays live in `config/sites/{key}/*.php`, which Laravel loads as
`sites.{key}.*`. They are applied leaf by leaf (maps merge, lists
replace, null removes), reverted and re-applied on every `switch_blog`
after Acorn's own handler, and never overlay `app.providers`. Optional
blogmeta-to-config mapping via `tenancy.meta_config`.

New commands: `site:run`, `site:check`, `site:cache`, `site:clear`
(also run by `optimize`/`optimize:clear`) and `make:site`.

`app:tenancy:demap-domains` becomes `site:demap-domains` (old name kept
as an alias). It now processes more than 100 sites, uses a site's key as
its label when one is set, and runs `wp search-replace` through
symfony/process, since the Process facade's illuminate/process package
is not installed by Acorn. Tenancy commands move from `AppServiceProvider`
to the new provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JvSQuyBbpZMsUNeUjNtWa
@montchr montchr changed the title feat(tenancy): add site keys, site model and site commands feat(tenancy): multisite support for Acorn Oct 16, 2026
Harden multisite support for code that calls switch_to_blog(),
especially console commands looping over sites.

- Isolate Laravel's cache per blog across switches. Acorn only changes
  `cache.prefix`, which the file store (Acorn's default) ignores and which
  never reaches stores already built by CacheManager. CacheBootstrapper
  gives file stores a per-blog directory, re-prefixes built stores in
  place, and swaps array stores per blog, so injected repositories follow
  the switch too. Runs for every blog, keyed or not.
- Never let a failure escape the `switch_blog` handler: Core has already
  switched its globals by then. Bootstrapper, lookup and listener errors
  are reported through the exception handler and skipped. Discovery or
  provider-loading failures at boot fall back to plain WordPress.
- Detect the request site correctly when Acorn boots mid-switch, and
  bring state in line with the current blog.
- Register the switch handler at initialization rather than in boot(), and
  fire SiteInitialized after boot so listeners registered in boot() get it.
- Site::run() unwinds to its starting depth even if the callback throws or
  leaves switches unrestored.
- Add Console\Concerns\InteractsWithSites::forEachSite() for commands:
  per-site failures are reported and counted without stopping the loop.
- Refresh the site list on wp_initialize_site, wp_update_site and
  wp_delete_site for long-running processes.
- Autoload any site plugin's classes on demand (queued jobs, config values
  from other sites), with a resolver of our own so it also works under
  `--classmap-authoritative`.
- site:assign-keys stops with instructions when the network has no
  blogmeta table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JvSQuyBbpZMsUNeUjNtWa
Remove the library's runtime autoloading: the PSR-4 resolver, the
cross-site plugin fallback autoloader, and the implicit `src/` mapping
for site plugins without a composer.json.

Every site, theme and site-plugin class is now autoloaded by Composer on
every site (`app/Sites/*` via the project's `App\` mapping; themes and
site plugins via wikimedia/composer-merge-plugin). Loading a class has no
side effects; per-site behaviour comes only from which providers
SiteLoader registers, which is unchanged.

SiteManifest now only lists provider, composer and command class names.
site:check reports the one way autoloading can have cross-site side
effects, `autoload.files` in a theme or site plugin, and PSR-4 prefixes
Composer does not know (a missing merge-plugin include). Requirement
checks now cover site plugins as well as themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JvSQuyBbpZMsUNeUjNtWa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants