FeedController
\Benecaster\Feed\FeedController
Front-controller for every feed request. Registers the feed rewrite rules, validates the token on the way in, resolves which tier the request is entitled to, and dispatches the response — cached XML, the public feed, a 410, or a 403. See the notes below for the order those refusals are evaluated in, which is the part most worth knowing before changing anything here.
The pipeline emits no redirect of any kind. There is no maybe_redirect_for_hard_cutoff() and no benecaster_grace_period_feed_fallback filter. Any diagram, note or callback describing a 302 to the public feed at cutoff is wrong.
Registered automatically on plugin boot. You do not instantiate or call it directly; extend feed behavior through the feed filters and actions rather than by subclassing.
Constructor Dependencies
| Type | Description |
|---|---|
\Benecaster\Container |
Resolves collaborators lazily at dispatch time, including GraceExpiryTracker for the hard-cutoff check. |
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
maybe_dispatch(): void |
Public | — | Entry point on the parse_request path. Returns immediately unless the request matched a feed rewrite rule; otherwise validates the token and dispatches the appropriate response. |
validate_token( string $token ): ValidatedToken|null|false |
Public | — | Three-state result. A ValidatedToken for a live token, null when the token is unknown, false when it exists but is revoked — the caller needs that distinction to choose between the public-feed fallback and a 403. |
should_lock_over_cap_free_token( ValidatedToken $token_row ): bool |
Public | — | Whether this over-limit token should be locked out with a 410 rather than quietly demoted to the public feed. Three conditions, all required: the token is flagged over-limit; the token-cap enforcement preference is cap; and the token is free-tier. A paying subscriber is NEVER locked, over cap or not. Public for unit-test access only — this is not API surface and is not a supported extension point. |
resolve_tier_slugs( ... ): array |
Public | — | Resolves which tier slugs the request may see. Consults GraceExpiryTracker::is_grace_active() so a site whose license is Suspended but still inside the payment grace window keeps serving private feeds normally. Grant slugs are appended on every branch that serves a feed. The ordinary and follower branches append both kinds of grant (buy-ups and standalone entitlements). The over-limit and license-grace branches append standalone entitlements only, because the base was degraded and a buy-up degrades with it. The no-active-membership branch no longer always returns null: a listener holding standalone grants gets the public base plus those grants instead of a 403, and benecaster_subscriber_no_active_membership still fires for them, since the statement is still true. |
resolve_tier_from_url(): string |
Public | — | Extracts the tier slug from the request URL for per-tier public feed routes. |
client_etag_matches( string $etag ): bool |
Public | — | Compares the client's If-None-Match header against the cached feed ETag so an unchanged feed can answer 304 instead of re-sending the body. |
add_rewrite_rules(): void |
Public | — | Registers the feed rewrite rules. Rewrites are flushed on activation and when a relevant option changes. |
maybe_flush_on_option_change( string $option ): void |
Public | — | Flushes rewrite rules when an option that affects feed URL structure is updated, so a slug change takes effect without a manual permalink resave. |
Hooks Fired
Notes
feed_is_dark( int $show_id ) is the single "has private feed delivery ended?" decision. It is private and
not an extension point, but it is the method to read before changing anything in this pipeline. The gate runs
inside dispatch() on token-bearing requests only, and — this is the part that matters — before
validate_token().
Why before, and not on the invalid-token branch where the old cutoff logic sat. Cancellation and expiry revoke nothing. Their tokens stay valid, so a check that only ever saw invalid tokens would never see them at all. Payment hard cutoff does bulk-revoke, which is why the old arrangement worked for that one case and would have silently failed for the other two.
The terminal set is exactly three things, and nothing else belongs in it:
1. GraceExpiryTracker::is_hard_cutoff() — Day 30 of the payment-failure window. **Suspended alone is not
terminal**; days 1–29 keep private feeds fully intact, which is the entire point of the window.
2. LicenseStatus::Cancelled — the license server runs its own 30-day cancellation clock before it ever emits
this, so the grace has already been served by the time the plugin stores it.
3. LicenseStatus::Expired — which is also what 30 consecutive unreachable days force. Feeds going dark on a
month-long outage is deliberate: if unreachability failed open forever, blocking benecaster.com in
/etc/hosts would be free premium for life.
✅ Plus a stored site token for the show — a disconnected install still fails open. With no token the plugin cannot tell a subscriber from a stranger, so it does what the free plugin does and serves the public feed. A terminal status on a site holding no activation is not a dark feed.
Cap-mode over-limit is not in that set and must never be added to it. A healthy, paying license over its subscriber limit correctly serves the public feed to over-limit subscribers — a different state with a different remedy. Conflating the two is what produced the original contradiction.
How this interacts with the per-show toggle. Normally a revoked or unknown token is resolved against the
show's own serve_public_feed_on_invalid_token setting: enabled, the request falls through to the public feed;
disabled, it gets a 403. That per-show choice is the podcaster's, and it governs subscriber-level failures.
A terminal license state is a podcaster-level failure and bypasses the toggle entirely.
The two are easy to confuse when debugging: a site serving 403 where you expected the public feed is far more
likely to be in a terminal license state than to have the toggle switched off. The 403 body is the same
"Invalid or expired feed token." string either way, so the message will not tell you which — the scope will.
Three refusal paths now sit in this pipeline, and the ORDER is the behaviour.
A request can be refused by the hard-cutoff rule, by the over-cap free-tier lock, or by the over-limit demotion — and a single request can qualify for more than one at once. They are evaluated in a fixed order, each winning over the ones below it:
1. Dark-feed 403 (feed_is_dark()) — intercepts before token validation, on token-bearing requests only.
A podcaster whose card failed cannot have their free listeners 410'd by a rule about plan limits:
different fault, different remedy.
2. Over-cap free-tier lock (410) — fires after the invalid-token branch, since an invalid token is not an
over-cap one, and before tier resolution.
3. Over-limit demotion to the public feed — the branch inside tier resolution that would otherwise quietly
return the public feed for the very same token.
Read from the top: cutoff beats the lock, and the lock beats the demotion. Reordering these changes what a listener experiences, so treat the sequence as the contract rather than an implementation detail.
The lock is logged as over_limit, not as a new status. It is an over-limit outcome, and minting a new
token_status value here would silently change what the nightly analytics rollup aggregates. If you are
querying feed request logs to find locked listeners, they are not separately labelled.