One Migration, Three Tables: The DDL You Actually Ship
The sketch from Scoping the Capstone API fits on ten lines. Now it becomes SQL, and this is where every hand-wave from the sketch gets a price: a missing unique index, a FLOAT price, a column that cannot hold the value the API will send. The first migration ships three tables — products, stock_levels, users — plus the join table the relationships demand.
-- 0001_create_core.sql
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
password_hash CHAR(60) NOT NULL, -- bcrypt output is exactly 60 chars
role ENUM('admin','worker') NOT NULL DEFAULT 'worker',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
sku VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
archived_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_products_sku (sku)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE stock_levels (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
location_id BIGINT UNSIGNED NOT NULL,
quantity INT UNSIGNED NOT NULL DEFAULT 0, -- on hand can never be negative
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_stock_product_location (product_id, location_id),
CONSTRAINT fk_stock_product FOREIGN KEY (product_id)
REFERENCES products(id) ON DELETE CASCADE,
CONSTRAINT fk_stock_location FOREIGN KEY (location_id)
REFERENCES locations(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Read the file as a set of deliberate answers to the questions your queries will ask:
UNIQUE KEY uq_products_skuis what letsGET /products/{sku}be a single indexed lookup and makesPOSTreturning422 sku_takena database fact rather than a check-then-insert race. The same applies touq_stock_product_location: without it, two concurrent adjustments create two rows for one product-location pair, and everySUM(quantity)permanently double-counts. A unique constraint is an application invariant the database enforces for you.- Types encode policy.
unit_price DECIMAL(10,2)because binary floats cannot represent0.1exactly and totals will disagree by cents.quantity INT UNSIGNEDso a bug that would write −5 fails loudly at the insert.password_hash CHAR(60)because bcrypt output has a fixed length — aVARCHAR(30)silently truncates credentials and locks the user out forever. DEFAULT CHARSET=utf8mb4is not boilerplate: the olderutf8alias in MySQL is three-byte UTF-8 and rejects emoji and rare CJK. A customer namedZoëalready needs it, and a 4-byte-rejecting column is exactly the kind of failure you met in the string-functions material.updated_at ... ON UPDATE CURRENT_TIMESTAMPgives youmeta.updated_atfor free on every write, which is what makes keyset pagination and cache validation possible later.- Indexes follow the query list, not superstition: the unique keys cover
WHERE sku = ?andWHERE product_id = ? AND location_id = ?; pagination sorts byid, already the primary key. Adding an index on every column only slows writes.
Naming conventions matter more than they look: snake_case, singular table names for rows (products plural is the common REST-side convention — pick one and never mix), and constraint names you can recognize in a SHOW ENGINE INNODB STATUS dump six months later.
To apply it: mysql -u app -p inventory < 0001_create_core.sql works once, but a capstone ships a runner — numbered files plus a schema_migrations table so php bin/migrate.php up applies only what is pending, and status shows what has run where. You will build exactly that in the final chapter of this topic.
One warning before you write the second migration: adding a foreign key to a table that already contains orphan rows fails with Cannot add foreign key constraint. The safe order is always create tables first, then ALTER TABLE ... ADD CONSTRAINT once the data is clean. With the tables standing, the next question is what your DELETE statements are allowed to touch — the subject of the next chapter.
What DELETE Does to stock_levels When the Product Still Has History
You type DELETE FROM products WHERE sku = 'A1' on a database where stock_levels and stock_adjustments already reference that row. What happens next is decided entirely by constraints you wrote months ago — and if you wrote none, MySQL's default answer is a surprise.
The default is not cascade. Without a foreign key, the delete succeeds and leaves orphan rows: stock quantities pointing at a product id that no longer exists, adjustments whose product_id can never be joined again. Every unit test on a fresh database passes, then the first monthly report joins products and silently drops those rows. Orphans are the failure mode that produces wrong numbers, not errors — which is why it survives review.
With a foreign key and no action specified, you get the opposite failure: the delete is rejected.
DELETE FROM products WHERE sku = 'A1';
-- ERROR 1451 (23000): Cannot delete or update a parent row:
-- a foreign key constraint fails (fk_stock_product)
That error is a feature when it guards history and a bug when it guards derived state. So each relationship gets an explicit rule:
-- migration 0002: attach the delete policy
ALTER TABLE stock_levels
ADD CONSTRAINT fk_stock_product
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;
ALTER TABLE stock_adjustments
ADD CONSTRAINT fk_stock_product_history
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT;
-- optional reference: an author can be removed, their posts keep a null pointer
ALTER TABLE posts
ADD CONSTRAINT fk_post_author
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL;
| Rule | Child rows on parent delete | Use it for | API surface |
|---|---|---|---|
CASCADE |
deleted with the parent | derived/rebuildable state: stock_levels, cart lines |
delete just returns 204 |
RESTRICT (default) |
parent delete fails | recorded history: stock_adjustments |
translate the SQL error into 409 Conflict |
SET NULL |
FK column set to NULL |
optional references, needs a nullable column | resource still exists, reference shows absent |
The pairing for this domain is the point of the chapter: cascade derived data, restrict recorded events. Current quantity can be recomputed from adjustments, so losing it costs nothing; the adjustment log is the audit trail, and a product deletion must not rewrite it. NO ACTION and RESTRICT differ only in deferred-constraint engines (PostgreSQL distinguishes them; MySQL treats them the same), so RESTRICT is the portable spelling.
Two follow-ups interviewers push on.
First, the application-logic alternative — "I'll delete children first in a transaction" — fails on concurrency: two requests both read the parent, one deletes children and parent, the other's child delete now targets a vanished parent. The constraint is enforced at the row lock, in the engine, on every path that touches the data — including the TRUNCATE-happy intern and the admin script you forgot about. Application checks are for turning that refusal into a friendly 409; they are not the enforcement mechanism.
Second, backfills. You cannot ADD CONSTRAINT on a table that already contains orphans — MySQL fails with Cannot add foreign key constraint. The repair is a cleanup statement you run before the ALTER, and it should be part of the migration:
DELETE sl FROM stock_levels sl
LEFT JOIN products p ON p.id = sl.product_id
WHERE p.id IS NULL; -- purge orphans, then add the constraint
Wrap both statements in one migration so no environment ends up half-constrained — that "which environments have the FK?" question is exactly what the schema_migrations tracker answers, which is where this topic ends: making up repeatable and the seed data worth running against. That is the next chapter.
Seeding Data That Makes Manual Checks and Automated Tests Agree
Schema without data proves nothing: an empty table cannot fail pagination, cannot collide on a unique key, and cannot tell you whether 422 or 409 is what your API actually returns. The seed script is fixture design — you are choosing rows specifically so the behaviors from the acceptance list have something to trip over.
Design the dataset against AC-01…AC-07, not by habit:
| Fixture | Why the suite needs it |
|---|---|
| 121 products | page=2&limit=100 returns 21 items and meta.total = 121 (AC-06) |
one reserved sku |
duplicate POST → 422 sku_taken (AC-02) |
| an admin and a worker account | 403 versus success on DELETE (AC-04) |
| a product with zero stock rows | null/absent handling in GET /products/{sku} |
unit_price like 19.99 |
proves DECIMAL arithmetic, catches a float regression |
names containing café, emoji |
forces the utf8mb4 path end to end |
Then make reruns boring. Seeding happens on every local reset, every fresh CI database, and every developer's second attempt of the day — so the script must be idempotent: run twice, same state, no crash, no duplicate rows.
<?php
// bin/seed.php — CLI, idempotent, safe to run anywhere
declare(strict_types=1);
$pdo = new PDO(getenv('DATABASE_URL'), null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->beginTransaction();
$ins = $pdo->prepare(
'INSERT INTO products (sku, name, unit_price) VALUES (:sku, :name, :price)
ON DUPLICATE KEY UPDATE name = VALUES(name), unit_price = VALUES(unit_price)'
);
foreach (fixtureProducts() as $p) { // yields sku 'SKU-0001'..'SKU-0121'
$ins->execute($p);
}
$pdo->commit();
printf("products: %d\n", $pdo->query('SELECT COUNT(*) FROM products')->fetchColumn());
ON DUPLICATE KEY UPDATE keyed on the unique sku is what makes the second run a no-op-with-fresh-values instead of Integrity constraint violation. The alternative that also works: TRUNCATE and reinsert inside one transaction — but only when nothing references the rows yet (FOREIGN_KEY_CHECKS juggling is a sign the choice is wrong). Blind INSERT is never the answer.
The honest question is where seeds run. Keep two tiers: a small dev/demo seed (php bin/seed.php) humans run by hand, and per-test fixtures created in setUp() for PHPUnit — a test that depends on a seed file existing on the CI box is a test that fails on someone else's machine. Transaction-wrapped tests (roll back in tearDown()) or truncate-and-reseed both work; pick one and state it in the README.
Seeds need a schema, so both run through the migration runner, whose whole job is making up a no-op when there is nothing to do:
migrations/0001_create_core.sql
migrations/0002_add_constraints.sql
migrations/0003_add_indexes.sql
$ php bin/migrate.php status
0001_create_core applied 2026-03-02 09:14
0002_add_constraints applied 2026-03-02 09:14
0003_add_indexes PENDING
$ php bin/migrate.php up && php bin/seed.php
applied 0003_add_indexes — schema up to date
products: 121
The runner's mechanics are thirty lines: a schema_migrations(version, applied_at) table created if missing, filenames compared against applied rows, each pending file executed and recorded inside one transaction with the DDL. That tracker is what makes a deploy safe to re-run, tells you instantly which environment drifted, and gives you the migration half of the rollback story in Implementing the REST Endpoints.
Two discipline rules finish the topic. Migrations are append-only — never edit an applied file, write 0004_... to fix it; environments differ precisely because someone "just tweaked" a file locally. And each migration must be safe against data written by the previous version, because that is the state production is in when the deploy runs.
With tables, constraints, and reproducible data in place, the API layer finally has something real to answer with.
Quiz
1. stock_levels has a UNIQUE(product_id, location_id) index. What failure does it prevent?
2. stock_adjustments.history must survive product deletion, while stock_levels is derived state that can go with it. Which pair of rules is correct?
3. Why must the seed script be idempotent?
4. Your deploy runs migrations. What does a migrations tracking table give you that a folder of .sql files does not?
Flashcards
Question
What belongs in the products / stock_levels / users DDL, and why those column types?
Click to reveal answer
Answer
products: sku UNIQUE, name, unit_price DECIMAL(10,2) (never FLOAT), archived_at NULL for soft delete. stock_levels: product_id + location_id FKs, quantity INT UNSIGNED, UNIQUE(product_id, location_id). users: email UNIQUE, password_hash CHAR(60) for bcrypt, role ENUM or CHECK. All tables get utf8mb4 charset and a TIMESTAMP/ DATETIME created_at.
Question
CASCADE vs RESTRICT vs SET NULL on a foreign key — pick and justify.
Click to reveal answer
Answer
CASCADE deletes child rows with the parent — right for derived state (stock_levels). RESTRICT (the default) blocks the parent delete while children exist — right for history (stock_adjustments), surfacing as a 409 from the API. SET NULL requires a nullable FK — right for optional references like 'deleted author'. Default behavior is RESTRICT/NO ACTION, so orphans require you to deliberately remove the constraint.
Question
What makes a seed script idempotent, and what fixture shapes do tests need?
Click to reveal answer
Answer
Rerunning it must not duplicate rows or fail: use INSERT ... ON DUPLICATE KEY UPDATE keyed by sku/email, or TRUNCATE + insert in one transaction. Fixture data must cover edge cases the API tests assert — 121 products for pagination math, a reserved SKU for the 422 case, an admin and a worker for 403 vs 200, a product with zero stock for null handling.
Question
How does a migration runner with a migrations table work end to end?
Click to reveal answer
Answer
Files are numbered (0001_create_products.sql …); the runner creates a schema_migrations table if missing, compares MAX(version)/rows against filenames, and executes only pending files, inserting a row (and ideally wrapping each in a transaction) as it goes. `status` lists applied vs pending per environment, which is how you spot drift before a deploy.
Question
Why does quantity use INT UNSIGNED but adjustments.delta use a signed INT?
Click to reveal answer
Answer
Stock on hand can never be negative, so UNSIGNED turns 'insufficient stock' into a database error instead of silent negative inventory. Adjustments record direction in the value itself (delta = -10 for a removal), and an UNSIGNED column rejects exactly those rows — the audit table would lose every outbound movement.
Revision Notes
Key Takeaways
- 1. Column types are policy: DECIMAL(10,2) for money, INT UNSIGNED for stock on hand, signed INT for deltas, UNIQUE pairs as invariants — the database enforces what the sketch promised
- 2. Cascade rules decide what a parent DELETE does: CASCADE for derived state, RESTRICT for recorded history (surfacing as a 409), SET NULL only for nullable optional references
- 3. Every table carries utf8mb4 and a created_at/updated_at; indexes follow the query shapes you already know from the endpoint list (filter by sku, paginate by id)
- 4. Seed data is fixture design: 121 rows for pagination, reserved SKUs for 422 paths, admin + worker users for 403 — rerunning must be safe
- 5. A migrations tracking table makes `up` idempotent and `status` a drift report, so deploys apply pending changes exactly once per environment
Interview Tips
- • "Two requests buy the last unit simultaneously — what stops overselling?" — the UNIQUE(product_id, location_id) row lock plus an UPDATE ... WHERE quantity >= 1 with row-count check; mentioning SELECT ... FOR UPDATE shows depth
- • "Delete a product with history — what happens?" — walk the two tables separately: CASCADE the derived stock row, RESTRICT the adjustments and return 409; the split answer is what distinguishes you from someone who says 'cascade everything'
- • "Why not FLOAT for price?" — binary floating point cannot represent 0.1 exactly, so totals disagree; DECIMAL(10,2) or integer cents, applied consistently
- • "How do you keep environments in sync?" — numbered migrations plus a schema_migrations table; `status` shows applied vs pending, seeding is idempotent, and both run from the same commands in CI and on the server
Cheat Sheet
Modeling the Database Schema
DDL decisions (products / stock_levels / users):
unit_price DECIMAL(10,2) -- money: never FLOAT
quantity INT UNSIGNED -- stock on hand ≥ 0
delta INT -- signed: −10 means a removal
password_hash CHAR(60) -- bcrypt is exactly 60 chars
UNIQUE KEY uq_products_sku (sku)
UNIQUE KEY uq_stock_product_location (product_id, location_id) -- stops double-counting
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -- 4-byte UTF-8 (utf8 alias is a lie)
Delete policy — cascade derived, restrict history:
| Rule | Child rows on parent delete | For | API |
|---|---|---|---|
ON DELETE CASCADE |
removed with parent | stock_levels (rebuildable) |
204 |
ON DELETE RESTRICT (default) |
delete refused, ERROR 1451 |
stock_adjustments (audit) |
409 Conflict |
ON DELETE SET NULL |
FK set to NULL |
optional refs (needs nullable col) | reference absent |
No FK ⇒ orphan rows that pass tests and corrupt reports. App-level "delete children first" loses to concurrency; the engine enforces constraints on every path. ADD CONSTRAINT fails on existing orphans — purge first, in the same migration.
Seeding (idempotent):
INSERT INTO products (...) VALUES (...)
ON DUPLICATE KEY UPDATE name = VALUES(name) -- keyed on the unique sku
Fixtures are designed from acceptance cases: 121 products (pagination math), reserved SKU (422), admin + worker (403 vs 200), zero-stock product, 19.99 prices, non-ASCII names. Dev seed = php bin/seed.php; per-test fixtures live in setUp() — never depend on a shared seed in CI.
Migration plan: append-only numbered files + schema_migrations(version, applied_at); up applies pending files in one transaction each, status reports drift. Never edit an applied file; every migration must be safe against data written by the previous version.