Merging Guest and Authenticated User Carts in Laravel: Session-to-Database Sync on Login

TL;DR: Listen to Laravel's built-in Login event. When it fires, call syncGuestCart($userId). That method finds the user's existing database cart, merges the session cart items into it (incrementing quantities for duplicate products), associates the merged cart with the user, and reloads the session. The guest token is retired and the user's cart token takes its place.
This post is part of a series. The persistence layer is covered in PHP Shopping Cart Session Management Best Practices. The schema behind this is in Shopping Cart Database Schema Design.
The Three Scenarios
| # | Guest cart | User's account cart | Expected outcome |
|---|---|---|---|
| 1 | Has items | No existing cart | Associate guest cart with user ID |
| 2 | Has items | Has an existing cart | Merge guest items in — increment qty for duplicates |
| 3 | Empty | Has an existing cart | Restore user's cart into the session |
All three need to work silently and correctly on every login method: web form, remember-me cookie, OAuth callback, Sanctum token.
The syncGuestCart Method
// Modules/Cart/Classes/Cart.php
public function syncGuestCart(int $userId): self
{
$sessionItems = $this->readSession(); // current session cart items
// Find an existing, active cart for this user
$userCart = CartModel::with('items')
->where('user_id', $userId)
->where('instance', $this->currentInstance())
->active()
->first();
if ($userCart) {
// Scenario 2: merge session items into the user's existing cart
foreach ($sessionItems as $item) {
$existing = $userCart->items()
->where('row_id', $item->rowId)
->first();
if ($existing) {
// Same product + options — increment quantity
$newQty = $existing->qty + $item->qty;
$existing->update([
'qty' => $newQty,
'subtotal' => $newQty * $existing->price,
'total' => ($newQty * $existing->price) + $existing->tax_amount - $existing->discount_amount,
]);
} else {
// Different product — add as new line item
$userCart->items()->create([
'row_id' => $item->rowId,
'product_id' => $item->id,
'name' => $item->name,
'qty' => $item->qty,
'price' => $item->price,
'options' => json_encode($item->options),
'subtotal' => $item->qty * $item->price,
'total' => $item->qty * $item->price,
'user_id' => $userId,
]);
}
}
$this->refreshTotals($userCart);
$this->rotateToken($userCart->identifier);
$this->restoreFromDatabase($userCart->identifier, false);
} elseif (!$sessionItems->isEmpty()) {
// Scenario 1: no existing user cart — associate current session cart
$this->flushToDatabase($this->identifier);
CartModel::where('identifier', $this->identifier)->update(['user_id' => $userId]);
} else {
// Scenario 3: no session items — restore the user's most recent cart
$latestCart = CartModel::where('user_id', $userId)
->where('instance', $this->currentInstance())
->active()
->latest()
->first();
if ($latestCart) {
$this->rotateToken($latestCart->identifier);
$this->restoreFromDatabase($latestCart->identifier, false);
}
}
return $this;
}
protected function refreshTotals(CartModel $cart): void
{
$cart->load('items');
$subtotal = $cart->items->sum(fn($i) => $i->qty * $i->price);
$tax = $cart->items->sum('tax_amount');
$discount = $cart->items->sum('discount_amount');
$cart->update([
'subtotal' => $subtotal,
'tax_amount' => $tax,
'discount_amount' => $discount,
'total' => $subtotal + $tax + $cart->shipping_amount - $discount,
]);
}
protected function rotateToken(string $identifier): void
{
$this->identifier = $identifier;
$this->session->put($this->instance . '_token', $identifier);
}The Login Event Listener
// Modules/Cart/Listeners/CartSyncOnLogin.php
namespace Modules\Cart\Listeners;
use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Queue\ShouldQueue;
class CartSyncOnLogin implements ShouldQueue
{
public string $queue = 'default';
public function handle(Login $event): void
{
// Skip API guard logins — mobile apps use the /api/cart/sync endpoint instead
if ($event->guard !== 'web') {
return;
}
app('cart')->syncGuestCart($event->user->id);
}
}Register it in your EventServiceProvider or AppServiceProvider:
// Modules/Cart/Providers/EventServiceProvider.php
use Illuminate\Auth\Events\Login;
use Modules\Cart\Listeners\CartSyncOnLogin;
protected $listen = [
Login::class => [CartSyncOnLogin::class],
];Or with Laravel 11+ auto-discovery in AppServiceProvider::boot():
Event::listen(Login::class, CartSyncOnLogin::class);API Merge Endpoint for Mobile / SPA
Mobile apps authenticate via Sanctum, not the web guard. Give them a dedicated endpoint:
// Modules/Cart/Http/Controllers/Api/CartApiController.php
public function syncAfterLogin(): JsonResponse
{
if (!Auth::check()) {
return response()->json(['success' => false, 'message' => 'Unauthenticated.'], 401);
}
app('apicart')->syncGuestCart(Auth::id());
return response()->json([
'success' => true,
'message' => 'Cart synced with your account.',
'identifier' => app('apicart')->getIdentifier(),
'cart' => app('apicart')->apiContent(),
]);
}The mobile app calls this endpoint immediately after a successful login and stores the new identifier from the response for all subsequent cart requests.
Conflict Resolution Options
When both carts contain the same product, you have three strategies:
| Strategy | Logic | Best for |
|---|---|---|
| Increment (default) | guest qty + user qty | Most stores — customers expect totals to add |
| Replace | session item wins | High-value deliberate purchases (furniture, software licences) |
| Keep higher | max(guest qty, user qty) | Long-lived carts where qty is a re-selection |
To use "keep higher", change the merge line:
$existing->update(['qty' => max($existing->qty, $item->qty)]);Edge Cases Handled
- Remember-me cookie fires Login event on every page load — the merge is idempotent. If the session cart is empty and the user cart is already loaded, nothing changes.
- Multiple tabs open simultaneously — the
unique(['identifier', 'instance'])constraint prevents duplicate cart creation from concurrent requests. - Social login (Socialite OAuth) — fire the event manually after your OAuth callback creates the session:
event(new Login('web', $user, false)); - User logs in on a second device — the second device's session will have a different guest identifier. Both login events fire
syncGuestCart, but since there's now a user cart on record (from the first device), subsequent syncs simply merge into it.
Feature Tests
// tests/Feature/CartSyncTest.php
class CartSyncTest extends TestCase
{
use RefreshDatabase;
public function test_guest_items_merge_into_existing_user_cart_on_login(): void
{
// Guest cart: product 1, qty 2
$guestCart = Cart::factory()->create(['user_id' => null]);
CartItem::factory()->create(['cart_id' => $guestCart->id, 'product_id' => 1, 'qty' => 2]);
session(['cart.default_token' => $guestCart->identifier]);
// User already has a cart: product 1, qty 1
$user = User::factory()->create();
$userCart = Cart::factory()->create(['user_id' => $user->id]);
CartItem::factory()->create(['cart_id' => $userCart->id, 'product_id' => 1, 'qty' => 1]);
event(new Login('web', $user, false));
// product 1 should now have qty 3 (2 + 1)
$this->assertDatabaseHas('cart_items', [
'cart_id' => $userCart->id,
'product_id' => 1,
'qty' => 3,
]);
}
public function test_guest_cart_is_associated_when_user_has_no_existing_cart(): void
{
$guestCart = Cart::factory()->create(['user_id' => null]);
session(['cart.default_token' => $guestCart->identifier]);
$user = User::factory()->create();
event(new Login('web', $user, false));
$this->assertDatabaseHas('carts', [
'identifier' => $guestCart->identifier,
'user_id' => $user->id,
]);
}
public function test_user_cart_is_restored_when_session_is_empty(): void
{
$user = User::factory()->create();
$userCart = Cart::factory()->create(['user_id' => $user->id]);
CartItem::factory()->count(3)->create(['cart_id' => $userCart->id]);
event(new Login('web', $user, false));
$this->assertEquals(
$userCart->identifier,
session('cart.default_token')
);
}
}Continue the Series
- PHP Shopping Cart Session Management Best Practices — the foundation
- Shopping Cart Database Schema Design — all four tables explained
- Real-Time Shopping Cart Count with Laravel Reverb — after merging, update the badge instantly across all tabs
- Laravel Cart Abandonment Recovery — once carts are user-associated, send timed recovery emails
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.



