Tutorial

PHP Shopping Cart Session Management Best Practices: Hybrid Session + Database Persistence in Laravel

D

Dinesh Wijethunga

June 16, 2026 8 min readIntermediate 20 views
πŸŽ“

PHP Shopping Cart Session Management Best Practices: Hybrid Session + Database Persistence in Laravel

TL;DR: Store cart data in Laravel's session for fast reads. Persist it to a carts + cart_items database table on every mutation. Restore from the database when the session expires. This is the hybrid approach that makes production e-commerce carts reliable without making them slow.

Why Session-Only Carts Break in Production

The approach most tutorials teach β€” session(['cart' => $items]) β€” fails in three real-world scenarios every serious shop will hit:

  • Session expiry β€” Laravel sessions default to 120 minutes. A customer who leaves and comes back finds an empty cart.
  • Cross-device shopping β€” A user adds items on mobile, then checks out on desktop. Sessions are browser-bound.
  • Abandoned cart recovery β€” You cannot email customers about items that only ever existed in an ephemeral session.

The solution is a hybrid model: session as a fast in-memory cache, database as the source of truth.

The Database Schema

Two tables handle everything: carts (one row per cart) and cart_items (one row per line item).

Schema::create('carts', function (Blueprint $table) {
    $table->id();
    $table->string('identifier')->index();       // stable token β€” NOT the session ID
    $table->string('instance')->default('cart'); // reuse for 'wishlist', 'compare', etc.
    $table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
    $table->decimal('subtotal', 10, 2)->default(0);
    $table->decimal('discount_amount', 10, 2)->default(0);
    $table->decimal('tax_amount', 10, 2)->default(0);
    $table->decimal('shipping_amount', 10, 2)->default(0);
    $table->decimal('total', 10, 2)->default(0);
    $table->string('coupon_code')->nullable();
    $table->string('currency', 3)->default('USD');
    $table->string('status')->default('active');
    $table->foreignId('order_id')->nullable();
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();

    $table->unique(['identifier', 'instance']);
    $table->index(['user_id', 'instance']);
    $table->index('expires_at');
});
Schema::create('cart_items', function (Blueprint $table) {
    $table->id();
    $table->foreignId('cart_id')->constrained()->onDelete('cascade');
    $table->string('row_id');               // md5(product_id + options)
    $table->foreignId('product_id')->constrained()->onDelete('cascade');
    $table->string('name')->nullable();
    $table->integer('qty');
    $table->decimal('price', 10, 2)->default(0);
    $table->decimal('tax_rate', 5, 2)->default(0);
    $table->decimal('tax_amount', 10, 2)->default(0);
    $table->decimal('discount_amount', 10, 2)->default(0);
    $table->decimal('subtotal', 10, 2)->default(0);
    $table->decimal('total', 10, 2)->default(0);
    $table->json('options')->nullable();
    $table->json('selected')->nullable();
    $table->json('product_attributes')->nullable(); // price snapshot at add-time
    $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
    $table->timestamps();

    $table->unique(['cart_id', 'row_id']);
    $table->index(['cart_id', 'product_id']);
});

Key design decisions:

  • identifier is a stable token you generate once β€” never use session()->getId() (explained below)
  • instance lets the same Cart class power wishlists and compare lists alongside the main cart
  • user_id is nullable β€” guest carts are first-class citizens from the start
  • product_attributes snapshots the product price at add-time so price changes mid-session don't silently alter the cart total

The Most Critical Mistake: Using session()->getId() as the Cart Key

Session IDs rotate on login (Auth::login() calls Session::regenerate() for CSRF safety). If your cart identifier equals the session ID, every customer who logs in mid-shop silently loses their cart.

The fix is to generate a custom token once and store it under a separate session key:

protected function bootstrapCartToken(): void
{
    $key = $this->instance . '_token';

    if (!$this->session->has($key)) {
        $this->identifier = 'web_' . md5(uniqid('cart_', true));
        $this->session->put($key, $this->identifier);
    } else {
        $this->identifier = $this->session->get($key);
    }
}

This key is independent of the session ID, so it survives Session::regenerate() and remember-me logins.

Service Provider: Bind as a Singleton

// Modules/Cart/Providers/CartServiceProvider.php
public function register(): void
{
    $this->app->singleton('cart', function ($app) {
        return new Cart(
            $app->make(SessionManager::class),
            $app->make(Dispatcher::class)
        );
    });

    // For API / mobile clients β€” reads identifier from X-Cart-Identifier header
    $this->app->singleton('apicart', function () {
        return new ApiCart(request()->header('X-Cart-Identifier'));
    });
}

Adding Items: Session First

public function add($id, $name, $qty, $price, array $options = []): object
{
    $rowId = $this->buildRowKey($id, $options); // md5($id . serialize($options))

    if ($this->hasRowId($rowId)) {
        return $this->incrementQty($rowId, $this->get($rowId)->qty + $qty);
    }

    $item = (object) [
        'rowId'   => $rowId,
        'id'      => $id,
        'name'    => $name,
        'qty'     => $qty,
        'price'   => $price,
        'total'   => $qty * $price,
        'options' => $options,
    ];

    $content = $this->readSession();
    $content->put($rowId, $item);
    $this->session->put($this->instance, $content);

    $this->events->dispatch('cart.added', $item);

    return $item;
}

protected function buildRowKey($id, array $options): string
{
    ksort($options);
    return md5($id . serialize($options));
}

