PHP Shopping Cart Session Management Best Practices: Hybrid Session + Database Persistence in Laravel
Dinesh Wijethunga
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:
identifieris a stable token you generate once β never usesession()->getId()(explained below)instancelets the same Cart class power wishlists and compare lists alongside the main cartuser_idis nullable β guest carts are first-class citizens from the startproduct_attributessnapshots 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
| Practice | Why it matters |
|---|---|
Custom token, not session()->getId() | Session IDs rotate on login |
unique(['identifier', 'instance']) constraint | Prevents race-condition duplicate rows |
| Persist totals to DB on every mutation | Checkout reads DB, not session |
Set expires_at 30 days from creation | Enables cleanup and cart-age signals for recovery emails |
| Merge on login, not on every request | One-time sync via Login event listener |
Snapshot price in cart_items.price | Product price changes must not alter in-progress carts |
Continue the Series
- Next: Real-Time Shopping Cart Count with Laravel Reverb β broadcast instant badge updates across all tabs
- Shopping Cart Database Schema Design for Laravel β deep dive into all four tables
- Merging Guest and User Carts on Login β so customers never lose items when they log in
- Laravel Cart Abandonment Recovery β automated email sequences to recover lost sales
Reviews & Ratings
Sign in to leave a review.
