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?
Before and After Registration Are Two Different States
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.testBefore 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
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
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
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
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 #42This 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.
$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
$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
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
$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
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
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
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 NULLor 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.



