Icanup
0% прочитано

Repeated Event Delivery Should Not Create a Second Notification

An idempotent listener alone does not guarantee duplicate-free notifications. You need a stable business event identity, atomic delivery reservation, and notification creation deduplication inside the delivery workflow itself.

3 листопада 2026 р. 10 хв читанняLaravel

In a notification system, it is easy to build a listener that looks idempotent while still creating duplicates.

An event can be delivered more than once: a scheduler runs again, a queue job retries, a worker fails after partial progress, or two workers observe the same business event concurrently.

If every execution unconditionally creates a new notification record, the user gets two identical notifications even though the business event happened only once.

Listener Idempotency Is Not Enough

A listener can validate model state, permissions, or timestamps and still fail to protect notification creation itself.

Two processes may both observe that the same post is published and the author is eligible. Both pass the eligibility check. If each then calls Notification::create(), a duplicate is created.

Business eligibility and delivery deduplication therefore need to be separate steps.

Use a Stable Business Event Identity

Knowing the notification type is not enough. Deduplication needs a stable identity for the concrete business event.

For the first actual publication of a scheduled post, event identity can be tied to that specific post:

Bounded event identity
$eventKey = sprintf(    'scheduled-post-published:%d',    $post->id,);

A repeated scheduler run or retry for the same post should produce the same key. A different post by the same author gets another key and should create another notification.

Why once per user Is the Wrong Boundary

One author can publish many posts. If deduplication is based only on user and template, the second real post may incorrectly produce no notification.

The boundary should describe the concrete business event: this post, this first scheduled publication, this recipient, and, when delivery is tracked separately, this channel.

exists Before insert Is Not Atomic

Unsafe check-then-insert
if (    ! NotificationDispatch::query()        ->where('event_key', $eventKey)        ->exists()) {    NotificationDispatch::query()->create([        'event_key' => $eventKey,    ]);}

This code contains a concurrency window. Two workers can both observe exists() === false and both proceed to insert.

The guarantee should rely on atomic reservation or database uniqueness rather than only on a preceding SELECT.

Reserve the Event Atomically Before Notification Creation

A practical pattern is to keep a delivery ledger and atomically reserve the event identity before notification creation.

The database constraint should match the real idempotency boundary. In a system tracking channels separately, that may include event key, recipient, and channel.

Atomic reservation through database uniqueness
$inserted = DB::table(    'notification_dispatches')->insertOrIgnore([    'event_key' => $eventKey,    'notifiable_type' => $user->getMorphClass(),    'notifiable_id' => $user->getKey(),    'channel' => 'database',    'created_at' => now(),    'updated_at' => now(),]); if ($inserted === 0) {    return DeliveryResult::alreadyReserved();}
The table and column names are illustrative. The important point is that reservation must be atomic and protected by a database constraint.

Reserved and Delivered Are Different States

The process can still fail after reservation. For example, the database notification may already exist while broadcast or mail is temporarily unavailable.

A ledger is therefore more useful with explicit outcomes such as reserved, delivered, failed, or skipped. A retry can finish incomplete delivery without creating a second database notification.

Create the Database Notification Before Broadcast

Realtime delivery should not be the only place where a notification exists. Create the durable database notification first, then use broadcast as a fast signal about state that already exists on the server.

A repeated broadcast then does not create another notification, and the client can reconcile with the server after reconnecting.

Retry the Attempt, Not the Business Event

A queue retry means "try to finish the work again." It does not mean the business event happened a second time.

With a stable event key, the retry finds the same reservation and either completes an unfinished channel or returns already delivered.

Schedulers Need the Same Guarantee

A scheduler often works as a reconciler: it repeatedly finds records that now satisfy the conditions. The same post can be eligible in several consecutive runs.

Deduplication should therefore be tied to the post event identity, not to one cron execution.

Recheck Eligibility Before Delivery

State can change between scheduler discovery and queue execution. The post can be deleted, publication time can move, or recipient permission can be revoked.

The delayed job should recheck eligibility and fail closed when the conditions no longer hold.

Dispatch After Commit

If a notification job is dispatched before a transaction commits, a worker may observe uncommitted state or state that will later be rolled back.

For notifications based on persisted business state, dispatch after commit creates a cleaner boundary.

Dispatch notification work after commit
DB::transaction(    function () use ($post): void {        $post->save();         DB::afterCommit(            function () use ($post): void {                DispatchNotification::dispatch(                    $post->id,                );            },        );    },);

Tests Should Deliberately Try to Create Duplicates

An idempotency test suite needs more than "notification created". It should cover:

  • the same event handled twice - one notification;
  • scheduler runs again - one notification;
  • two different posts by one author - two notifications;
  • two concurrent workers - one reservation;
  • retry after a transient failure - no second database notification;
  • permission revoked before delivery - zero notifications;
  • repeated broadcast - no duplicate in the database channel.
Regression test for repeated delivery
public function test_same_event_is_delivered_once(): void{    $event = $this->scheduledPublicationEvent();     $this->dispatcher->handle($event);    $this->dispatcher->handle($event);     $this->assertDatabaseCount(        'notifications',        1,    );}

Do Not Hide Duplicate Handling as Generic Success

It is useful to distinguish outcomes such as delivered, already_delivered, skipped, and failed.

During QA, this proves that the second execution was intentionally deduplicated rather than simply doing nothing by accident.

A repeated technical execution does not mean the business event happened again. The notification workflow must distinguish those two cases.

Summary

Reliable notification idempotency starts not with the listener, but with a correctly defined business event.

A stable event key, atomic reservation in a ledger, database uniqueness, eligibility rechecks, and explicit delivery outcomes make scheduler retries and concurrent execution safe.

One business event then creates one notification even when the technical process executes more than once.