Recording that a message was handled and doing the work are two writes, and a crash between them is either a duplicate or a silent loss.
DB::transaction(function () use ($event) {
$claimed = ProcessedEvent::insertOrIgnore([
'handler' => self::class,
'event_id' => $event->id,
]);
if ($claimed === 0) {
return; // already handled
}
$this->apply($event);
});
Claiming outside the transaction and working inside it means a crash leaves the event marked as processed and not done, which is the failure that is silent and permanent. Scoping the claim to the handler class matters when several handlers consume the same event, and using the broker’s message id rather than a payload hash is what survives a redelivery that is not byte-identical.