After registration, I have a business operation that claims data created before the user signed up.
At first glance, the listener is simple: receive the event and call a service.
But as soon as the listener is queued or enters a flow with retries, another question appears: what happens if the same handler runs again?
For me, the correct answer is that the second execution should not corrupt state, create duplicates, or repeat side effects that already happened.
A Listener Running Twice Is a Normal Scenario, Not an Exotic Failure
event dispatched | vlistener starts | +-- database change succeeds | +-- process crashes before completion is acknowledged | vqueue retries listenerA retry may happen because of a timeout, an exception after partial execution, a worker restart, or duplicate message delivery.
Separately from the queue, the same business event may be dispatched again because of a bug, replay, or another trigger path.
That is why I do not build correctness on the assumption that "this listener will run exactly once."
A Retry and a New Business Operation Are Not the Same Thing
In the article about the difference between a retry and a new operation I discuss this distinction separately.
A retry means the system is trying to complete the same logical operation. A new operation means the user or system created a new intent.
For a listener that runs after registration, another execution is usually a retry of the same operation, so the final result should stay the same.
An Unsafe Listener Depends on Call History
final class ClaimPendingData{ public function handle( UserRegistered $event, ): void { PendingClaim::query()->create([ 'user_id' => $event->user->id, 'email' => $event->user->email, ]); $this->mailer->sendClaimedNotice( $event->user, ); }}This code only works under an exactly-once execution assumption.
The first retry may already create a duplicate row and send the email again.
The problem is not the queue itself. The business operation has no definition of "already completed."
I Prefer to Model a Listener as a Transition to the Required State
Before registration pending recorduser_id = NULLemail = user@example.test After successful claim pending recorduser_id = 42email = user@example.test Retry This way of thinking is simpler for me than asking "has the listener run before?"
I do not use listener execution history as the primary criterion. I inspect canonical state and ask whether the required change is still needed.
A Conditional Update Is Naturally Idempotent
$updated = PendingConversation::query() ->whereNull('user_id') ->where( 'guest_email', $user->email, ) ->update([ 'user_id' => $user->id, ]);In this example, whereNull('user_id') describes the eligible state and also makes repeated execution safe.
After the first successful claim, the row no longer matches the condition. A retry does not create new state or modify an already claimed record.
The Number of Changed Rows Helps Control Side Effects
$claimedCount = $service->claimFor( $user,); if ($claimedCount === 0) { return;} $this->afterClaim->dispatch( $user->id, $claimedCount,);This distinction matters: an idempotent database update does not automatically make the whole listener idempotent.
If a notification, analytics event, or email is always sent after a no-op update, the retry is still externally visible.
That is why I connect further side effects to an actual state change.
I Keep the Critical State Change in One Short Transaction
$claimedIds = DB::transaction( function () use ($user): array { $ids = PendingConversation::query() ->whereNull('user_id') ->where( 'guest_email', $user->email, ) ->lockForUpdate() ->pluck('id'); if ($ids->isEmpty()) { return []; } PendingConversation::query()Idempotency and concurrency are different problems, but they often appear together.
A sequential retry may be safe while two workers that see the same unclaimed row at the same time still create a race condition.
When the operation requires it, I use a transaction and locking around the critical state change, not around external calls.
External Side Effects Are Safer After Commit
DB::afterCommit( function () use ( $user, $claimedIds, ): void { if ($claimedIds === []) { return; } ClaimedDataChanged::dispatch( $user->id, $claimedIds, ); });This does not solve every distributed consistency problem, but it removes one dangerous case: the side effect happens and the database transaction later rolls back.
The follow-up handler should still be retry-safe because after-commit dispatch does not turn the downstream system into exactly-once delivery.
If the Listener Creates a Row, Back Idempotency with a Database Constraint
Schema::table( 'registration_claims', function (Blueprint $table): void { $table ->unique([ 'user_id', 'claim_type', ]); });firstOrCreate() or a prior exists() check communicates intent, but under concurrency an application-level check alone may not be enough.
If duplicate state is truly invalid in the business model, a unique constraint provides the final line of defense.
An Idempotency Key Should Represent the Logical Operation, Not a Random Delivery
If every retry receives a new random identifier and that value is used as the deduplication key, the system will not recognize the same logical operation.
The key should remain stable for one business intent, such as user plus operation type or another natural or explicit operation identifier.
But when the state transition is naturally idempotent, a separate idempotency key may not be necessary at all.
The Correct Lifecycle Event and an Idempotent Listener Solve Different Problems
In the article about a stale lifecycle rule surviving an event change the problem is different: the event changed, but an old guard still enforced the previous semantics.
This article addresses the next layer. Even when the lifecycle is correct, the handler still needs to be safe when executed again.
The correct event answers "when should this run?" Idempotency answers "what happens if it runs again?"
I Always Add a Test for the Second Execution
public function test_listener_is_safe_when_run_twice(): void{ $user = User::factory()->create(); $pending = PendingConversation::factory() ->create([ 'user_id' => null, 'guest_email' => $user->email, ]); $event = new UserRegistered( $user, ); $this->listener->handle( $event,I Separately Test That a Retry Does Not Repeat the Side Effect
Event::fake([ ClaimedDataChanged::class,]); $this->listener->handle( $event,); $this->listener->handle( $event,); Event::assertDispatchedTimes( ClaimedDataChanged::class, 1,);A Useful Test Is Failure After the State Change Followed by a Retry
Attempt 1 claim database rows | vsuccess | vlater step throws Attempt 2 listener runs again | vA happy-path test does not expose retry problems.
I want to see behavior after partial success because that is the dangerous case: the system already changed something while the infrastructure still considers the operation incomplete.
I Keep the Listener Thin and Put Idempotency in the Service
final readonly class ClaimPendingData{ public function __construct( private ClaimPendingDataService $service, ) { } public function handle( UserRegistered $event, ): void { $this->service->handle( $event->user, ); }}This matters because the same operation may later be called from somewhere other than the event listener.
If idempotency lives in the service, manual retry, a command, a backfill, and another trigger path all inherit the same safety.
My Checklist for a Queued Listener
- What happens if the handler runs twice?
- Does the operation define "already completed" using canonical state?
- Does the second execution become a no-op instead of a duplicate write?
- Does a database constraint prevent duplicate rows?
- Is concurrent execution by two workers safe?
- Do side effects run only after a real state change?
- Are external effects kept out of the transaction until commit?
- Is there a test that calls the handler twice?
- Is there a test for partial failure followed by retry?
I Do Not Try to Guarantee One Call. I Make Repeated Calls Safe.
This is the most useful shift in how I think about event listeners.
Instead of designing around the assumption that "this event will arrive once," I design the state transition so another execution does not change an already correct result.
A retry-safe listener relies on canonical state, becomes a no-op after the target state is reached, prevents duplicates at the database level, and does not repeat side effects without a new state change.
A reliable listener is not one that never runs twice. It is one for which running twice is no longer a problem.



