TL;DR
- Bulk actions over a mixed selection always skip rows. A count of skips is not a result — the operator needs to know which reason applied to how many, because "not permitted" and "already gone" call for completely different next moves.
- Count skip reasons individually, not in total. One counter per reason is about ten lines of code and it's the entire difference between an actionable toast and a shrug.
- A run that changed nothing is a warning, even when every single skip was legitimate. The operator asked for something and got no change; a green toast there reads as "done".
- Selection is keyed on the public UUID, never the auto-increment id — it's a client-writable property.
- The nastiest bug of the day wasn't in any of that. It was a toast that said "3 deployments stopping" while all three jobs sat unclaimed in the queue table, because the job was dispatched to a queue no worker was listening on.
Most of yesterday went into one feature that sounds boring on the changelog line: act on many rows at once. Select some rows in a listing, hit Stop, done.
It is boring, right up until you notice that a bulk action is the one place in a CRUD app where the operation is guaranteed to be partially refused. Single-row actions are binary — the button is either there or it isn't, and if you click it and it fails you get an error about that one thing. Bulk is different. Ten rows go in, the system has opinions about each of them individually, and something has to come back out that a human can act on.
The lie is "3 skipped"
Here's the shape I started with, which is the shape almost every bulk implementation ships with:
$succeeded = 0;
$skipped = 0;
foreach ($this->resolveSelected() as $row) {
if (! $this->canAct($row)) {
$skipped++;
continue;
}
$this->act($row);
$succeeded++;
}
$this->toast("{$succeeded} stopped, {$skipped} skipped.");
"7 stopped, 3 skipped." Looks like a result. It isn't.
Put yourself behind that message. Three rows didn't move. Is that because:
- your role can't perform this action on them,
- they were already stopped,
- they belong to another team and never resolved at all,
- or they're in a state where this action doesn't apply?
Each of those has a different next action. The first is "ask someone with the right role". The second is "nothing to do, carry on". The third is "you're looking at the wrong page". The fourth is "wait, then retry". A single integer collapses all four into "hmm", and "hmm" reliably becomes "click it again and see".
So the counter grew a key:
final class BulkActionResult
{
/** @var array<string, int> reason => count */
private array $skipped = [];
private int $succeeded = 0;
private int $failed = 0;
public function skip(string $reason): self
{
$this->skipped[$reason] = ($this->skipped[$reason] ?? 0) + 1;
return $this;
}
public function succeeded(): self
{
$this->succeeded++;
return $this;
}
public function didNothing(): bool
{
return $this->succeeded === 0;
}
}
That's it. That's the whole idea. A map instead of an integer, so the toast can say "2 not permitted, 1 already stopped" instead of "3 skipped", and the difference between refused and nothing to do stops being invisible.
Small object, and it earns its keep by being the only place that knows how to phrase the outcome:
public function toastMessage(string $summary): string
{
$parts = [$summary];
foreach ($this->skipped as $reason => $count) {
$parts[] = __(':count skipped — :reason.', ['count' => $count, 'reason' => $reason]);
}
if ($this->failed > 0) {
$parts[] = __(':count failed.', ['count' => $this->failed]);
}
return implode(' ', $parts);
}
Green is a claim
The severity of the toast turned out to need a rule of its own, and it's the bit I'd have got wrong on autopilot:
public function toastType(): string
{
if ($this->failed > 0) {
return $this->succeeded > 0 ? 'warning' : 'error';
}
return $this->didNothing() ? 'warning' : 'success';
}
Read the last line carefully. A run where every row was legitimately skipped is still a warning. No errors occurred. Nothing is broken. The system behaved exactly as designed. And it is still not a success, because success is green, green means "your thing happened", and the operator's thing did not happen.
This is the same class of bug as a stub that returns an empty array: technically correct, and it teaches the human the wrong thing. Colour is a claim about what changed. Don't make a claim you can't back.
Two rules the selection itself has to encode
Bulk selection lives in a trait shared by every listing, and two rules went in there because both are quietly easy to lose:
Selection is keyed on the public UUID, never the internal id. These are Livewire properties — client-writable, by definition. Resolving a bulk mutation from an auto-increment key that a browser handed you is guessable-id enumeration with a helpful loop around it. And even with UUIDs, the resolution query has to be tenant-scoped, so a UUID from another organisation simply doesn't resolve. The policy check is the second gate. It is not the first one.
Selection is current-page-only, and any filter, search or page change clears it. A selection that survives a filter change means the operator confirms "delete 12" while looking at a list of 4. Whatever they're about to destroy, they can no longer see it. Same reasoning for always clearing after the run — including when nothing succeeded — because the rows behind that selection were just re-evaluated, and leaving the boxes ticked invites a second click on a set nobody has re-read.
protected function reportBulkResult(BulkActionResult $result, string $summary): void
{
$this->clearSelection();
$this->dispatch(
'toast',
type: $result->toastType(),
message: $result->toastMessage($summary),
);
}
Check order is a UX decision, not a style one
In the loop, I check the row's status before the policy, and that ordering is deliberate.
The destroy path also refuses an already-destroyed row. So if the policy check came first, an operator with a weaker role selecting a row that's already gone would be told "not permitted" — which sends them off to ask for elevated access to do something that didn't need doing at all.
The usual argument for checking authorization first is to avoid leaking state to someone who shouldn't see it. That doesn't apply here: the row's status is rendered in the list they're looking at. Nothing leaks, and the more specific reason is the useful one.
Worth generalising: when two guards both refuse, the one whose reason is more actionable should run first — unless the other one's reason is the thing you're protecting.
The one that actually bit me
None of the above was the real bug of the day.
The bulk lifecycle jobs were dispatched with ->onConnection('database')->onQueue('deployments') — a queue name that is registered, that appears in the supervisor list, that looks entirely correct in review. The toast came back cheerfully: "3 deployments stopping."
Nothing was stopping. All three jobs were sitting unclaimed in the jobs table.
Two layers to it, and both are worth having in your head:
-
A queue name registered for Horizon is a Redis queue. A job forced onto the
databaseconnection never reaches it — the supervisor is watching a different connection entirely. The name being present in config is not evidence that anything is listening. -
The local dev listener had no
--queueflag, so it consumeddefaultand nothing else. A named queue on the right connection with no worker on that name is indistinguishable, from the browser, from a queue that's working fine.
The fix in the code was one line — match the dispatch of the job that already got this right, which uses that connection with no named queue at all. The fix that matters more was in the message:
"3 deployments queued to stop — needs a queue worker on the database connection."
A message that claims the work is happening is what makes the failure silent. "Stopping" is a claim about the world. "Queued to stop" is a claim about what this request did, which is the only thing the request can honestly report. The extra clause costs nothing and turns a mystery into a checklist item.
And because a message is now load-bearing, the test asserts the message:
it('skips a row whose status forbids the action and says which requirement failed', function () {
Queue::fake();
$active = deploymentAt(DeploymentStatus::Active);
$stopped = deploymentAt(DeploymentStatus::Stopped);
Livewire::test(Index::class)
->set('selected', [$active->uuid, $stopped->uuid])
->call('bulkStop')
->assertDispatched(
'toast',
type: 'success',
message: '1 deployment queued to stop — needs a queue worker on the database connection. '
. '1 skipped — only an active or degraded deployment can be stopped.',
);
Queue::assertPushed(DeploymentLifecycleJob::class, 1);
});
Asserting on a full user-facing string feels brittle, and it is a little. I'd normally push back on it. Here it's the point: the string is the feature. If someone later "tidies" it back to "3 deployments stopping", I want a red test, not a shrug.
The connection and the null queue are pinned by that test too — because the trap isn't the kind of thing you rediscover, it's the kind of thing you re-introduce.
Takeaway
A bulk action's real output isn't the mutation. It's the sentence you hand back to the person who clicked it. Budget for that sentence like it's a feature:
- one counter per skip reason, not one counter,
- warning when nothing changed, even if nothing was wrong,
- and never phrase a queued job as though the work already happened.
Next up is making the destroy confirmation carry the same honesty — it currently asks you to retype the count, which is good, but the count it shows is the selected count, not the count that will actually be acted on. Those two numbers are allowed to differ, and the moment they do, the confirmation is confirming the wrong thing.
Top comments (0)