Skip to content
TutorialDatabase

Shopping Cart Database Design: Complete Schema for Laravel E-Commerce (Tables, Indexes, Relationships)

D

Dinesh Wijethunga

June 25, 2026Reviewed Aug 1, 2026 13 min readIntermediate 482 views
ShareX / TwitterLinkedIn
Shopping Cart Database Design: Complete Schema for Laravel E-Commerce (Tables, Indexes, Relationships)

TL;DR: A production shopping cart needs four tables — carts, cart_items, cart_coupons, and cart_abandonments. The two constraints that matter most are a composite unique on (identifier, instance) for the cart header and a composite unique on (cart_id, row_id) for line items. Get those wrong and you will be writing repair migrations against live data.

This is the shopping cart database design running in production on a live Laravel storefront since November 2025: four tables, about forty columns, and a handful of composite indexes that are doing more work than they look like they are. Below is every table, every index, why each one exists, and the three decisions I would make differently if I started again.

Shopping cart database design ER diagram showing the carts, cart_items, cart_coupons and cart_abandonments tables with their primary keys, foreign keys and composite unique constraints
The four-table shopping cart schema: one cart header, many line items, many coupons, and at most one abandonment record.

The four tables at a glance

TableResponsibilityRows per cart
cartsHeader: owner identity, totals, currency, expiry, status1
cart_itemsLine items with pre-computed totals and a product snapshot1 per product/option combination
cart_couponsApplied codes and their computed discount values0–n
cart_abandonmentsAbandonment lifecycle: email state, recovery token, attribution0–1

The split matters. A single carts table with a JSON blob works until you need to query "which carts contain product 412 and were abandoned in the last 48 hours" — then you are doing full table scans over JSON on every marketing run.

Table 1 — carts

Schema::create('carts', function (Blueprint $table) {
    $table->id();
    $table->string('identifier');                  // user ID or session ID
    $table->string('instance')->default('cart');   // 'cart', 'wishlist', 'quote'
    $table->text('content')->nullable();           // legacy serialized payload
    $table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
    $table->string('coupon_code')->nullable();
    $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('subtotal', 10, 2)->default(0);
    $table->decimal('total', 10, 2)->default(0);
    $table->text('notes')->nullable();
    $table->string('currency', 3)->default('USD');
    $table->timestamps();
    $table->timestamp('expires_at')->nullable();

    $table->unique(['identifier', 'instance']);
    $table->index(['user_id', 'instance']);
    $table->index('expires_at');
});

Why identifier and instance instead of just user_id

This is the single most important decision in the schema, and almost every tutorial gets it wrong by making user_id the cart owner.

A cart exists before a user does. A guest browsing your store has a session ID and no account. If user_id is your only owner column, you cannot persist a guest cart at all without nullable-owner special cases leaking through every query.

identifier holds whichever identity currently owns the cart — the session ID for a guest, the user ID once they authenticate. instance lets the same table serve carts, saved-for-later lists, and quotes without a discriminator table.

The composite unique is what makes guest-to-user merge safe:

$table->unique(['identifier', 'instance']);

One identity gets exactly one cart per instance. When a guest logs in, you fetch their session cart and their user cart, merge the items, and delete the loser. Without that constraint two concurrent tabs will happily create two carts for the same session, and your merge logic will silently drop items.

The content column is a compromise, not a pattern

The content TEXT column holds a serialized cart payload, and it coexists with the fully normalized cart_items table. That is redundant, and it is redundant on purpose: it is what remains of the package-based cart this store started on, kept during the migration so old sessions did not vaporize on deploy day.

Do not copy it into a greenfield schema. I am showing it because production schemas carry this kind of scar tissue and pretending otherwise is how tutorials mislead people. If you inherit a column like this, the exit path is to backfill cart_items from it, stop writing to it, then drop it in a later release — three deploys, not one.

Money columns

decimal(10, 2) throughout. Never float: binary floating point cannot represent 0.10 exactly, and cart totals that drift by a cent are the kind of bug that surfaces in an accounting reconciliation six months later. Ten digits with two decimals caps a line at 99,999,999.99, which is comfortable for retail.

