Thirty commits across three repos, and the day split cleanly into two halves: standing a control plane up on a bare host, and fixing the things that were quietly wrong once it was actually reachable.
The deploy half has its own write-up — a deploy script that had been reporting success for weeks while skipping its own git pull. This is everything else.
1. An account that can sign in and see nothing
Multi-tenant app. A user belongs to organisations; every query is scoped to the current one. Registration created the user row and stopped there — no role, no organisation.
Which produces the worst possible failure mode: the account works. Login succeeds, the session is valid, the dashboard renders. It's just empty, because every scoped query returns zero rows and every permission check runs against a user holding no role at all.
That doesn't read as "your account is awaiting access." It reads as a broken product.
Self-serve registration should hand you something you own, so it now does:
public function create(array $input): User
{
Validator::make($input, [
// ...
'password' => $this->passwordRules(),
])->validate();
return DB::transaction(function () use ($input): User {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
$user->assignRole('user');
$organization = Organization::create([
'name' => $name = __(":name's Organisation", ['name' => $user->name]),
'slug' => $this->uniqueSlug($name),
'owner_id' => $user->id,
'plan' => OrganizationPlan::Starter,
]);
$user->setDefaultOrganizationId($organization->id);
return $user;
});
}
The transaction isn't decoration. User with a role but no organisation is exactly the state this change exists to eliminate — so a failure halfway through must not be able to create one.
Two details worth pulling out:
The slug suffixes rather than throws. organizations.slug is unique, and two people called Zul is not an error condition, it's Tuesday. Anything derived from user-supplied names needs a collision strategy chosen deliberately, and "500 on the second signup" is rarely it.
Setting the default matters as much as creating the org. Without it the organisation switcher opens on nothing, and the console is empty for a user who does have somewhere to be. Same visible symptom, entirely different cause. Anywhere you have a "current X" concept, creating an X and selecting one are two steps, and forgetting the second looks exactly like forgetting the first.
Then the same rule, from the other direction
Registration having a tenant while the admin-created path didn't means which door an account came through decides whether it works. So both paths now go through one invokable action, and a deploy operation backfills the accounts made before either did.
Pulling it into a shared action surfaced a good trap:
private function makeDefaultFor(User $user, Organization $organization): void
{
// The tenancy helper writes the *session's* current organisation as well
// as the column. Called while an admin creates somebody else's account, it
// would move the admin into the new user's tenant — silently, mid-request,
// with nothing on screen to explain it.
if (auth()->check() && auth()->id() !== $user->id) {
$user->forceFill(['organization_id' => $organization->id])->save();
return;
}
$user->setDefaultOrganizationId($organization->id);
}
The helper is named for the common case — you, setting your own default — and it does the extra session write because in that case you want it. Reuse it for another user and the extra write becomes a bug that manifests as "the admin panel randomly teleported me into a customer's tenant."
Pinned with a test, because this is the kind of thing that reads as correct forever:
it('leaves the acting admin in their own organisation', function () {
$admin = User::factory()->withOrganization()->create();
$before = $admin->fresh()->organization_id;
actingAs($admin);
app(CreatePersonalOrganizationAction::class)
->execute($subject = User::factory()->create());
expect($admin->fresh()->organization_id)->toBe($before)
->and($subject->fresh()->organization_id)->not->toBe($before);
});
Any helper that touches both persistent state and request state needs a hard look before it's called on behalf of somebody else. The session write is invisible at the call site, which is precisely why it survives review.
2. Four frontend traps, none of which produce an error
A UI panel — "this provider is restricted to N components" — that counted checkboxes and reported the wrong number forever. Unpicking it turned up four separate things that all fail silently. Every one went into the project's gotchas file the same afternoon.
A component library's checkbox is often not an <input>. The one I'm using renders a custom element with role="checkbox". Any JavaScript querying input[type=checkbox] matches zero elements and reports a confident, wrong count. :checked doesn't apply either, though the element does expose a .checked property. Match both shapes, read .checked — and pin the marker attribute in a test, so a library upgrade fails CI instead of silently returning the count to zero.
A double quote anywhere inside an x-data attribute ends the attribute. Blade doesn't escape what you write in there. So a comment containing role="checkbox" truncates the expression mid-way, leaves a stray checkbox" attribute on the element, and un-Alpines the entire subtree — with no console error. Just a panel that never reacts. Single quotes for JS strings inside Alpine attributes, Blade comments outside the tag, and /* */ over // in multi-line attributes.
x-cloak does nothing without a CSS rule, and I never wrote one. It's only an attribute Alpine removes on init; all the hiding lives in:
[x-cloak] { display: none !important; }
Without it, every x-show="false" element renders visible on first paint and vanishes a moment later. The 2FA challenge, a couple of tab groups, a log disclosure — all flashing their hidden state for months. Nothing errors, so nothing catches it.
And the one that ties them together: a Livewire assertion proves the server rendered a string. It proves nothing about what the browser does with it. assertSee passes just as happily for markup whose Alpine expression is broken, whose selector matches nothing, or whose x-cloak is inert. All three shipped green suites.
So: anything whose behaviour lives in Alpine or in a component library's custom element needs a real browser once, and then the markup contract it depends on pinned in a test. The browser pass finds it; the pinned test keeps it found.
Related, from the same panel: wire:model is deferred, so any badge or counter derived from it is a round trip behind what the operator can see. Compute it client-side, and seed the initial value from the server so first paint is right.
3. Saying what the thing actually is
Two small changes with the same spirit.
An enum for infrastructure provider types used to render as its own case names. Now each type carries its real noun — a server, a cluster, a hypervisor — through label(), so the form says "add a server" instead of "add a provider (type: ssh)". This is the whole argument for enums with label()/color() rather than bare strings: the vocabulary lives in one place, and the UI stops leaking your internal taxonomy at the user.
And a capabilities checklist that claimed more than it delivered — showing a full list of supported components regardless of what the selected target could actually do, and showing the same list for a target where the honest answer was "nothing yet." A checklist whose job is to tell you what's supported, quietly not doing that, is worse than no checklist: it converts an unknown into a wrong belief.
Both of these are the same instinct as the deploy post. Anything that reports state should be reporting state it verified.
4. The fourth comparison site
One fix on a different project, and it's a good cautionary tale about "we already fixed that."
A legacy backend stores some records with inconsistent casing, and compares strings case-sensitively. The application lowercases input before querying. So a record stored in uppercase is simply invisible — the lookup finds nothing and the flow stops with a generic "unable to verify" message.
That was found and fixed weeks ago. Three comparison sites were converted to compare LOWER() on both sides. There were four.
The fourth sat on a path reached only from the very last step of the flow. So the symptom didn't disappear — it moved. The earlier steps now worked, the user got further, and it failed at submit instead of at the start. Which reads like a new, unrelated bug, and got triaged as one.
Two things I'm taking from it:
When you fix a class of defect, enumerate the class. Not "search for the failing call and fix it" — grep for every comparison against that column, list them, fix them together, then write down how many there were. Three of four is worse than zero of four, because it buys you a bug report that looks new.
A symptom moving later in a flow is evidence of a partial fix, not of a different bug. That's a genuinely useful triage heuristic and I don't apply it often enough.
5. Housekeeping
Rounding out the day: a reverse-proxy generator that now answers TLS for hostnames no vhost claims — previously the catch-all served its 404 as a binary download, which is a memorable way to discover that nginx guesses content type from the file extension and .html was missing. Native Redis provisioning, one instance per deployment, with per-OS profiles behind a contract so Debian and RHEL differences stay in one place each. A small swap file added during node bootstrap, because a two-gig box will OOM during composer install and blame something unrelated. And on the public marketing site, a couple of chores: redirects retired, sign-in pointed at the deployed console.
The through-line
Reading it back, today was almost entirely about systems that report something they haven't checked: a deploy that never pulled, a checklist claiming coverage it lacks, a badge counting elements it can't see, a fix that covered three of four sites, an enum showing its internal name.
None of them errored. That's the property they share, and it's the reason they all lasted weeks. Loud failures get fixed on the day they appear. The expensive ones are the ones that look fine.
Top comments (1)
Frontend traps that never error are the ones I would turn into explicit smoke tests. If tenant state, empty UI branches, or comparison logic can silently look fine, the test should assert the visible state, not just the absence of exceptions.