Exactly-once delivery is not available from any broker worth using, so the handler has to tolerate seeing the same message twice.
public function handle(OrderPlaced $event): void
{
$claimed = ProcessedEvent::insertOrIgnore([
'event_id' => $event->id,
'handler' => self::class,
]);
if ($claimed === 0) {
return; // already handled
}
$this->doTheWork($event);
}
The claim and the work must be in one transaction, or a crash between them 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. Some effects are naturally idempotent and need none of this: setting a field to a value, deleting by id, upserting. Recognising those saves a table.