Skip to content

feat(checkout): always honour Allow Site Templates toggle — fall back to pre-selected or oldest active template when none chosen #1147

@superdav42

Description

@superdav42

Summary

If a product has the Allow Site Templates toggle enabled, every checkout for that product should result in the new site being cloned from a real template — never default WordPress content. Today, when a customer checks out without picking a template (no Template Selection field on the form, or the field exists but the customer didn't select one), the new site is created with default WordPress content even when the product is configured to allow templates.

A fresh WordPress site as the outcome of a paid checkout should only happen when the network admin has explicitly disabled the Allow Site Templates toggle on the product.

Desired behaviour

At checkout (Checkout::create_pending_site() and equivalent paths), resolve template_id in this priority order. The first non-zero result wins:

  1. Customer-supplied template_id — i.e. what the Template Selection field on the checkout form posted, or whatever a previous filter resolved (request_or_session('template_id')). This is already step 1 today and stays unchanged.
  2. MODE_ASSIGN_TEMPLATE forced template — the wu_checkout_template_id filter already overrides any customer pick when the product's mode is "Assign Site Template". This is already step 2 today via Site_Template_Limits::maybe_force_template_selection() and stays unchanged.
  3. NEW: pre-selected template (BEHAVIOR_PRE_SELECTED) under MODE_CHOOSE_AVAILABLE_TEMPLATESLimit_Site_Templates::get_pre_selected_site_template() already returns this id. Use it when the customer didn't pick anything and the mode allows pre-selection.
  4. NEW: oldest active site template — fallback when none of the above resolved a template AND the product's Allow Site Templates toggle is on. "Oldest active" = lowest blog_id among sites where wu_type = site_template, is_active() === true, is_archived() === false, is_deleted() === false, is_spam() === false (matches the filter chain added in PR fix(template-switching): hide inactive/archived/deleted/spam templates from customer grid #1129 for the customer-panel grid). If MODE_CHOOSE_AVAILABLE_TEMPLATES is in force, restrict the pool to get_available_site_templates() first; only fall through to the global pool when that subset is empty.

If the product's Allow Site Templates toggle is off (Limit_Site_Templates::is_enabled() === false), do nothing — the customer gets a default WP site, which is the documented behaviour for that toggle (per the existing tooltip in inc/admin-pages/class-product-edit-admin-page.php:962: "If this option is disabled, sign-ups on this plan will get a default WordPress site.").

Where to implement

The wu_checkout_template_id filter is the single hook every site-creation path runs through:

  • inc/checkout/class-checkout.php:1604 (the standard checkout)
  • inc/models/class-membership.php:2098 (publish_pending_site() re-application)
  • inc/managers/class-site-manager.php:273 (manual site creation)
  • inc/apis/class-register-endpoint.php:624 (REST register endpoint)

Site_Template_Limits::maybe_force_template_selection() (inc/limits/class-site-template-limits.php:151) is already wired into that filter and currently handles only MODE_ASSIGN_TEMPLATE. Extend that one method to cover the new fallback chain so every entry point benefits without per-call duplication.

Suggested implementation outline (illustrative, not literal):

public function maybe_force_template_selection( $template_id, $membership ) {

    $template_id = (int) $template_id;

    if ( ! $membership instanceof \WP_Ultimo\Models\Membership ) {
        return $template_id;
    }

    $limit = $membership->get_limitations()->site_templates;

    // Allow Site Templates toggle off — leave whatever the caller had.
    if ( ! $limit->is_enabled() ) {
        return $template_id;
    }

    // Existing behaviour: assign-template mode always wins, customer pick is ignored.
    if ( Limit_Site_Templates::MODE_ASSIGN_TEMPLATE === $limit->get_mode() ) {
        return (int) $limit->get_pre_selected_site_template();
    }

    // Customer already picked a valid template — keep it.
    if ( $template_id > 0 ) {
        return $template_id;
    }

    // No customer pick — try pre-selected (only meaningful when mode is choose-available).
    if ( Limit_Site_Templates::MODE_CHOOSE_AVAILABLE_TEMPLATES === $limit->get_mode() ) {
        $pre_selected = (int) $limit->get_pre_selected_site_template();

        if ( $pre_selected > 0 ) {
            return $pre_selected;
        }
    }

    // Final fallback: oldest active template the product allows.
    return $this->resolve_default_template_id( $limit );
}

resolve_default_template_id() should:

  1. Query wu_get_site_templates([ 'fields' => 'ids', 'orderby' => 'blog_id', 'order' => 'ASC', 'number' => 9999 ]) (or equivalent) to get all candidates oldest-first.
  2. If MODE_CHOOSE_AVAILABLE_TEMPLATES, intersect with $limit->get_available_site_templates() first; only if that intersection is empty, fall through to the unrestricted list.
  3. Filter each candidate via is_active() && ! is_archived() && ! is_deleted() && ! is_spam() (mirrors PR fix(template-switching): hide inactive/archived/deleted/spam templates from customer grid #1129).
  4. Return the first surviving candidate's id, or 0 if none exists (in which case the caller behaves as today: wpmu_create_blog() and a fresh site).

Related code

  • inc/limitations/class-limit-site-templates.php — modes (MODE_DEFAULT, MODE_ASSIGN_TEMPLATE, MODE_CHOOSE_AVAILABLE_TEMPLATES), is_enabled() (default state on, see default_state() line 304-311), get_pre_selected_site_template() line 245, get_available_site_templates() line 206.
  • inc/limits/class-site-template-limits.php — current filter handler at line 151. Also uses the same logic at line 169 (maybe_force_template_selection_on_cart) for cart extra — that path may need the same treatment so the cart preview matches what the eventual site will use.
  • inc/admin-pages/class-product-edit-admin-page.php:948-1001 — the product UI (Allow Site Templates toggle + selection mode dropdown).
  • inc/models/class-site.phpSite::save() routes to Site_Duplicator::duplicate_site() when get_template() is truthy, otherwise to wpmu_create_blog(). Filter resolution must happen before the route decision.

Acceptance criteria

  1. Product with Allow Site Templates ON, MODE_DEFAULT, no Template Selection field on the form → checkout creates a site cloned from the oldest active site template in the network.
  2. Product with Allow Site Templates ON, MODE_CHOOSE_AVAILABLE_TEMPLATES and a BEHAVIOR_PRE_SELECTED template, customer didn't pick → checkout uses the pre-selected template.
  3. Product with Allow Site Templates ON, MODE_CHOOSE_AVAILABLE_TEMPLATES, no pre-selected template, customer didn't pick → checkout uses the oldest active template from the available subset; if that subset is empty (admin made all of them inactive), fall back to oldest active across all templates.
  4. Product with Allow Site Templates ON, MODE_ASSIGN_TEMPLATE → unchanged (customer's pick is ignored, the assigned template always wins). Existing test coverage stays green.
  5. Product with Allow Site Templates OFF → unchanged, customer gets a default WP site even if the form had template_id set. (Existing semantics.)
  6. Product with Allow Site Templates ON, customer picked a valid template explicitly → customer's pick is honoured exactly as today, regardless of mode (except MODE_ASSIGN_TEMPLATE).
  7. Product with Allow Site Templates ON but the network has zero usable templates (all inactive/archived/deleted/spam) → checkout falls through to default WP behaviour (no fatal, no WP_Error from Site_Duplicator). A wu_log_add('site-duplication', ...) line at WARN level is fine for diagnostics.

Tests to add

  • Unit: tests/WP_Ultimo/Limits/Site_Template_Limits_Test.php (create if absent) covering each acceptance criterion above. Mock the membership, the limit, and the network's site-template list. Assert the integer that maybe_force_template_selection returns under each combination.
  • Integration: extend tests/WP_Ultimo/Helpers/Site_Duplicator_Test.php (or a new Checkout_Template_Resolution_Test.php) to drive the full Membership::create_pending_site()publish_pending_site() flow with template_id = 0 in $site_data and verify the resulting blog's content is cloned from the expected template, not default WP.

Why this matters

This change ensures the product's Allow Site Templates toggle becomes the single source of truth for "should this customer get a real template?". Today the toggle is misleading: it can be on, the customer can complete checkout, and they still receive a fresh WP site if the form happened to lack a Template Selection field — exactly what was reported on the live site that triggered this investigation. With this fix, the only way a customer ends up on a default WP site is the way the tooltip already documents: the admin explicitly turned the toggle off.

Out of scope

  • Changing the product UI tooltip / labels. The behaviour change here makes the existing tooltip more accurate ("If this option is disabled, sign-ups on this plan will get a default WordPress site."), so no copy edit is needed.
  • The customer-panel template-switching bug for sites with template_id = 0, which is tracked separately as fix(template-switch): cannot switch template when site has no current template (regression from PR #1113) #1145 — that issue is the recovery path if any sites have already been created without a template before this fix lands.
  • The signup-form misconfiguration on the customer's site (no Template Selection field on their checkout forms). That is a customer-side configuration matter, separate from this engine fix.

aidevops.sh v3.14.83 plugin for OpenCode v1.14.33 with claude-opus-4-7 spent 6h 32m and 91,896 tokens on this with the user in an interactive session.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions