Skip to content
TutorialLaravel

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

D

Dinesh Wijethunga

June 28, 2026Updated Aug 2, 2026 7 min readIntermediate 318 views
ShareX / TwitterLinkedIn
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 cartUser's account cartExpected outcome
1Has itemsNo existing cartAssociate guest cart with user ID
2Has itemsHas an existing cartMerge guest items in — increment qty for duplicates
3EmptyHas an existing cartRestore 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:

StrategyLogicBest for
Increment (default)guest qty + user qtyMost stores — customers expect totals to add
Replacesession item winsHigh-value deliberate purchases (furniture, software licences)
Keep highermax(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

D
Dinesh Wijethunga

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.

Comments(0)

Guest comments are held for moderation.

You might also like