Persisting to the Database

Call $cart->flushToDatabase() before any checkout redirect. The method detects whether a cart already exists (and updates it) or needs to be created fresh. It also handles the edge case where the existing cart was already converted to an order:

public function flushToDatabase(?string $identifier = null, bool $force = false): ?CartModel
{
    $identifier = $identifier ?: $this->identifier;
    $content    = $this->readSession();

    if ($content->isEmpty()) return null;

    if (!$force && $this->cartIsPersisted($identifier)) {
        $cart = $this->loadPersistedCart($identifier);

        if ($this->cartHasOrder($cart)) {
            $newToken = 'web_' . md5(uniqid('cart_', true));
            $this->rotateToken($newToken);
            return $this->persistNewCart($newToken, $content);
        }

        return $this->syncPersistedCart($cart, $content);
    }

    return $this->persistNewCart($identifier, $content);
}

protected function cartIsPersisted(?string $identifier = null): bool
{
    return CartModel::where('identifier', $identifier ?: $this->identifier)
        ->where('instance', $this->currentInstance())
        ->exists();
}

protected function loadPersistedCart(?string $identifier = null): ?CartModel
{
    return CartModel::with('items')
        ->where('identifier', $identifier ?: $this->identifier)
        ->where('instance', $this->currentInstance())
        ->first();
}

protected function cartHasOrder(CartModel $cart): bool
{
    return $cart->order_id !== null
        || in_array($cart->status, ['completed', 'ordered']);
}

protected function rotateToken(string $identifier): void
{
    $this->identifier = $identifier;
    $this->session->put($this->instance . '_token', $identifier);
}

Inside persistNewCart, each item is written as a CartItem row with pre-computed financials:

CartItem::create([
    'cart_id'         => $cart->id,
    'row_id'          => $rowId,
    'product_id'      => $item->id,
    'name'            => $item->name,
    'qty'             => $qty,
    'price'           => $price,
    'options'         => $item->options,
    'selected'        => $selected,
    'subtotal'        => $qty * $price,
    'tax_rate'        => $item->taxRate ?? 0,
    'tax_amount'      => ($qty * $price) * (($item->taxRate ?? 0) / 100),
    'discount_amount' => $item->discountAmount ?? 0,
    'total'           => ($qty * $price) + $taxAmount - $discountAmount,
    'user_id'         => Auth::id(),
]);

Restoring the Cart from Database

When a returning visitor lands on the shop and their session is empty, restore from the database:

public function restoreFromDatabase(?string $identifier = null, bool $merge = false): self
{
    $identifier = $identifier ?: $this->identifier;

    $cart = CartModel::with('items')
        ->where('identifier', $identifier)
        ->where('instance', $this->currentInstance())
        ->first();

    if (!$cart) return $this;

    $stored = collect();
    foreach ($cart->items as $dbItem) {
        $stored->put($dbItem->row_id, $this->hydrateItem($dbItem));
    }

    if ($merge) {
        $current = $this->readSession();
        $stored->each(function ($item) use ($current) {
            $current->has($item->rowId)
                ? $this->incrementQty($item->rowId, $current->get($item->rowId)->qty + $item->qty)
                : $current->put($item->rowId, $item);
        });
        $this->session->put($this->instance, $current);
    } else {
        $this->session->put($this->instance, $stored);
    }

    $this->events->dispatch('cart.restored');
    return $this;
}

protected function hydrateItem(CartItem $dbItem): object
{
    return (object) [
        'rowId'   => $dbItem->row_id,
        'id'      => $dbItem->product_id,
        'name'    => $dbItem->name,
        'qty'     => $dbItem->qty,
        'price'   => $dbItem->price,
        'options' => $dbItem->options ?? [],
        'total'   => $dbItem->qty * $dbItem->price,
    ];
}

Trigger restore in your main web middleware or AppServiceProvider:

if (auth()->check()) {
    app('cart')->restoreFromDatabase(null, true);
}

API Middleware: X-Cart-Identifier Header

// Modules/Cart/Http/Middleware/IdentifyCart.php
public function handle(Request $request, Closure $next): Response
{
    $cart     = app('apicart');
    $response = $next($request);

    return $response->header('X-Cart-Identifier', $cart->getIdentifier());
}

Mobile and SPA clients store this header value in localStorage and send it on every request. This is the stateless equivalent of a session cookie.

Hosting tip: Running this in production? Hostinger VPS is a solid option for PHP apps β€” check the full setup guide at How to Set Up a VPS Server with PHP, Nginx and MySQL.

Cart Expiry and Cleanup

// routes/console.php (Laravel 11+)
Schedule::call(function () {
    CartModel::where('expires_at', '<=', now())->each(function ($cart) {
        $cart->items()->delete();
        $cart->delete();
    });
})->daily()->name('cart:cleanup');

Best Practices Quick Reference

PracticeWhy it matters
Custom token, not session()->getId()Session IDs rotate on login
unique(['identifier', 'instance']) constraintPrevents race-condition duplicate rows
Persist totals to DB on every mutationCheckout reads DB, not session
Set expires_at 30 days from creationEnables cleanup and cart-age signals for recovery emails
Merge on login, not on every requestOne-time sync via Login event listener
Snapshot price in cart_items.priceProduct price changes must not alter in-progress carts

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.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

PHP Shopping Cart Session Management Best Practices: Hybrid Session + Database Persistence in Laravel | DineshStack