0% прочитано

Claiming Pre-Registration Data After a User Signs Up

A guest may create data before signing up, and after registration the system needs to safely claim that data for the new user. Email can bridge the two states, but the claim process must be safe against retries, races, and accidental ownership of unrelated records.

23 жовтня 2026 р. 11 хв читанняLaravel

A user does not always register before interacting with the system.

For example, a guest may create a support conversation through a public form, receive a reply, and create an account only later.

After registration, a practical question appears: how do we find data that already belongs to this person semantically but still has no user_id, and safely attach it to the new user?

I do not treat this as merely "finding old rows after registration." It is an ownership transition: guest-owned data becomes user-owned data only through an explicit and verified rule.

Before and After Registration Are Two Different States

Ownership moves from guest identity to user identity
Before signup conversation  user_id = NULL  guest_email = person@example.test  After signup user  id = 42  email = person@example.test conversation  user_id = 42  guest_email = person@example.test

Before registration, the system does not yet have a stable user_id, so another identifier is required.

In my case, email served as that bridge: it already existed on the guest conversation and later appeared on the registered user.

But email should not become a universal identity by accident. Here it is used only as the rule for one specific transition between two states.

Email Can Be the Bridge If Its Semantics Are Strong Enough

Email is not appropriate in every system.

I use it only when the guest flow already treats email as a contact identifier and registration prevents two active users from sharing the same email.

If email can be freely reassigned, reused across accounts, or does not provide enough ownership confidence, a stronger claim mechanism is needed.

I Explicitly Define Which Records Are Claimable

The claim scope contains both identity match and unclaimed state
PendingConversation::query()    ->whereNull('user_id')    ->where(        'guest_email',        $user->email,    );

The key condition here is not only the email match.

whereNull('user_id') means the claim is allowed only for data that does not already belong to a registered user.

This protects against a dangerous case where a new account accidentally steals an already claimed conversation.

The Registration Event Is a Natural Trigger Point

The listener only passes the new user into the business operation
final readonly class ClaimPendingData{    public function __construct(        private ClaimPendingDataService $service,    ) {    }     public function handle(        UserRegistered $event,    ): void {        $this->service->handle(            $event->user,        );    }}

Registration provides the moment when a stable user_id exists and guest identity can transition into user ownership.

I keep the listener thin. It should not know the SQL details of claiming, retry rules, or concurrency strategy.

Registration and Email Verification Should Not Be Coupled Automatically

This is related to the article about changing a lifecycle rule after registration.

If the current business rule says to claim after registration, an old email-verification guard should not silently restore the previous lifecycle.

Verification should remain a condition only when it is still an explicit current rule.

The Basic Claim Can Be Expressed as One Conditional Update

A second execution naturally becomes a no-op
final class ClaimPendingDataService{    public function handle(        User $user,    ): int {        return PendingConversation::query()            ->whereNull('user_id')            ->where(                'guest_email',                $user->email,            )            ->update([                'user_id' => $user->id,            ]);    }}

After the first successful execution, matching rows already have a user_id, so another execution does not touch them.

This is one reason I prefer to model claiming as a state transition instead of "create another relationship."

Running the Listener Again Must Be Safe

I cover this in more detail in the article about idempotent listeners and safe retries.

A queue retry, duplicated event delivery, or manual rerun should not create duplicate ownership.

If the target state is already reached, another claim should finish without making new changes.

One User May Claim Multiple Pre-Registration Records

The claim only affects unclaimed records
Before signup conversation #10  user_id = NULL  guest_email = person@example.test conversation #11  user_id = NULL  guest_email = person@example.test conversation #12  user_id = 7  guest_email = person@example.test  After signup as user #42

This is an important ownership-semantics test.

An email match does not authorize overwriting a record that already belongs to another registered user.

Concurrent Claiming Needs Separate Attention

Idempotency protects repeated sequential runs, but two workers may start at the same time.

If the claim operation is more complex than one atomic update, I use a transaction and lockForUpdate() around the critical section.