Storing integer minor units (cents) is the stricter alternative and worth it if you handle multiple currencies with different exponents. This store is single-currency-per-cart, so decimal stays.

Why the totals are denormalized

subtotal, tax_amount, shipping_amount, discount_amount, and total could all be derived by summing cart_items. They are stored anyway, because the cart badge and the mini-cart render on every page load. Summing child rows on every request to display "$142.50" is a join you pay for a few million times a month for no benefit.

The tradeoff is that these columns can drift from their children. Recalculate them in one place — a single recalculate() method on the cart model, called from every mutation — and never write them from a controller.

Table 2 — cart_items

Schema::create('cart_items', function (Blueprint $table) {
    $table->id();
    $table->foreignId('cart_id')->constrained()->onDelete('cascade');
    $table->string('row_id');                       // hash of product + options
    $table->foreignId('product_id')->constrained()->onDelete('cascade');
    $table->string('name')->nullable();
    $table->integer('qty');
    $table->decimal('price', 10, 2)->nullable()->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();            // size, colour, engraving
    $table->json('product_attributes')->nullable(); // snapshot at add time
    $table->timestamps();

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

row_id is the column tutorials skip

The obvious design is unique(['cart_id', 'product_id']) — one row per product per cart. It is wrong the moment you sell anything with variations.

A customer buying the same t-shirt in medium black and large white is buying one product_id across two distinct lines. They need separate quantities, separate removal, separate pricing. A unique on product_id makes that impossible and forces you into an ugly "merge the quantities" fallback that loses the customer's actual intent.

row_id is a deterministic hash of the product ID plus the normalized options:

protected function generateRowId(int $productId, array $options): string
{
    ksort($options); // order must not affect identity

    return md5($productId . serialize($options));
}

The ksort matters. Without it, ['size' => 'M', 'color' => 'black'] and ['color' => 'black', 'size' => 'M'] hash differently and your customer ends up with two identical lines. The unique on (cart_id, row_id) then does the deduplication in the database: adding the same variant twice becomes a quantity increment, not a second row.

product_attributes freezes the product at add time

Prices change. Products get renamed, restocked, and discontinued. If your cart joins to products for the display name and price at render time, then a price change mid-session silently rewrites what the customer thought they were buying.

product_attributes stores a JSON snapshot — name, price, SKU, thumbnail, tax class — as it existed when the item went in the cart. The cart renders from the snapshot. Checkout re-validates against the live product and surfaces any delta explicitly ("the price of this item changed"), which is both honest and, in several jurisdictions, required.

It also means a deleted product does not blank out historical carts.

tax_rate is decimal(5,2)

Five digits, two decimals: up to 999.99. Rates are stored per line rather than per cart because mixed baskets are normal — food at 5%, electronics at 20% — and a single cart-level rate cannot represent that.

Table 3 — cart_coupons

Schema::create('cart_coupons', function (Blueprint $table) {
    $table->id();
    $table->foreignId('cart_id')->constrained()->onDelete('cascade');
    $table->string('code');
    $table->string('type');                        // percentage, fixed, shipping
    $table->decimal('value', 10, 2);               // the coupon's configured value
    $table->decimal('discount_amount', 10, 2);     // what it actually took off
    $table->json('data')->nullable();              // coupon definition snapshot
    $table->timestamps();

    $table->index(['cart_id', 'code']);
});

Two money columns, because they answer different questions. value is the coupon's configuration — "15%". discount_amount is what it actually removed from this specific cart — "$21.40". You need both: the first to explain the coupon, the second to reconcile the total without recomputing promotion logic.

The data snapshot exists for the same reason as product_attributes. Marketing edits coupons. A coupon that was 15% in March and 10% in April must still explain a March order correctly.

A constraint this table is missing

Look at that last line. It is an index on (cart_id, code), not a unique. Nothing at the database level stops the same coupon being applied to the same cart twice.

In this codebase the service layer guards it, so it has never fired in production. That is not the same as being safe: a retry, a double-submitted form, or a second worker processing the same request can all slip past an application-level check that the database would have rejected outright. If you are building this fresh, make it unique:

$table->unique(['cart_id', 'code']);

Table 4 — cart_abandonments

Schema::create('cart_abandonments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('cart_id')->constrained()->onDelete('cascade');
    $table->string('email')->nullable();
    $table->string('phone')->nullable();
    $table->string('token')->unique();
    $table->boolean('email_sent')->default(false);
    $table->timestamp('email_sent_at')->nullable();
    $table->boolean('recovered')->default(false);
    $table->timestamp('recovered_at')->nullable();
    $table->integer('reminder_count')->default(0);
    $table->timestamp('last_reminder_sent_at')->nullable();
    $table->json('customer_data')->nullable();
    $table->timestamps();

    $table->index('email');
    $table->index('recovered');
    $table->index('created_at');
});

token is the unguessable string in the recovery link. It is unique because it is a lookup key — one token resolves to exactly one abandoned cart. Generate it with Str::random(64), never from the cart ID.

reminder_count and last_reminder_sent_at exist because recovery is a sequence, not an event. The pattern that works is three touches — one hour, twenty-four hours, seventy-two hours — and both columns are what let a scheduled job pick the right message without re-deriving state from the mail log.

email and phone are nullable and separate from the user record because most abandoned carts belong to guests who typed an email at checkout and never finished. That half-captured email is the entire point of the table.

One index here is redundant

The production version of this migration also declares $table->index('token') alongside $table->unique('token'). A unique constraint is an index in MySQL, so that creates two B-trees on the same column: double the write cost, zero read benefit. It is omitted above. Check your own schema for this — it is an easy thing to add twice and it never announces itself.

The same schema in plain SQL

If you are not on Laravel, the design translates directly. The constraints, not the framework, are the substance:

CREATE TABLE carts (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    identifier      VARCHAR(255)    NOT NULL,
    instance        VARCHAR(255)    NOT NULL DEFAULT 'cart',
    user_id         BIGINT UNSIGNED NULL,
    coupon_code     VARCHAR(255)    NULL,
    discount_amount DECIMAL(10,2)   NOT NULL DEFAULT 0,
    tax_amount      DECIMAL(10,2)   NOT NULL DEFAULT 0,
    shipping_amount DECIMAL(10,2)   NOT NULL DEFAULT 0,
    subtotal        DECIMAL(10,2)   NOT NULL DEFAULT 0,
    total           DECIMAL(10,2)   NOT NULL DEFAULT 0,
    currency        CHAR(3)         NOT NULL DEFAULT 'USD',
    expires_at      TIMESTAMP       NULL,
    created_at      TIMESTAMP       NULL,
    updated_at      TIMESTAMP       NULL,
    PRIMARY KEY (id),
    UNIQUE KEY carts_identifier_instance_uq (identifier, instance),
    KEY carts_user_instance_idx (user_id, instance),
    KEY carts_expires_at_idx (expires_at),
    CONSTRAINT carts_user_id_fk FOREIGN KEY (user_id)
        REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE cart_items (
    id                 BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    cart_id            BIGINT UNSIGNED NOT NULL,
    row_id             VARCHAR(255)    NOT NULL,
    product_id         BIGINT UNSIGNED NOT NULL,
    name               VARCHAR(255)    NULL,
    qty                INT             NOT NULL,
    price              DECIMAL(10,2)   NULL DEFAULT 0,
    tax_rate           DECIMAL(5,2)    NOT NULL DEFAULT 0,
    tax_amount         DECIMAL(10,2)   NOT NULL DEFAULT 0,
    discount_amount    DECIMAL(10,2)   NOT NULL DEFAULT 0,
    subtotal           DECIMAL(10,2)   NOT NULL DEFAULT 0,
    total              DECIMAL(10,2)   NOT NULL DEFAULT 0,
    options            JSON            NULL,
    product_attributes JSON            NULL,
    created_at         TIMESTAMP       NULL,
    updated_at         TIMESTAMP       NULL,
    PRIMARY KEY (id),
    UNIQUE KEY cart_items_cart_row_uq (cart_id, row_id),
    KEY cart_items_cart_product_idx (cart_id, product_id),
    CONSTRAINT cart_items_cart_id_fk FOREIGN KEY (cart_id)
        REFERENCES carts (id) ON DELETE CASCADE
) ENGINE=InnoDB;

What each index is actually for

IndexQuery it serves
carts (identifier, instance) UNIQUEEvery cart load. Also the guest-merge safety constraint.
carts (user_id, instance)"Show this user's saved lists" in account pages.
carts (expires_at)The nightly prune job. Without it, cleanup scans the table.
cart_items (cart_id, row_id) UNIQUEAdd-to-cart dedup. The hot path.
cart_items (cart_id, product_id)"Which carts hold product X" — stock and marketing queries.
cart_abandonments (recovered)The scheduled job selecting carts still needing a reminder.
cart_abandonments (created_at)Time-windowed abandonment reporting.

Every index is a tax on every write. The pruning job index earns its keep because carts are written far less often than they are read; a boolean index on recovered earns it because the scheduler runs constantly against a table that is overwhelmingly already-recovered rows.

Five mistakes that force a repair migration

  1. Making user_id the cart owner. Guest carts become impossible and you retrofit identifier later, against live rows, with no way to reconstruct which session owned what.
  2. Unique on (cart_id, product_id). Works until the first product variant ships, then requires rebuilding line identity for every existing cart.
  3. Joining to products for cart display. A price change rewrites carts in flight. Snapshot at add time.
  4. Float for money. The drift is invisible for months and unfixable in retrospect, because you no longer know which totals were correct.
  5. No expires_at. The table grows without bound, and adding a prune later means deleting millions of rows against a table with no index to find them.

What I would change

Three things, having run this for eight months.

Make cart_coupons (cart_id, code) unique. Covered above. The application guard has held, but the database should not be relying on it.

Add a status column from day one. This schema got order_id and status bolted on two weeks after launch, because "active / converted / abandoned / expired" turns out to be the axis every report queries. Retrofitting a status column means backfilling it from inference, and inference is never fully right.

Store minor units instead of decimal. Single-currency made decimal defensible. The moment a second currency with a different exponent appears, integer cents stops being a preference and becomes the only correct answer.

Where this fits

This schema is the foundation for four things I have written up separately: session persistence, real-time cart count broadcasting with Reverb, abandonment tracking and recovery emails, and merging a guest cart into a user account at login.

The guest-merge article is the one that depends most directly on what is above — the (identifier, instance) unique is the constraint that makes the merge safe rather than best-effort.

Frequently Asked Questions

Should the shopping cart be owned by user_id or session ID?
Neither alone. Use a single identifier column holding whichever identity currently owns the cart — session ID for a guest, user ID once they authenticate — with a composite unique on (identifier, instance). A cart exists before a user does, so a user_id-only design makes guest carts impossible without nullable-owner special cases leaking into every query.
Why can I not put a unique constraint on (cart_id, product_id)?
Because one product in two variants is two separate lines. A customer buying the same t-shirt in medium black and large white needs separate quantities and separate removal. Use a unique on (cart_id, row_id) instead, where row_id is a deterministic hash of the product ID plus its ksort-ed options.
Should I store cart money as decimal or integer cents?
decimal(10,2) is fine for a single-currency store and caps a line at 99,999,999.99. Switch to integer minor units the moment you support currencies with different exponents. Never use float — binary floating point cannot represent 0.10 exactly and cart totals drift by a cent.
How do I stop product prices changing under a customer mid-session?
Snapshot the product into a JSON column (product_attributes) at add-to-cart time and render the cart from that snapshot rather than joining to the live products table. Re-validate against the live product at checkout and surface any delta explicitly to the customer.
How many tables does a production shopping cart need?
Four: carts for the header (owner, totals, currency, expiry), cart_items for line items, cart_coupons for applied discount codes, and cart_abandonments for the recovery lifecycle. A single table with a JSON blob works until you need to query which carts contain a given product, at which point every marketing run becomes a full table scan.
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

How to keep Session-Based Shopping Cart with Database PersistenceFeatured
ArticleIntermediateLaravel

How to keep Session-Based Shopping Cart with Database Persistence

Learn how to build a high-performance e-commerce cart system using session storage for fast operations with database persistence for checkout. Our hybrid approach reduces database load by 40% while improving user experience with instant cart updates and seamless guest checkout.

D
Dinesh Wijethunga
about 2 months ago
2K6m