StagingManager
\Benecaster\Staging\StagingManager
The entry point for staging mode detection and staging subscriber-set management. Add-on developers use it for two things: to find out whether the current site is a staging environment, and to read or adjust which subscribers hold live feed tokens there.
Staging sites deliberately run with a reduced subscriber set. A staging copy of a production database would otherwise poll feeds and report counts as though it were the real site, which is why the active set exists and why it is capped on the lower plans.
Resolve the class from the service container rather than constructing it: benecaster()->container()->make( \Benecaster\Staging\StagingManager::class ).
Constructor Dependencies
| Type | Description |
|---|---|
\Benecaster\Token\TokenRepository |
Data access for the token rows the staging active set is built from. |
\Benecaster\Staging\EnvironmentResolver |
Resolves the two environment constants and the precedence between them. Optional — it defaults to a fresh instance, since it holds no dependencies of its own. |
\Benecaster\Staging\StagingPatternFetcher |
Supplies the vendor-specific hostname patterns Benecaster fetches from the licence server. Optional — defaults to a fresh instance, matching the EnvironmentResolver pattern above, so no call site needs to change to pick this up. A fetched pattern can only ever ADD a staging match on top of the hardcoded rules below; it can never remove one. |
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
is_staging(): bool |
Public | — | Returns true when the current site is operating in staging mode. It asks EnvironmentResolver::declared() first and only falls through to hostname patterns (see Notes) when neither constant is set: BENECASTER_IS_PRODUCTION returns false here, BENECASTER_STAGING returns true, and with both set the site is production. Detection runs once per request and is cached for the remainder of it. ⚠ This method does not read either constant itself, and neither should you. Read them through EnvironmentResolver, which is the only place the precedence is expressed — going direct is how an install came to be staging by this answer and production by LiveKeyGuard's at the same moment. |
get_staging_cap(): ?int |
Public | — | Returns the staging subscriber cap for this licence, or null when the plan has none. Free, Starter and Growth are capped at 25; Pro, Multi-Show and Studio return null, as does an operator-set null override. The value comes from the daily validation response. When null, add_to_set() never returns a cap error and rebuild_default_set() includes every subscriber. |
get_active_set(): array |
Public | — | Returns the staging active set as an array of WordPress user IDs, or an empty array when staging is not active or the set has not been built yet. Use this rather than reading the underlying option directly — it handles the empty-state edge cases and is the stable surface. |
add_to_set( int $user_id ): bool|WP_Error |
Public | — | Adds a user to the staging active set. Returns true on success, or a WP_Error when a cap is set and the set is already full. It does not auto-swap: at cap, the caller must call remove_from_set() to free a slot first, and is responsible for presenting a swap UI. On success it fires benecaster_clear_feed_cache once per distinct show and tier pair among the user's active tokens, so the newly-active subscriber is not served a stale empty feed on their first poll. |
remove_from_set( int $user_id ): void |
Public | — | Removes a user from the staging active set. Silently succeeds when the user is not in the set, so it is safe to call without checking first. |
rebuild_default_set(): void |
Public | — | Regenerates the active set from scratch. For each configured membership tier it selects at least two subscribers, then fills the remaining cap slots by join date, oldest first. When get_staging_cap() returns null, every subscriber is included. Runs automatically on first staging detection when the set is empty, and when an admin chooses "Reset to defaults". It overwrites the set entirely — manual additions are discarded. |
Constants
| Name | Value | Description |
|---|---|---|
FeedCache::STAGING_CACHE_TTL_DEFAULT |
45 |
Feed cache TTL in seconds while staging is active, down from the production TTL so feed changes show up quickly while you are testing. |
BENECASTER_STAGING_CACHE_TTL |
(not defined) |
Define in wp-config.php to override the staging TTL, in seconds. Raise it to test caching behaviour itself; setting it to PHP_INT_MAX effectively disables the staging reduction, which is not recommended. |
Hooks Fired
Notes
## URL patterns
Detection is now two sources, unioned — a fetched pattern can only ever ADD a match, never remove one. is_staging()'s hostname check first tries the hardcoded, vendor-neutral rules below, then the fetched vendor-specific list (see Fetched patterns below). Matching is case-insensitive and considers only the host portion of the site URL, for both sources.
Hardcoded, vendor-neutral — work fully offline, ship with the plugin:
| Pattern | Example match |
|---|---|
| staging / staging{n} label anywhere in the host | staging.mypodcast.com, staging1.mypodcast.com, app.staging.mypodcast.com, mypodcast.staging.wpengine.com |
| *.local | mypodcast.local |
| localhost | http://localhost |
| *.test | mypodcast.test |
| Private or reserved IP literal | 192.168.1.20, 10.0.0.5, 127.0.0.1, ::1 |
The staging label must match a whole dot-delimited label, so stagingfoo.example.com and staging-example.com are not detected. Public IP literals are not detected either.
## Fetched patterns
*.flywheelstaging.com and *.ngrok.io / *.ngrok.app moved off this hardcoded list (feature/staging-pattern-list-consumer, 2026-09-09) to a small vendor-specific list the plugin fetches from the licence server once a day via WP-Cron and caches in the benecaster_staging_patterns option — {patterns: string[], match_rule: string, updated_at: string|null, last_fetched: string}. The cache never expires and is never cleared on a failed fetch; a fetch failure leaves the option exactly as it was and retries on the next daily tick, so a site that has fetched successfully at least once keeps detecting vendor-specific staging hosts even if the licence server is later unreachable. Two private methods implement the match: host_matches_fetched_pattern() walks the cached list, and host_matches_glob() implements the licence server's host-glob-v1 rule exactly (see 05-license-server.md → GET /staging-patterns) — a leading *. matches one or more whole labels, any other * matches within a single label only.
*.wpengine.com and *.kinsta.cloud are not matched, having been on the suffix list until 2026-09-04. Both are those hosts' documented production temporary URLs — the address a live site serves from until a custom domain is pointed at it — so the suffix matched exactly the sites it most needed not to. ⚠ WP Engine's legacy staging shape {install}.staging.wpengine.com is unaffected: it carries a staging label and is matched by the label rule, which is the row it now belongs to in the table above. Both exclusions follow the same rule the pattern list is built on, and an ambiguous host fails to detect — but the two mistakes are not the ones the rule is often quoted as weighing, so it is worth knowing what each actually costs.
A false positive — a production site detected as staging — is the expensive direction. is_staging() suppresses the live-Stripe-key guard's approval, and the flag is sent on the daily /validate ping, where the licence server skips tier logic for a staging site: that site's subscriber counts are excluded from the over-limit windows, the threshold notices and drop detection. It is not invisible, though — the site wears a 4px orange border on every admin screen, so the state is visible even where the consequence is not.
A false negative — a staging site not detected — costs no activation slot, contrary to a claim that circulated in several documents until 2026-09-04. StagingManager is never consulted at /connect, and nothing on the connect URL decides the slot: the URL carries no staging parameter at all. Whether a connected site costs a slot is the licence server's call, derived from the verified hostname and re-evaluated on every /validate ping. What a false negative actually costs is that live Stripe keys become saveable on the staging site (clones are still refused by InstallIdentity, which is hostname-blind) and the staging subscriber set does not apply.
The method the old claim named no longer exists. It was called is_staging() until 2026-09-04, which is how the show-slot claim arose: two methods with one name answering different questions, and a reader taking the answer from the wrong one. It was renamed OAuthInitiator::license_server_is_non_production() to say what it actually asked — whether BENECASTER_LICENSE_URL points away from benecaster.com — and then deleted along with the connect parameter it decided. If you meet either name in an older document, it is that method and not StagingManager's.
A domain that matches none of these needs define('BENECASTER_STAGING', true) in wp-config.php. If you are unsure whether your host's staging URL is recognised, set the constant — it costs nothing and removes the guesswork.
## Staging and the daily validation payload
The daily validation request carries an is_staging boolean on every ping, in both states, taken straight from is_staging(). It is not an omit-when-false flag.
⚠ Absence is not a signal. A payload with no is_staging key leaves the licence server's stored value untouched rather than resetting it to false, so an omission is indistinguishable from a site whose state has not changed — which is why the plugin omitting it in production went unnoticed until 2026-09-04.
Two further adjustments apply while staging:
- The reported subscriber count is capped at the staging cap, so a staging site carrying a full production database cannot trigger a tier upgrade or an overage counter. - When the cap is null (Pro, Multi-Show, Studio) the real count is sent uncapped, consistent with those plans having no ceiling in production either.
The licence server excludes staging pings from tier upgrade logic, benchmark aggregation, and the estimated-reach preview for announcements. See License REST API for the full payload.