The Tenant You Suspended Is Still Running Jobs
A status field changed. A boolean next to it didn't. The HTTP layer enforced the difference — nothing else did.
Suspending a tenant sounds like a single action: flip a switch, access stops. In a multi-tenant system with more than one entry point — an HTTP layer, a CLI, a queue of background workers — "suspended" is not one fact. It's however many facts your code actually checks, at however many places it checks them. Miss one, and a tenant you believe is locked out is still fully operational everywhere you didn't look.
Two fields, one meaning, two different lifecycles
A tenant suspension usually starts as a status change:
$tenant->setSetupStatus(SetupStatus::Suspended);
$entityManager->flush();
That's the field a billing dashboard shows. It's also, on its own, almost decorative — because the code that actually decides whether a tenant exists for the purposes of routing requests to it doesn't ask about setupStatus at all:
$tenant = $this->tenantRepository->findOneBy(['active' => true]);
active and setupStatus are two different fields, updated by two different code paths, with no enforced relationship between them. If suspension only ever sets setupStatus, the tenant keeps resolving normally through active. It hasn't been suspended in any sense the resolver cares about — only in the sense a status badge in an admin panel cares about.
Where enforcement actually lived
In practice, the gap gets papered over at the layer where the effects are most visible: an HTTP subscriber checks the tenant's status on every web request and blocks it there.
flowchart TD
A[Suspend tenant] --> B["setupStatus = Suspended<br/>(active untouched)"]
B --> C{Entry point}
C -->|HTTP request| D[TenantSuspensionSubscriber<br/>checks setupStatus]
D --> E[403 — blocked correctly]
C -->|Console command| F[TenantResolverListener<br/>checks active only]
C -->|Background worker| F
F --> G[Tenant resolves normally<br/>— fully operational]
That subscriber does exactly what it's supposed to do — reject the request. The problem is everything to its left in the diagram that never goes through it. A scheduled job that resolves its tenant list from active = true finds the suspended tenant sitting right there, indistinguishable from every tenant in good standing. A console command run manually against that tenant's data works without complaint. Nothing in either path was ever wired to ask the question the HTTP subscriber asks.
Why this isn't a niche architecture problem
This isn't specific to one framework or one field-naming choice — it's a structural risk in any multi-tenant system where enforcement is attached to a specific transport rather than to tenant resolution itself. Background workers are, in general, one of the more common places for cross-tenant issues to surface precisely because they're the layer furthest from the request/response cycle developers instinctively reason about when they think "access control." A worker dequeues a job, resolves a tenant, and runs — the same suspicion that makes you check $this->isGranted(...) on a controller rarely makes it into a worker's process() method, because a worker doesn't feel like a place that needs an authorization check. It resolves tenants the same way everything else does, which is exactly the problem.
The fix is boring on purpose
public function suspend(): void
{
$this->setSetupStatus(SetupStatus::Suspended);
$this->setActive(false);
}
Pairing the two fields at the single point where suspension happens means every consumer of active — the resolver, a worker's tenant lookup, a scheduled job's tenant list — becomes correct automatically, without needing its own awareness of suspension as a concept. The fix isn't "add more checks to more places." It's collapsing two facts that were accidentally allowed to drift apart back into one, at the one place that sets them.
What to actually audit
If your system suspends, disables, or soft-deletes tenants (or accounts, or any top-level scoping entity), the question worth asking isn't "does suspension work?" — a quick manual test through the UI will usually say yes, because the UI goes through HTTP. The question is: how many other paths resolve that same entity, and does every one of them ask the same question the HTTP layer asks? Background workers, console commands, and scheduled jobs are the paths most likely to have been built before suspension existed as a requirement, and least likely to be covered by whatever test convinced everyone the feature was done.