Locking is useful when the claim contains multiple steps
$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()

I Trigger Side Effects Only After a Real Claim

A retry without a state change does not create a new downstream effect
$claimedCount =    $claimService->handle(        $user,    ); if ($claimedCount === 0) {    return;} ClaimCompleted::dispatch(    $user->id,    $claimedCount,);

Otherwise the database state may be idempotent while a notification, analytics event, or email is still duplicated.

For me, "nothing changed" means downstream side effects should not run again without a separate reason.

Follow-Up Events Are Safer After Commit

Downstream flow should not run before persistence commits
DB::afterCommit(    static function () use (        $user,        $claimedIds,    ): void {        if ($claimedIds === []) {            return;        }         ClaimCompleted::dispatch(            $user->id,            $claimedIds,        );    });

Email Matching Should Use One Normalization Rule

If the guest form stores Person@Example.test while registration stores person@example.test, claiming should not depend on an accidental formatting difference.

Email normalization should therefore be defined before persistence and applied consistently in both flows.

It is important not to invent aggressive provider-specific normalization without a clear requirement. For most systems, consistent trimming and case handling aligned with the authentication model is enough.

Claim at Registration, Not on Every Email Change

If the user later changes their email, I do not want to automatically claim every old guest record matching the new address.

That is a different business operation with different security assumptions.

The registration claim has a clear temporal and semantic boundary: the account has just been created and pre-registration state is being attached to it.

Having No Pre-Registration Data Is Not an Error

A no-op is a valid result
$claimedCount =    $service->handle(        $user,    ); if ($claimedCount === 0) {    return;}

Most new users may have no guest conversations at all.

That means "nothing found" is a normal outcome for the claim service, not an exception.

I Test the Ownership Transition, Not Only the Listener

The primary test verifies correct ownership
public function test_registration_claims_matching_guest_data(): void{    $user = User::factory()->create([        'email' => 'person@example.test',    ]);     $conversation =        PendingConversation::factory()            ->create([                'user_id' => null,                'guest_email' =>                    'person@example.test',            ]);     $this->service->handle(        $user,

The Second Test Runs the Claim Twice

Running it again does not change an already correct state
public function test_claim_is_idempotent(): void{    $user = User::factory()->create([        'email' => 'person@example.test',    ]);     $conversation =        PendingConversation::factory()            ->create([                'user_id' => null,                'guest_email' =>                    'person@example.test',            ]);     $this->service->handle(        $user,

A Separate Test Prevents Stealing an Already Owned Record

An email match does not override established ownership
public function test_claim_does_not_reassign_owned_data(): void{    $existingUser =        User::factory()->create();     $newUser =        User::factory()->create([            'email' => 'person@example.test',        ]);     $conversation =        PendingConversation::factory()            ->create([                'user_id' =>                    $existingUser->id,                'guest_email' =>

My Checklist for Claiming Data After Registration

  • There is a clear canonical owner after registration.
  • Email is used only as a bridge for this specific transition.
  • The claim scope contains user_id IS NULL or an equivalent unclaimed-state condition.
  • Already owned records are never reassigned only because the email matches.
  • The listener runs at the correct lifecycle moment.
  • The service is safe to run repeatedly.
  • A concurrency strategy is defined for multi-step claiming.
  • Downstream side effects happen only after a real state change.
  • Email normalization is consistent across guest and registration flows.
  • Tests cover success, no-op, retry, and already-owned records.

Claiming After Registration Is a Controlled Ownership Transition

The main lesson for me is that pre-registration data should not simply be "found and attached" to a new user at any cost.

The system needs an explicit ownership-transition rule: which guest identifier is strong enough, which records are still claimable, when in the lifecycle the claim runs, and what happens on retry.

When email is used only as a bridge, claiming is limited to unclaimed records, and the service is idempotent, the registration flow remains simple and safe even when it runs again.

After registration, I am not creating ownership from nothing. I am safely transitioning existing guest data into user-owned state.