A deduplication table is a check bolted on afterwards, and some operations are naturally repeatable — which is better, because there is nothing to get wrong.
// not idempotent: running twice doubles the count
DB::table('stats')->increment('views');
// idempotent by construction: the same event sets the same value
DB::table('order_status')->updateOrInsert(
['order_id' => $e->orderId],
['status' => $e->status, 'updated_at' => $e->occurredAt]
);
// and the version check, for out-of-order delivery
where('version', '<', $e->version)
Designing the operation to be a set rather than an increment removes the need for any deduplication at all, and it is available more often than people expect. The version check handles the harder case: at-least-once delivery says nothing about order, so a stale event arriving after a newer one will overwrite it unless something compares. Together they cover both failure modes with no extra table.