Bekzod Erkinov
Listen to Article
Loading...Table of contents · 8 sections
Laravel 12 Product Catalog and Cart: Schema, Indexes, Login Merge
This part gives you a Laravel 12 product catalog schema (products, variants, integer prices), one index chosen from EXPLAIN output, and a database cart that merges guest items into the user's cart on login. It is Part 2 of the Laravel ecommerce platform series (Part 1: Domain-Driven Architecture & Project Setup), but you don't need Part 1 to follow along. Every model, service, migration, seed and test the code depends on is on this page.
Laravel ecommerce setup: Laravel 12 and MySQL 8.4 in Docker
docker network create shop
docker run -d --name shop-mysql --network shop -e MYSQL_ROOT_PASSWORD=secret \
-e MYSQL_DATABASE=shop mysql:8.4
docker run --rm -v "$PWD/app:/app" -w /app composer:2 \
composer create-project laravel/laravel . "^12.0" --ignore-platform-reqs
Point .env at DB_CONNECTION=mysql, DB_HOST=shop-mysql, DB_DATABASE=shop, and make phpunit.xml use MySQL with a separate shop_test database (create it first). Run php artisan from a PHP 8.4 container with pdo_mysql that mounts the same directory. What I ran:
$ php artisan --version
Laravel Framework 12.69.3
$ php -v | head -1
PHP 8.4.26 (cli) (built: Oct 6 2026 01:25:39) (NTS)
mysql> SELECT VERSION();
8.4.11
I only ran this on 12.69.3; Laravel 12 is in its security-fixes-only window until 2027-02-24 (release notes), and I haven't tried it on 13.
One config line matters later: in config/database.php, add 'use_upsert_alias' => true to the mysql connection. MySQL deprecated VALUES() in ON DUPLICATE KEY UPDATE in favour of row aliases (MySQL docs). The option came from a Laravel 9.x pull request (#42053, merged April 2022) and needs MySQL 8.0.19 or newer, which is why it is opt-in. In 12.69.3 it is read at Illuminate/Database/Query/Grammars/MySqlGrammar.php:414. The SQL it produces is shown in the cart section.
Laravel product catalog schema: products, variants, integer prices
Price lives on the variant, not the product. A "T-shirt" isn't sellable; "T-shirt / Blue / M" is. The product holds what the listing page shows; the variant holds SKU and price.
Money is an unsigned integer in minor units. Stripe's API works the same way: 1099 means 10.99 USD and 10 means 10 JPY (Stripe currencies). If your column already holds minor units, there is no conversion step to get wrong when you call Stripe. Floats in PHP 8.4 (money.php):
var_dump(0.1 + 0.2 == 0.3);
var_dump((int) (19.99 * 100));
var_dump((int) round(19.99 * 100));
$cents = 1999; $qty = 3; $total = $cents * $qty;
echo $total, ' cents = ', intdiv($total, 100) . '.' . str_pad($total % 100, 2, '0', STR_PAD_LEFT), PHP_EOL;
bool(false)
int(1998)
int(1999)
5997 cents = 59.97
(int) (19.99 * 100) gives 1998, a customer charged one cent short. Store 1999, multiply by quantity as integers, and format only at the edge. Keep the currency next to the amount: "divide by 100" isn't universal. Stripe lists zero-decimal currencies like JPY, and ISK and UGX are special: they are zero-decimal, but Stripe wants them sent as two-decimal values ending in 00 (5 ISK is 500).
The listing price is denormalised. products.min_price_cents is copied from the cheapest active variant so category pages can filter and sort on one table. The cost is that every variant write must call Product::syncMinPrice(), shown below.
database/migrations/..._create_catalog_tables.php:
Schema::create('categories', function (Blueprint $t) {
$t->id();
$t->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete();
$t->string('slug')->unique();
$t->string('name');
$t->timestamps();
});
Schema::create('products', function (Blueprint $t) {
$t->id();
$t->foreignId('category_id')->constrained();
$t->string('slug')->unique();
$t->string('name');
$t->text('description')->nullable();
$t->string('status', 16)->default('draft'); // draft | active | archived
$t->char('currency', 3)->default('USD');
$t->unsignedInteger('min_price_cents')->default(0);
$t->timestamps();
// equality columns first, sort column last, id for a stable order
$t->index(['category_id', 'status', 'min_price_cents', 'id'], 'products_cat_status_price_idx');
});
Schema::create('variants', function (Blueprint $t) {
$t->id();
$t->foreignId('product_id')->constrained()->cascadeOnDelete();
$t->string('sku', 64)->unique();
$t->string('name'); // "Blue / M"
$t->unsignedInteger('price_cents');
$t->unsignedInteger('compare_at_cents')->nullable(); // "was" price
$t->boolean('is_active')->default(true);
$t->timestamps();
$t->index(['product_id', 'is_active']);
});
An unsignedInteger tops out at 4,294,967,295 minor units, about 42.9 million in a two-decimal currency; use unsignedBigInteger if you sell something dearer. Models (all with $guarded = [], which is fine for code that never mass-assigns request input):
// app/Models/Category.php
class Category extends Model { protected $guarded = []; }
// app/Models/Product.php
class Product extends Model
{
protected $guarded = [];
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** Recompute the denormalised listing price from active variants. */
public function syncMinPrice(): void
{
$this->update([
'min_price_cents' => (int) $this->variants()->where('is_active', true)->min('price_cents'),
]);
}
}
// app/Models/Variant.php
class Variant extends Model
{
protected $guarded = [];
protected $attributes = ['is_active' => true]; // see the gotcha below
protected $casts = ['price_cents' => 'integer', 'is_active' => 'boolean'];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
Gotcha: a created model doesn't know the database default
My first cart test failed with Variant not available. The cart refuses inactive variants, and a variant created through $product->variants()->create([...]) had is_active === null: MySQL applies the column default, but Eloquent doesn't re-read the row after the insert. The $attributes line on Variant mirrors the default on the model.
Catalog query and index: from filesort to a composite index
A category page is WHERE category_id = ? AND status = 'active' ORDER BY price LIMIT 24. This is the complete seed (seed.sql, run with mysql shop < seed.sql after php artisan migrate): 50 categories, 200,000 products and 3 variants each.
SET SESSION cte_max_recursion_depth = 300000;
INSERT INTO categories (slug, name, created_at, updated_at)
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 50)
SELECT CONCAT('cat-', i), CONCAT('Category ', i), NOW(), NOW() FROM n;
INSERT INTO products (category_id, slug, name, status, currency, min_price_cents, created_at, updated_at)
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 200000)
SELECT 1 + (i MOD 50), CONCAT('product-', i), CONCAT('Product ', i),
ELT(1 + ((i DIV 50) MOD 10 = 0) + 2*((i DIV 50) MOD 10 = 5), 'active', 'draft', 'archived'),
'USD', 500 + FLOOR(RAND(i) * 49500), NOW(), NOW() FROM n;
INSERT INTO variants (product_id, sku, name, price_cents, is_active, created_at, updated_at)
SELECT p.id, CONCAT('SKU-', p.id, '-', v.k), CONCAT('Option ', v.k),
p.min_price_cents + (v.k - 1) * 300, 1, NOW(), NOW()
FROM products p JOIN (SELECT 1 k UNION SELECT 2 UNION SELECT 3) v;
ANALYZE TABLE products, variants;
SELECT (SELECT COUNT(*) FROM products) products, (SELECT COUNT(*) FROM variants) variants,
(SELECT COUNT(*) FROM products WHERE category_id=7 AND status='active') cat7_active;
products variants cat7_active
200000 600000 3200
160,000 products are active overall, 3,200 in category 7. For the baseline I added a plain index on category_id by hand, to simulate the common default of a single index on the foreign key (the migration above doesn't create one). The composite index also serves FK lookups on category_id, since that is its first column, so in your own schema you can drop the single-column one once the composite exists. The baseline setup:
ALTER TABLE products ADD INDEX products_category_id_idx (category_id);
ALTER TABLE products DROP INDEX products_cat_status_price_idx;
ANALYZE TABLE products;
bench.php is the whole benchmark. It runs each query 40 times through PDO and prints the median and p90 including the round trip, then the EXPLAIN row. It covers the four queries used in this section and the next:
<?php
// php bench.php (env: DB_HOST, default 127.0.0.1)
$pdo = new PDO('mysql:host='.(getenv('DB_HOST') ?: '127.0.0.1').';dbname=shop', 'root', 'secret');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$base = "SELECT id, name, min_price_cents FROM products WHERE category_id = 7 AND status = 'active'";
$page = "ORDER BY min_price_cents, id LIMIT 24";
// keyset cursor = the last row before offset 3000
$cur = $pdo->query("$base ORDER BY min_price_cents, id LIMIT 1 OFFSET 2999")->fetch(PDO::FETCH_ASSOC);
[$p, $i] = [$cur['min_price_cents'], $cur['id']];
$queries = [
'page1 (LIMIT 24)' => "$base $page",
'OFFSET 3000' => "$base $page OFFSET 3000",
'keyset, tuple form' => "$base AND (min_price_cents, id) > ($p, $i) $page",
'keyset, OR form' => "$base AND (min_price_cents > $p OR (min_price_cents = $p AND id > $i)) $page",
];
foreach ($queries as $name => $sql) {
$t = [];
for ($n = 0; $n < 40; $n++) {
$s = microtime(true);
$pdo->query($sql)->fetchAll();
$t[] = (microtime(true) - $s) * 1000;
}
sort($t);
printf("%-20s median %7.2f ms p90 %7.2f ms\n", $name, $t[20], $t[36]);
$r = $pdo->query("EXPLAIN $sql")->fetch(PDO::FETCH_ASSOC);
echo json_encode(array_intersect_key($r, array_flip(['type', 'key', 'rows', 'filtered', 'Extra']))), "\n";
}
These timings come from Docker on a Windows laptop and move a lot between runs (an earlier run of the same two page-1 plans gave 13.18 ms vs 0.75 ms), so compare the plans, not the milliseconds. Baseline (single-column index) output:
page1 (LIMIT 24) median 49.32 ms p90 95.02 ms
{"type":"ref","key":"products_category_id_idx","rows":4000,"filtered":10,"Extra":"Using where; Using filesort"}
OFFSET 3000 median 32.09 ms p90 68.07 ms
{"type":"ref","key":"products_category_id_idx","rows":4000,"filtered":10,"Extra":"Using where; Using filesort"}
keyset, tuple form median 43.98 ms p90 63.05 ms
{"type":"ref","key":"products_category_id_idx","rows":4000,"filtered":10,"Extra":"Using where; Using filesort"}
keyset, OR form median 36.90 ms p90 47.05 ms
{"type":"ref","key":"products_category_id_idx","rows":4000,"filtered":3.56,"Extra":"Using where; Using filesort"}
Using filesort is the problem: MySQL reads all ~4,000 rows of the category, discards the non-active ones, sorts the rest and returns 24. Now swap in the composite index (category_id, status, min_price_cents, id) that the migration creates, and rerun:
ALTER TABLE products ADD INDEX products_cat_status_price_idx (category_id, status, min_price_cents, id);
ALTER TABLE products DROP INDEX products_category_id_idx;
ANALYZE TABLE products;
page1 (LIMIT 24) median 1.65 ms p90 2.91 ms
{"type":"ref","key":"products_cat_status_price_idx","rows":3200,"filtered":100,"Extra":null}
OFFSET 3000 median 24.70 ms p90 39.42 ms
{"type":"ref","key":"products_cat_status_price_idx","rows":3200,"filtered":100,"Extra":null}
keyset, tuple form median 33.95 ms p90 60.10 ms
{"type":"ref","key":"products_cat_status_price_idx","rows":3200,"filtered":100,"Extra":"Using where"}
keyset, OR form median 1.70 ms p90 5.12 ms
{"type":"range","key":"products_cat_status_price_idx","rows":201,"filtered":100,"Extra":"Using index condition"}
The plan is the result to trust: Using filesort is gone, filtered goes from 10 to 100, and the rows examined for page 1 stop after 24 instead of the whole category. Page 1 dropped from 49.32 to 1.65 ms, more than an order of magnitude, but single runs on this machine are noisy (see above), so don't quote the ratio. The two equality columns come first and the sort column after them, so rows come out in order and the LIMIT stops early. In the baseline, OFFSET 3000 (32 ms) beating page 1 (49 ms) is that noise, most likely a cold buffer pool on the first query; both ran the identical filesort plan. For the same index habits at larger scale, see scaling a Laravel app to 100,000 users and Part 6 of this series. The MySQL EXPLAIN docs describe the columns.
Laravel pagination: OFFSET vs keyset on a 200,000-product catalog
Look at the OFFSET 3000 line (page 126 of that category) in the composite-index output above. The index removed the filesort, but deep OFFSET is still more than ten times slower than page 1 on the same index (24.70 vs 1.65 ms in this run), because MySQL walks 3,000 index entries and discards them. The fix is keyset pagination: remember the last row's (price, id) and ask for rows after it. My first attempt used the tuple form (the keyset, tuple form row):
... AND (min_price_cents, id) > (?, ?) ORDER BY min_price_cents, id LIMIT 24
rows: 3200 and type: ref say it scans the whole category: 33.95 ms, no better than OFFSET. The MySQL manual documents this: with c1=1 AND (c2,c3) > (1,1) only the first index column is used, and it recommends rewriting with plain AND/OR (row constructor optimization). The OR form, as Eloquent:
Product::query()
->where('category_id', $cat)->where('status', 'active')
->where(fn ($q) => $q->where('min_price_cents', '>', $price)
->orWhere(fn ($q) => $q->where('min_price_cents', $price)->where('id', '>', $id)))
->orderBy('min_price_cents')->orderBy('id')
->limit(24)->get();
The keyset, OR form row is type: range, rows: 201, 1.70 ms: same index, same rows returned, back to page-1 speed. Use OFFSET for the first few pages and keyset for "load more" or infinite scroll. If you need "page 126 of 134" links, you need OFFSET, so cap how deep it goes.
Laravel cart: session vs database, and merging on login
Laravel's session can use file, cookie, database, redis and others (session docs), so a cart array in the session is the shortest path. Use this checklist to decide:
| Question | Session cart | Database cart |
|---|---|---|
| Abandoned-cart emails or reports? | Can't query carts | Query carts by updated_at |
| Cart follows the user across devices? | No, dies with the session | Yes, keyed by user_id |
| Checkout needs to lock and total in SQL? | Cart lives outside SQL | Rows to lockForUpdate() and SUM() |
| Guest traffic is high or bot-heavy? | No DB write per add | One cart insert plus an upsert per guest's first add; bots create rows too |
The rule: pick the database cart if you answer yes to any of the first three questions; otherwise the session is fine. The last row is the database cart's price. Each guest add is a database write (12.34 ms median for add() below), and a scraper that never keeps cookies creates a new cart per request. Prune stale guest carts, as shown later in this section, and rate-limit the add endpoint.
The design: a carts table with its own random token for guests, kept in the session, and a unique user_id (one cart per user) once they log in.
Schema::create('carts', function (Blueprint $t) {
$t->id();
$t->uuid('token')->unique();
$t->foreignId('user_id')->nullable()->unique()->constrained()->cascadeOnDelete();
$t->timestamps();
$t->index('updated_at'); // abandoned-cart prune job
});
Schema::create('cart_items', function (Blueprint $t) {
$t->id();
$t->foreignId('cart_id')->constrained()->cascadeOnDelete();
$t->foreignId('variant_id')->constrained();
$t->unsignedSmallInteger('quantity');
$t->timestamps();
$t->unique(['cart_id', 'variant_id']); // add twice = update, enforced by the DB
});
Why not key the guest cart by the session ID? SessionGuard::login() calls updateSession(), which calls $this->session->regenerate(true) and only afterwards fireLoginEvent() (Illuminate/Auth/SessionGuard.php, lines 567-604 in 12.69.3). Regeneration prevents session fixation (session docs), so a cart keyed by the ID is orphaned exactly when you want to merge. A token stored inside the session data survives regeneration.
// app/Models/CartItem.php
class CartItem extends Model { protected $guarded = []; }
// app/Models/Cart.php
class Cart extends Model
{
protected $guarded = [];
public function items(): HasMany
{
return $this->hasMany(CartItem::class);
}
public function totalCents(): int
{
return (int) $this->items()
->join('variants', 'variants.id', '=', 'cart_items.variant_id')
->sum(DB::raw('variants.price_cents * cart_items.quantity'));
}
/** Delete guest carts untouched for $days days (items cascade). Returns rows deleted. */
public static function pruneGuests(int $days = 30): int
{
return static::whereNull('user_id')->where('updated_at', '<', now()->subDays($days))->delete();
}
}
The prune job is one line in routes/console.php, using the updated_at index and the $cart->touch() in add() below: Schedule::call(fn () => Cart::pruneGuests(30))->daily();.
The cart stores no price: totalCents() always uses the variant's current price, so a price change shows up in the cart at once. If you must warn shoppers about changes, add a unit_price_cents snapshot column and compare it at checkout; I dropped it because nothing here reads it.
User is the stock Laravel 12 model and factory. Here is the whole service:
// app/Services/CartService.php
class CartService
{
public const MAX_QTY = 99;
/** The user's cart if logged in, else the guest cart named by the session token. */
public function current(): Cart
{
if ($user = auth()->user()) {
return Cart::createOrFirst(['user_id' => $user->id], ['token' => (string) Str::uuid()]);
}
$token = session('cart_token');
if (! $token) {
$token = (string) Str::uuid();
session(['cart_token' => $token]);
}
return Cart::firstOrCreate(['token' => $token]);
}
public function add(Variant $variant, int $qty = 1): CartItem
{
abort_unless($variant->is_active, 422, 'Variant not available');
$cart = $this->current();
$now = now();
return DB::transaction(function () use ($cart, $variant, $qty, $now) {
DB::table('cart_items')->upsert(
[[
'cart_id' => $cart->id,
'variant_id' => $variant->id,
'quantity' => min($qty, self::MAX_QTY),
'created_at' => $now,
'updated_at' => $now,
]],
['cart_id', 'variant_id'], // the unique index
[
// keyed entry = our own expression; 'laravel_upsert_alias' is the row being inserted
'quantity' => DB::raw('LEAST(cart_items.quantity + laravel_upsert_alias.quantity, '.self::MAX_QTY.')'),
'updated_at', // bare entry = copy from the inserted row
]
);
$cart->touch();
return CartItem::where(['cart_id' => $cart->id, 'variant_id' => $variant->id])->firstOrFail();
});
}
/** Fold the guest cart into the user's cart. Quantities add (capped); the guest cart is deleted. */
public function mergeGuestInto(User $user, string $guestToken): void
{
DB::transaction(function () use ($user, $guestToken) {
$guest = Cart::where('token', $guestToken)->whereNull('user_id')->lockForUpdate()->first();
if (! $guest) {
return;
}
$mine = Cart::createOrFirst(['user_id' => $user->id], ['token' => (string) Str::uuid()]);
DB::statement(
'INSERT INTO cart_items (cart_id, variant_id, quantity, created_at, updated_at)
SELECT ?, g.variant_id, g.quantity, NOW(), NOW()
FROM cart_items g WHERE g.cart_id = ?
ON DUPLICATE KEY UPDATE quantity = LEAST(cart_items.quantity + g.quantity, '.self::MAX_QTY.'),
updated_at = NOW()',
[$mine->id, $guest->id]
);
$guest->delete(); // cascades to its items
});
}
}
Imports are App\Models\{Cart, CartItem, User, Variant}, Illuminate\Support\Facades\DB and Illuminate\Support\Str. The unique (cart_id, variant_id) index makes "add the same item twice" an update decided by the database, and LEAST(..., 99) caps the quantity there too, so two racing requests can't push a line past it.
What the upsert generates with use_upsert_alias on, captured with DB::pretend() (bindings inlined; MySQL receives ? placeholders):
insert into `cart_items` (`cart_id`, `created_at`, `quantity`, `updated_at`, `variant_id`) values (1, '2026-10-07 16:48:09', 2, '2026-10-07 16:48:09', 1) as laravel_upsert_alias on duplicate key update `quantity` = LEAST(cart_items.quantity + laravel_upsert_alias.quantity, 99), `updated_at` = `laravel_upsert_alias`.`updated_at`
The grammar source (starting at line 414) explains the shape: a keyed entry in the update array is emitted as written, a bare entry becomes `col` = `laravel_upsert_alias`.`col`. With the option set to false the same call fails, because the alias is never declared:
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'laravel_upsert_alias.quantity' in 'field list'
So my raw expression depends on the option. If you don't want that coupling, drop the config line and write LEAST(cart_items.quantity + VALUES(quantity), 99); it works but MySQL 8.4 warns that VALUES() is deprecated.
The controller and route that call add() (routes/web.php: Route::post('/cart/items', [CartController::class, 'store']);):
class CartController extends Controller
{
public function store(Request $request, CartService $carts): JsonResponse
{
$data = $request->validate([
'variant_id' => ['required', 'integer', 'exists:variants,id'],
'quantity' => ['integer', 'min:1', 'max:'.CartService::MAX_QTY],
]);
$item = $carts->add(Variant::findOrFail($data['variant_id']), $data['quantity'] ?? 1);
return response()->json(['variant_id' => $item->variant_id, 'quantity' => $item->quantity], 201);
}
}
add() had a median of 12.34 ms and p90 of 15.26 ms over 300 calls (upsert, touching the cart, re-reading the row), and totalCents() on a 300-line cart took a median of 2.46 ms. EXPLAIN of the total query shows eq_ref on variants.PRIMARY; cart_items shows type: ALL only because my test table held one cart of 300 rows, where a scan is the cheapest plan.
Merge on login with a listener
A listener on Laravel's Login event does the merge. Nothing needs registering: Laravel 12 discovers listeners in app/Listeners from the type-hinted handle() argument (event discovery). I checked it in isolation with php artisan event:list --event=Login before running any test:
Illuminate\Auth\Events\Login ...............................................
⇂ App\Listeners\MergeGuestCart@handle
class MergeGuestCart
{
public function __construct(private CartService $carts) {}
public function handle(Login $event): void
{
if ($token = session('cart_token')) {
$this->carts->mergeGuestInto($event->user, $token);
session()->forget('cart_token');
}
}
}
Why a guest token can't be hijacked. cart_token lives only in server-side session data and is never sent to the client, so another user can't present someone else's token; mergeGuestInto() also only touches carts with user_id null.
Concurrency. user_id is unique, so two requests that both find no cart for a user can both try to insert one, and plain firstOrCreate would throw a unique-constraint error for the loser. That is why current() and mergeGuestInto() use Cart::createOrFirst() for the user cart: it inserts first, catches the violation and returns the existing row. test_creating_the_user_cart_twice_returns_the_existing_row below exercises that violation path sequentially, not with two real parallel connections. The guest-cart firstOrCreate keys on a random token, so it can't collide; two parallel first requests from one browser may each mint a token, one wins the session write, and the other cart is an orphan that the prune job removes.
Gotcha: the one-statement merge failed on MySQL, and the cause was a missing alias
My first merge was INSERT INTO cart_items ... SELECT variant_id, quantity, ... FROM cart_items WHERE cart_id = ? ON DUPLICATE KEY UPDATE quantity = LEAST(quantity + VALUES(quantity), 99). MySQL 8.4.11 rejected it:
ERROR 1052 (23000): Column 'quantity' in field list is ambiguous
I first blamed "reading and writing the same table" and fell back to reading rows in PHP. That was a guess, so I tested it on two carts (cart 1: 1×variant 1; cart 2: 2×variant 1 and 1×variant 2). Giving the SELECT side an alias (FROM cart_items g) and qualifying the columns made the same single statement work, with or without VALUES():
cart_id variant_id quantity
1 1 3
1 2 1
So the same-table read is fine; the unaliased cart_items appears twice (insert target and select source) and the bare column name is ambiguous. A derived table (FROM (SELECT ...) AS s) works too. The service above uses the alias version, one statement inside the transaction.
Tests: eight cart tests on MySQL, not SQLite
The suite uses MySQL because the upsert and LEAST() are MySQL syntax; SQLite would pass or fail for the wrong reasons. All tests are in tests/Feature/CartTest.php on shop_test (set DB_CONNECTION to mysql and DB_DATABASE to shop_test in phpunit.xml). The class header, the helper and the merge test:
<?php
namespace Tests\Feature;
use App\Models\{Cart, Category, Product, User, Variant};
use App\Services\CartService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\{Auth, DB};
use Illuminate\Support\Str;
use Tests\TestCase;
class CartTest extends TestCase
{
use RefreshDatabase;
private function variant(string $sku, int $cents): Variant
{
$cat = Category::firstOrCreate(['slug' => 'shirts'], ['name' => 'Shirts']);
$p = Product::create(['category_id' => $cat->id, 'slug' => "p-$sku", 'name' => "Product $sku", 'status' => 'active']);
$v = $p->variants()->create(['sku' => $sku, 'name' => 'M', 'price_cents' => $cents]);
$p->syncMinPrice();
return $v;
}
public function test_guest_cart_merges_into_user_cart_on_login(): void
{
$a = $this->variant('A', 1000);
$b = $this->variant('B', 2500);
$user = User::factory()->create();
$carts = app(CartService::class);
Auth::login($user); // user already has 1 x A
$carts->add($a, 1);
Auth::logout();
session()->flush();
$carts->add($a, 2); // as a guest: 2 x A and 1 x B
$carts->add($b, 1);
$this->assertSame(2, DB::table('carts')->count());
Auth::login($user); // fires Login -> MergeGuestCart
$rows = DB::table('cart_items')->orderBy('variant_id')->get(['variant_id', 'quantity']);
$this->assertCount(2, $rows);
$this->assertSame(3, (int) $rows[0]->quantity); // 1 + 2
$this->assertSame(1, (int) $rows[1]->quantity);
$this->assertSame(1, DB::table('carts')->count()); // guest cart gone
$this->assertNull(session('cart_token'));
}
The other seven go in the same class, and a closing } ends it:
public function test_money_is_integer_and_sums_exactly(): void
{
app(CartService::class)->add($this->variant('A', 1999), 3);
$this->assertSame(5997, app(CartService::class)->current()->totalCents());
}
public function test_adding_same_variant_twice_updates_one_row(): void
{
$v = $this->variant('A', 1000);
$carts = app(CartService::class);
$carts->add($v, 2);
$carts->add($v, 3);
$this->assertSame(1, DB::table('cart_items')->count());
$this->assertSame(5, (int) DB::table('cart_items')->value('quantity'));
}
public function test_quantity_is_capped(): void
{
$v = $this->variant('A', 1000);
$carts = app(CartService::class);
$carts->add($v, 90);
$carts->add($v, 90);
$this->assertSame(CartService::MAX_QTY, (int) DB::table('cart_items')->value('quantity'));
}
public function test_cart_token_survives_session_id_regeneration(): void
{
app(CartService::class)->add($this->variant('A', 1000), 1);
$token = session('cart_token');
session()->regenerate(); // what login() does
$this->assertSame($token, session('cart_token'));
}
public function test_creating_the_user_cart_twice_returns_the_existing_row(): void
{
$user = User::factory()->create();
$first = Cart::createOrFirst(['user_id' => $user->id], ['token' => (string) Str::uuid()]);
// the second call hits the unique user_id index, catches the violation and returns the row
$second = Cart::createOrFirst(['user_id' => $user->id], ['token' => (string) Str::uuid()]);
$this->assertSame($first->id, $second->id);
$this->assertSame(1, DB::table('carts')->count());
}
public function test_prune_deletes_only_stale_guest_carts(): void
{
$user = User::factory()->create();
$old = Cart::create(['token' => (string) Str::uuid()]);
$fresh = Cart::create(['token' => (string) Str::uuid()]);
$mine = Cart::create(['token' => (string) Str::uuid(), 'user_id' => $user->id]);
DB::table('carts')->whereIn('id', [$old->id, $mine->id])->update(['updated_at' => now()->subDays(40)]);
$this->assertSame(1, Cart::pruneGuests(30));
$this->assertEqualsCanonicalizing([$fresh->id, $mine->id], DB::table('carts')->pluck('id')->all());
}
public function test_post_cart_items_adds_to_the_guest_cart(): void
{
$v = $this->variant('A', 1000);
$this->postJson('/cart/items', ['variant_id' => $v->id, 'quantity' => 2])
->assertCreated()->assertJson(['variant_id' => $v->id, 'quantity' => 2]);
$this->postJson('/cart/items', ['variant_id' => $v->id])
->assertCreated()->assertJson(['quantity' => 3]);
}
Output of php artisan test:
PASS Tests\Unit\ExampleTest
✓ that true is true 0.03s
PASS Tests\Feature\CartTest
✓ guest cart merges into user cart on login 5.78s
✓ money is integer and sums exactly 0.18s
✓ adding same variant twice updates one row 0.21s
✓ quantity is capped 0.22s
✓ cart token survives session id regeneration 0.33s
✓ creating the user cart twice returns the existing row 0.15s
✓ prune deletes only stale guest carts 0.16s
✓ post cart items adds to the guest cart 0.45s
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response 0.15s
Tests: 10 passed (21 assertions)
Duration: 8.51s
The ten include the two stock example tests; I reran them after removing the price-snapshot column. The first test pays the migration cost. The add-twice test covers the unique-index update path; I didn't capture a separate constraint-failure case. One test-only gotcha: I first cached the category in a static variable inside the helper, and every later test failed with a foreign key error because the database had been reset in between. Use firstOrCreate, as above.
What I couldn't measure
I did not test concurrent writers (including two simultaneous logins merging the same guest token), replication lag, or catalogs over 200,000 products. By design it breaks if min_price_cents isn't re-synced on every variant write, or if the index order stops matching the ORDER BY.
What to do next
- Run your own category query through
EXPLAINand look forUsing filesortor arowsfigure close to the table size. Put equality columns first in the index, the sort column last. - Check your money columns. If any is
decimalorfloat, migrate to integer minor units before any payment code touches it, and store the currency beside it. - Replace
OFFSETwith theOR-form keyset query on any "load more" endpoint, and confirmtype: rangeinEXPLAIN. - If you use a database cart, key guests by your own token, not the session ID, and schedule the guest prune.
- Write the merge test before the merge code: user cart plus guest cart with an overlapping variant, then assert one cart and summed quantities.
- Call
Product::syncMinPrice()wherever variants are created, updated or deactivated, or move it into a model observer. - Next is payments: Part 3, Stripe Payments, Webhooks & Order Lifecycle covers the payment flow that follows the cart.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,087 views
Operating SystemsAdvanced Laravel Tutorial: Queues, Events and Pest Tests
22 min · 276 views
PerformanceComplete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 232 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Building a REST API with Laravel - Part 1: API Design, Versioning & Sanctum Authentication
May 10, 2026
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026
Building a Modern SaaS Application with Laravel - Part 3: Production Scaling, Queues & Observability
Apr 25, 2026