diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php
index 032df80c0..702fe873f 100644
--- a/inc/managers/class-domain-manager.php
+++ b/inc/managers/class-domain-manager.php
@@ -317,17 +317,22 @@ public function init_dns_record_manager(): void {
* Triggers subdomain mapping events on site creation.
*
* @since 2.0.0
+ * @since 2.12.1 Skips auto-creation of a mapped-domain record when the new
+ * site's domain is a network-base domain (the network primary
+ * or any operator-configured `available_domains` value from
+ * checkout `site_url` fields). Without this guard, subdirectory
+ * multisites that expose an alternate base domain via the
+ * checkout form (e.g. `example.com` alongside the network
+ * primary `wp.example.test`) would have every newly registered
+ * subsite produce a `primary_domain = true` mapping record for
+ * the shared base, breaking sibling subsites.
*
* @param \WP_Site $site The site being added.
* @return void
*/
public function handle_site_created($site): void {
- global $current_site;
-
- $has_subdomain = str_replace($current_site->domain, '', $site->domain);
-
- if ( ! $has_subdomain) {
+ if ($this->is_network_base_domain((string) $site->domain)) {
return;
}
@@ -347,8 +352,208 @@ public function handle_site_created($site): void {
wu_enqueue_async_action('wu_add_subdomain', $args, 'domain');
}
- // Create a domain record for the site
- $this->create_domain_record_for_site($site);
+ /**
+ * Allow integrations and addons to suppress the automatic creation of
+ * a per-site mapped-domain record. Returning false short-circuits the
+ * `create_domain_record_for_site()` call.
+ *
+ * @since 2.12.1
+ *
+ * @param bool $create Whether to create a Domain record. Default true.
+ * @param \WP_Site $site The site being added.
+ */
+ if (apply_filters('wu_should_create_domain_record_for_site', true, $site)) {
+ $this->create_domain_record_for_site($site);
+ }
+ }
+
+ /**
+ * Checks if the given domain is one of the network's "base" domains.
+ *
+ * A base domain is one of:
+ *
+ * - The network primary domain (`$current_site->domain`), or
+ * - Any value listed in `available_domains` / `available_domains_multi`
+ * on a `site_url` field of any published checkout form.
+ *
+ * In both cases the host is shared across many subdirectory subsites and
+ * must NOT be auto-promoted to a per-site mapped-domain record (which
+ * would route all `/*` requests through Mercator/sunrise to the
+ * single subsite owning that mapping and 404 every sibling).
+ *
+ * Results are memoised per request to avoid re-querying checkout forms
+ * on every `wp_insert_site` / `wp_delete_site` callback.
+ *
+ * @since 2.12.1
+ *
+ * @param string $domain The domain to test.
+ * @return bool
+ */
+ public function is_network_base_domain(string $domain): bool {
+
+ global $current_site;
+
+ $normalized = $this->normalize_base_domain($domain);
+
+ if ('' === $normalized) {
+ return false;
+ }
+
+ $network_domain = isset($current_site->domain)
+ ? $this->normalize_base_domain((string) $current_site->domain)
+ : '';
+
+ if ('' !== $network_domain && $normalized === $network_domain) {
+ return true;
+ }
+
+ $bases = $this->get_checkout_form_base_domains();
+
+ return in_array($normalized, $bases, true);
+ }
+
+ /**
+ * Returns the list of operator-configured base domains drawn from active
+ * checkout forms' `site_url` field settings (both `available_domains` and
+ * `available_domains_multi`).
+ *
+ * The list is memoised for the duration of the request. Use the
+ * `wu_checkout_form_base_domains` filter to inject extra hosts (for
+ * example from a hosting integration that provisions multiple network
+ * base domains).
+ *
+ * @since 2.12.1
+ *
+ * @return string[] Normalised lowercase hosts without `www.` or port.
+ */
+ public function get_checkout_form_base_domains(): array {
+
+ // Memoise the (expensive) checkout-form scan per request, but keep the
+ // `wu_checkout_form_base_domains` filter responsive on every call so
+ // addons (and tests) can override the list without restarting the
+ // request.
+ static $form_domains = null;
+
+ if (null === $form_domains) {
+ $form_domains = $this->collect_checkout_form_base_domains();
+ }
+
+ /**
+ * Filter the list of network-base domains used to suppress automatic
+ * per-site mapped-domain records.
+ *
+ * Hosts should be normalised (lowercase, no scheme, no port, no
+ * leading `www.`). Anything not in this list (and not equal to the
+ * network primary) is treated as a per-subsite custom mapping and
+ * gets a Domain record auto-created on `wp_insert_site`.
+ *
+ * @since 2.12.1
+ *
+ * @param string[] $domains Normalised lowercase hosts.
+ */
+ $domains = (array) apply_filters('wu_checkout_form_base_domains', $form_domains);
+
+ $normalized = [];
+
+ foreach ($domains as $domain) {
+ $host = $this->normalize_base_domain((string) $domain);
+
+ if ('' !== $host) {
+ $normalized[ $host ] = true;
+ }
+ }
+
+ return array_keys($normalized);
+ }
+
+ /**
+ * Scans every checkout form's `site_url` field settings and returns the
+ * union of `available_domains` and `available_domains_multi` values as
+ * a list of normalised hostnames.
+ *
+ * Extracted from `get_checkout_form_base_domains()` so the (potentially
+ * expensive) DB scan can be memoised independently from the always-fresh
+ * filter pass.
+ *
+ * @since 2.12.1
+ *
+ * @return string[] Normalised lowercase hosts.
+ */
+ protected function collect_checkout_form_base_domains(): array {
+
+ $domains = [];
+
+ if ( ! function_exists('wu_get_checkout_forms')) {
+ return $domains;
+ }
+
+ $forms = wu_get_checkout_forms(['number' => -1]);
+
+ foreach ((array) $forms as $form) {
+ if ( ! is_object($form) || ! method_exists($form, 'get_all_fields_by_type')) {
+ continue;
+ }
+
+ $site_url_fields = $form->get_all_fields_by_type('site_url');
+
+ foreach ((array) $site_url_fields as $field) {
+ foreach (['available_domains', 'available_domains_multi'] as $key) {
+ $raw = isset($field[ $key ]) ? (string) $field[ $key ] : '';
+
+ if ('' === $raw) {
+ continue;
+ }
+
+ foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
+ $host = $this->normalize_base_domain((string) $line);
+
+ if ('' !== $host) {
+ $domains[ $host ] = true;
+ }
+ }
+ }
+ }
+ }
+
+ return array_keys($domains);
+ }
+
+ /**
+ * Normalises a domain string for base-domain comparison.
+ *
+ * Lowercases, trims whitespace, strips an optional scheme, removes a
+ * trailing path/dot, drops any port suffix, and removes a leading `www.`
+ * so callers can compare hosts symbolically without worrying about
+ * superficial formatting differences.
+ *
+ * @since 2.12.1
+ *
+ * @param string $domain Raw domain or URL.
+ * @return string Empty string when the input cannot be parsed as a host.
+ */
+ protected function normalize_base_domain(string $domain): string {
+
+ $domain = strtolower(trim($domain));
+
+ if ('' === $domain) {
+ return '';
+ }
+
+ // Strip scheme/path so wp_parse_url gives us only the host.
+ if ( ! str_contains($domain, '://')) {
+ $domain = 'http://' . $domain;
+ }
+
+ $host = wp_parse_url($domain, PHP_URL_HOST);
+
+ if ( ! is_string($host) || '' === $host) {
+ return '';
+ }
+
+ $host = rtrim($host, '.');
+ $host = preg_replace('/^www\./', '', $host);
+
+ return (string) $host;
}
/**
@@ -402,17 +607,16 @@ public function create_domain_record_for_site($site) {
* Triggers subdomain mapping events on site deletion.
*
* @since 2.0.0
+ * @since 2.12.1 Uses `is_network_base_domain()` so deletions of subdirectory
+ * subsites that share a network-base domain do not enqueue a
+ * `wu_remove_subdomain` job for the shared host.
*
* @param \WP_Site $site The site being removed.
* @return void
*/
public function handle_site_deleted($site): void {
- global $current_site;
-
- $has_subdomain = str_replace($current_site->domain, '', $site->domain);
-
- if ( ! $has_subdomain) {
+ if ($this->is_network_base_domain((string) $site->domain)) {
return;
}
diff --git a/tests/WP_Ultimo/Managers/Domain_Manager_Test.php b/tests/WP_Ultimo/Managers/Domain_Manager_Test.php
index 4b26212cf..e0991fc3f 100644
--- a/tests/WP_Ultimo/Managers/Domain_Manager_Test.php
+++ b/tests/WP_Ultimo/Managers/Domain_Manager_Test.php
@@ -324,7 +324,7 @@ public function test_manager_is_singleton(): void {
*/
public function test_manager_slug(): void {
$reflection = new \ReflectionClass($this->domain_manager);
- $slug_prop = $reflection->getProperty('slug');
+ $slug_prop = $reflection->getProperty('slug');
if (PHP_VERSION_ID < 80100) {
$slug_prop->setAccessible(true);
@@ -843,7 +843,7 @@ public function test_get_by_site_with_object(): void {
wp_cache_flush();
- $site = get_blog_details($blog_id);
+ $site = get_blog_details($blog_id);
$mappings = Domain::get_by_site($site);
$this->assertNotFalse($mappings);
@@ -1662,7 +1662,7 @@ public function test_get_domain_mapping_instructions_from_settings(): void {
*/
public function test_date_created(): void {
$domain = new Domain();
- $date = '2025-01-01 12:00:00';
+ $date = '2025-01-01 12:00:00';
$domain->set_date_created($date);
$this->assertEquals($date, $domain->get_date_created());
@@ -1790,7 +1790,7 @@ public function test_domain_stage_classes_for_all_stages(): void {
*/
public function test_domain_validation_rules(): void {
$domain = new Domain();
- $rules = $domain->validation_rules();
+ $rules = $domain->validation_rules();
$this->assertIsArray($rules);
$this->assertArrayHasKey('blog_id', $rules);
@@ -1851,7 +1851,7 @@ public function test_dns_check_interval_setting(): void {
// Domain should still be in checking-dns stage (DNS won't resolve in test env)
$fetched = wu_get_domain($domain->get_id());
- $stage = $fetched->get_stage();
+ $stage = $fetched->get_stage();
// It should either still be checking-dns (retry scheduled) or failed (if tries exceeded)
$this->assertContains($stage, [Domain_Stage::CHECKING_DNS, Domain_Stage::FAILED]);
@@ -2541,4 +2541,239 @@ public function test_auto_promote_on_done_without_ssl(): void {
'A custom domain reaching done-without-ssl should also be auto-promoted.'
);
}
+
+ // ----------------------------------------------------------------
+ // NEW: is_network_base_domain + checkout-form-available-domains guard
+ //
+ // Regression: in subdirectory multisite, an operator can publish an
+ // alternate base domain via the checkout form's site_url field
+ // (`available_domains` / `available_domains_multi`). When customers
+ // register subsites under that alternate base (e.g. `example.com/`),
+ // the old `str_replace($current_site->domain, '', $site->domain)` test
+ // in handle_site_created/handle_site_deleted treated the alternate base
+ // as a per-subsite custom mapping and produced a `primary_domain = true`
+ // Domain record for `example.com` against each newly registered subsite —
+ // which made Mercator/sunrise route ALL `example.com/*` traffic to that
+ // single subsite and 404 every sibling.
+ // ----------------------------------------------------------------
+
+ /**
+ * The network primary domain is always treated as a base domain.
+ */
+ public function test_is_network_base_domain_matches_network_primary(): void {
+ global $current_site;
+
+ $this->assertTrue(
+ $this->domain_manager->is_network_base_domain((string) $current_site->domain)
+ );
+ }
+
+ /**
+ * Hostnames are normalised before comparison: www.* and case differences
+ * must not change the verdict.
+ */
+ public function test_is_network_base_domain_normalises_host(): void {
+ global $current_site;
+
+ $upper = strtoupper((string) $current_site->domain);
+ $with_www = 'www.' . preg_replace('/^www\./i', '', (string) $current_site->domain);
+
+ $this->assertTrue($this->domain_manager->is_network_base_domain($upper));
+ $this->assertTrue($this->domain_manager->is_network_base_domain($with_www));
+ }
+
+ /**
+ * Foreign hosts that are not the network primary and not declared via the
+ * filter must be classified as per-site mapped domains (false).
+ */
+ public function test_is_network_base_domain_rejects_unknown_host(): void {
+ add_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX);
+
+ try {
+ $this->assertFalse(
+ $this->domain_manager->is_network_base_domain('totally-unrelated-host.test')
+ );
+ } finally {
+ remove_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX);
+ }
+ }
+
+ /**
+ * Hosts injected through the `wu_checkout_form_base_domains` filter are
+ * recognised as base domains. This filter is the public seam used by the
+ * production code path (checkout-form-derived domains feed into it via
+ * `get_checkout_form_base_domains()`).
+ */
+ public function test_is_network_base_domain_honours_filter(): void {
+ $filter = function () {
+ return ['nambo.pro'];
+ };
+
+ add_filter('wu_checkout_form_base_domains', $filter, PHP_INT_MAX);
+
+ try {
+ $this->assertTrue($this->domain_manager->is_network_base_domain('nambo.pro'));
+ $this->assertTrue($this->domain_manager->is_network_base_domain('NAMBO.PRO'));
+ $this->assertTrue($this->domain_manager->is_network_base_domain('www.nambo.pro'));
+ $this->assertTrue($this->domain_manager->is_network_base_domain('https://nambo.pro/'));
+ } finally {
+ remove_filter('wu_checkout_form_base_domains', $filter, PHP_INT_MAX);
+ }
+ }
+
+ /**
+ * Regression: handle_site_created MUST NOT create a Domain record when
+ * the new site's domain is one of the operator-declared checkout-form
+ * base domains. Without this guard, every subdirectory subsite created
+ * under `nambo.pro` was getting a `primary_domain = true` mapping row
+ * for `nambo.pro`, which broke every sibling subsite.
+ */
+ public function test_handle_site_created_skips_domain_record_for_checkout_form_base(): void {
+ $base_filter = function () {
+ return ['regression-base.test'];
+ };
+ add_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX);
+
+ // Build a synthetic WP_Site whose domain is exactly the operator
+ // base. We don't need a real blog row for this assertion — the
+ // guard runs before any DB write, and using a fake site keeps the
+ // test independent of blog factory quirks.
+ $blog_id = $this->create_test_blog();
+ $site = (object) [
+ 'blog_id' => $blog_id,
+ 'domain' => 'regression-base.test',
+ 'path' => '/some-tenant/',
+ ];
+
+ try {
+ $this->domain_manager->handle_site_created($site);
+
+ $created = wu_get_domains([
+ 'blog_id' => $blog_id,
+ 'domain' => 'regression-base.test',
+ 'number' => 1,
+ ]);
+
+ $this->assertSame(
+ [],
+ $created,
+ 'handle_site_created must not create a mapped-domain record for a checkout-form base domain.'
+ );
+ } finally {
+ remove_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX);
+ }
+ }
+
+ /**
+ * The `wu_should_create_domain_record_for_site` filter can suppress the
+ * automatic Domain record even for non-base domains (escape hatch for
+ * integrations).
+ */
+ public function test_handle_site_created_respects_should_create_filter(): void {
+ add_filter('wu_should_create_domain_record_for_site', '__return_false');
+ // Force the base-domain list to empty so we exercise the filter, not the base check.
+ add_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX);
+
+ $blog_id = $this->create_test_blog();
+ $site = (object) [
+ 'blog_id' => $blog_id,
+ 'domain' => 'addon-suppressed.test',
+ 'path' => '/',
+ ];
+
+ try {
+ $this->domain_manager->handle_site_created($site);
+
+ $created = wu_get_domains([
+ 'blog_id' => $blog_id,
+ 'domain' => 'addon-suppressed.test',
+ 'number' => 1,
+ ]);
+
+ $this->assertSame(
+ [],
+ $created,
+ 'wu_should_create_domain_record_for_site=false must suppress automatic record creation.'
+ );
+ } finally {
+ remove_all_filters('wu_should_create_domain_record_for_site');
+ remove_filter('wu_checkout_form_base_domains', '__return_empty_array', PHP_INT_MAX);
+ }
+ }
+
+ /**
+ * Handle_site_deleted must not enqueue wu_remove_subdomain for a site
+ * whose domain is a checkout-form base — that base is shared and the
+ * subdomain-removal hook would (incorrectly) instruct host providers to
+ * tear down the shared base.
+ */
+ public function test_handle_site_deleted_skips_remove_for_checkout_form_base(): void {
+ $base_filter = function () {
+ return ['regression-base-delete.test'];
+ };
+ add_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX);
+
+ $blog_id = $this->create_test_blog();
+ $site = (object) [
+ 'blog_id' => $blog_id,
+ 'domain' => 'regression-base-delete.test',
+ 'path' => '/some-tenant/',
+ ];
+
+ wu_unschedule_all_actions('wu_remove_subdomain');
+
+ try {
+ $this->domain_manager->handle_site_deleted($site);
+
+ $matching_actions = wu_get_scheduled_actions(
+ [
+ 'hook' => 'wu_remove_subdomain',
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ 'args' => [
+ 'subdomain' => $site->domain,
+ 'site_id' => $site->blog_id,
+ ],
+ 'per_page' => 5,
+ ],
+ 'ids'
+ );
+
+ $this->assertEmpty(
+ $matching_actions,
+ 'handle_site_deleted must not enqueue wu_remove_subdomain for a checkout-form base domain.'
+ );
+ } finally {
+ remove_filter('wu_checkout_form_base_domains', $base_filter, PHP_INT_MAX);
+ wu_unschedule_all_actions('wu_remove_subdomain');
+ }
+ }
+
+ /**
+ * Normalize_base_domain must accept a wide variety of formatting
+ * (scheme prefix, trailing path, trailing dot, port, casing, leading www.).
+ */
+ public function test_normalize_base_domain_handles_varied_inputs(): void {
+ $reflection = new \ReflectionClass($this->domain_manager);
+ $method = $reflection->getMethod('normalize_base_domain');
+ $method->setAccessible(true);
+
+ $cases = [
+ 'Example.COM' => 'example.com',
+ 'www.Example.com' => 'example.com',
+ 'http://example.com/path' => 'example.com',
+ 'https://example.com:8080/' => 'example.com',
+ 'example.com.' => 'example.com',
+ ' EXAMPLE.com ' => 'example.com',
+ '' => '',
+ ];
+
+ foreach ($cases as $input => $expected) {
+ $actual = $method->invoke($this->domain_manager, $input);
+ $this->assertSame(
+ $expected,
+ $actual,
+ "normalize_base_domain({$input}) should yield '{$expected}', got '{$actual}'"
+ );
+ }
+ }
}