I tagged v0.10.0 of LaraFoundry this week: the admin companies console. It is the second screen of the operator panel, after admin users a few releases back, and the headline feature is that a platform operator can suspend a whole tenant now. Blocking the company turned out to be the easy half. The half worth writing about is not locking out the people who belong to other companies too.
LaraFoundry is a SaaS core I'm extracting in public from a live CRM, one module at a time. An earlier version of that CRM already runs in production, so most phases are less "invent a feature" and more "lift a feature out, and notice the habit that was fine for one app and wrong for a reusable core." This phase had a good one.
How the donor "blocked" a company
The honest answer is that it didn't, not directly. There was no company-level block in the admin at all. If you needed to cut off a tenant, you banned its owner, and a middleware then denied access to anyone whose owner was banned. The company went dark as a side effect of a person being punished.
That works right up until it doesn't. Banning the owner and suspending the company are two different operator actions with two different meanings. An owner can be a bad actor while their company keeps paying and keeps a dozen innocent employees working. A company can be suspended for non-payment while nobody did anything wrong. Folding both into "ban the owner" means you can never express one without implying the other, and the audit trail records the wrong story either way.
So the rule for this phase was to make a company block a first-class thing, separate from a user block, and to put its enforcement somewhere a future me can't accidentally route around.
One column, enforced at one boundary
A blocked company is one nullable timestamp on the companies table:
public function isBlocked(): bool
{
return $this->company_blocked_at !== null;
}
That column is not in $fillable, so no tenant can POST its way back to active. Setting it is an operator-only action behind a policy, and it writes an entry to the activity log so there is a record of who suspended what and why.
The interesting decision is where the block is enforced. The tempting answer is "add an if ($company->isBlocked()) check to the controllers that matter." That is exactly the donor's scattered-middleware pattern in a new coat, and it rots the same way: the day someone adds a new tenant-scoped route and forgets the check, the block has a hole.
LaraFoundry already has a single place every tenant-scoped request must pass: the middleware that resolves and requires an active company. That is the one chokepoint where "this tenant is suspended" has to be true or false for the whole request. So the block lives there. One column, read at one boundary, takes the entire tenant down. There is nothing to forget on the next route.
The part that actually took the thought
Here is the trap. A user is not a member of one company. They can own one, be an employee of two others, and have an active company selected out of those. If an operator suspends the company that happens to be active for that user, a naive enforcement check does the worst possible thing: it blocks the request, the user gets bounced, and because their active company is still the suspended one, every subsequent request bounces too. You have effectively logged out and locked out a person who did nothing wrong and still has perfectly good companies to work in.
So the enforcement is not a wall, it is a self-heal:
protected function handleBlocked(User $user, Request $request): RedirectResponse
{
// promote the next company that ISN'T blocked, then replay the request
if ($user->setNextAvailableCompany()) {
return redirect($request->fullUrl());
}
// no other company to fall back to: drop the active one and show the
// blocked screen. no logout, no redirect loop.
$user->clearActiveCompany();
return redirect()->route('larafoundry.tenancy.blocked');
}
If the user has another company that isn't blocked, the middleware quietly switches them into it and replays the original request, so they land where they were headed in a tenant that still works. Only if there is genuinely nowhere to go does it clear the active company and show a "this company is suspended" screen. It never logs them out, because logging out a multi-company user over one suspended company is wrong, and it never loops, because the fallback state is stable.
Making "the next available company" actually skip blocked ones is a one-line addition that is easy to miss:
$next = $this->companies()
->whereNull('company_blocked_at') // don't promote a user INTO a blocked company
->first();
And the company switcher gets the same guard, so a user can't manually select a suspended company from the dropdown either.
The review earned its keep, again
Every phase in this series has a moment where the code review catches something I was about to ship, and this one had two, both in exactly this area.
The first cut of the enforcement had a redirect loop: it bounced a blocked-company request to a safe route, but that route ran through the same middleware, saw the same still-blocked active company, and bounced again. The fix is the self-heal above, which changes the active company before redirecting, so the next pass sees a different, valid tenant.
The second was the promotion query. My first setNextAvailableCompany happily promoted whatever company came first, including a blocked one, which meant an operator could suspend a tenant and the affected user would get auto-switched right back into it. The whereNull('company_blocked_at') clause is small and it is the whole point.
There was also a duller but real bug the review flagged on the way through: the company list filters took array query parameters like ?status[]=x and blew up with a 500 because the base filter assumed scalar values. That one wasn't even about blocking, but the fix (skip any non-scalar filter value instead of trying to bind it) hardened the admin users filter at the same time.
The read-only line, on purpose
The console also shows each company's subscription status, classified into a small fixed vocabulary: on trial, active, expiring soon, expired, or never activated. An operator can see, at a glance, which tenants are about to lapse.
What the console deliberately cannot do is manage any of that. There is no "extend this subscription," no "change the plan," no "grant a manual trial" button. The status is read-only, computed from the billing columns the free core already carries, and the UI says as much in plain text: subscription management lives in the billing add-on.
This is the same free-versus-paid line I drew when I shipped the billing seam two phases ago. The free core is a complete multi-tenant operator console: list your tenants, inspect them, suspend the ones you need to. The moment the job becomes moving money and changing what a customer is entitled to, that is the paid larafoundry-billing add-on, a separate package. Showing the status is reading state the core already owns. Changing it is the product I'm going to charge for. Drawing that line through a single screen, so the free half is genuinely useful and the paid half is genuinely separate, is most of the design work.
The honesty section
A few things this phase is not. The company detail view is read-only: you can inspect a tenant, you cannot edit its details from the operator panel yet. The block stores a reason on the backend and audits it, but the reason-capture UI is minimal, not a polished workflow. And the admin dashboard with platform-wide metrics is deliberately deferred, because half its widgets want modules that don't exist in the core yet and the revenue half wants the billing add-on. Better an honest second console screen than a dashboard padded with two real numbers and a lot of "coming soon."
What shipped is the screen, green: the package is at 398 Pest tests and 81 Vue tests at the v0.10.0 tag, the security review came back clean, and six review findings (the two above among them) are fixed with regression tests.
The thread, again
The recurring lesson of this series is that the bug is almost never in the feature, it is in the default. Here the feature was easy: suspend a tenant. The default that needed unlearning was the donor's, where blocking a company meant banning a person, and the trap that needed avoiding was the obvious enforcement check that would have locked out everyone standing nearby. A block that takes down the right tenant and quietly steps the bystanders aside is a much smaller diff than it sounds, and it is the entire difference between an operator tool you can trust and one that generates support tickets.
Follow along
- Code, versioned and Pest-tested before each tag: github.com/dmitryisaenko/larafoundry
- The project and roadmap: larafoundry.com
Top comments (0)