Skip to content
intermediate Phase 16 · Capstone: Production PHP Application

Scoping the Capstone API

Define resources, endpoints, and acceptance criteria up front.

45m
0 problems
Topic Progress 0%

From "We Need Inventory Tracking" to a Verbed Endpoint List

A stakeholder hands you one sentence: "build us an inventory API so the warehouse can track stock." That sentence contains no software. Your first task on the capstone is to convert it into a concrete endpoint list, and the conversion rule is mechanical: nouns become plural resources, verbs become HTTP methods, and every row gets its status codes decided now — not while you are typing the handler.

Start by pulling the nouns out of the sentence. Inventory work gives you four: products, stock (how many units sit at a location), adjustments (the events that change stock), and users. That is enough for a first cut. Write the list as a table before touching an editor:

Method + path Meaning Success Failures to decide now
GET /products list, paginated 200 —
GET /products/{sku} one product 200 404
POST /products create 201 + Location 401, 422
PUT /products/{sku} full replace 200 401, 404, 422
DELETE /products/{sku} remove 204 401, 404
POST /stock-adjustments record a change 201 401, 422

Two design decisions inside that table are worth more than the rest of the plan combined.

Actions become resources. The PM will ask for "an endpoint that increases stock." Do not build POST /products/{sku}/increaseStock — build POST /stock-adjustments with {"sku":"A1","delta":10,"reason":"restock"}. An adjustment is an event: it has a timestamp, an actor, and a reason, so GET /stock-adjustments later gives you the audit history for free, and the new-quantity question is answered by reading stock, not by trusting whatever number a client sent. The same move handles "reset password" (POST /password-resets) and "apply a discount" (POST /discount-rules). When an operation resists a clean verb, that is a signal the noun is missing, not a signal to invent a URL.

Verbs stay out of paths. GET /getProducts, POST /updateStock, DELETE /deleteProductBySku is RPC wearing a REST costume. Leaking verbs into paths means every route invents its own method semantics, caches cannot treat GET as safe, proxies cannot replay idempotent requests, and your error handling fragments across twenty near-identical handlers. The uniform mapping — list GET, create POST, full replace PUT, partial PATCH, remove DELETE — is what makes one generic routing and error layer possible later.

Naming discipline pays off immediately: plural nouns (/products, not /product), identifiers in the path rather than the body (/products/{sku}), at most one level of nesting (/products/{sku}/stock), and no query strings for things that identify a resource. Reserve query parameters for modifiers: filtering (?status=active), sorting (?sort=-updated_at), and pagination (?page=2&limit=50) — all of which you will implement in Implementing the REST Endpoints.

Why front-load this? Because every downstream artifact copies the table verbatim. The schema sketch in Modeling the Database Schema is derived from the resource nouns; the PHPUnit suite in Covering the API with PHPUnit asserts one test per row and per decided failure code. If a status code is undecided at planning time, the test cannot be written, and an untested behavior is an unshipped behavior. Your CI from Shipping with a CI Pipeline will happily run a suite that asserts nothing useful — green builds prove nothing on their own.

Interviewers ask this exact question in some form: "design an inventory API." The candidates who score well draw the resource table first, defend one action-as-resource decision out loud, and only then discuss auth and pagination. Candidates who start by writing handlers are already debugging their own design.

The last step is deliberately subtractive: tag each row must / should / could. Barcodes and multi-location stock are could. Pagination and auth on writes are must. Anything you cut disappears from the endpoint list entirely — a half-built resource that answers 501 is worse for the contract than no resource at all. With the list frozen, the next move is sketching the tables it implies.

Sketching the Tables Before Any Migration Exists

The endpoint list tells you what clients can ask for. It says nothing about what the answer is stored as — and that gap is where capstone projects die halfway through. Before writing a single migration, sketch the entities on paper (or in a scratch file) and force every relationship to answer three questions: how many, nullable, and what happens on delete.

A sketch for the resources from the previous chapter takes ten lines:

users        1 ──── * stock_adjustments
products     1 ──── * stock_levels        * ──── 1 locations
products     1 ──── * stock_adjustments

users:        id PK, email UNIQUE, role ENUM(admin,worker), password_hash, created_at
products:     id PK, sku UNIQUE, name, unit_price DECIMAL(10,2), archived_at NULL
locations:    id PK, code UNIQUE, name
stock_levels: id PK, product_id FK → products, location_id FK → locations,
              quantity INT UNSIGNED, updated_at, UNIQUE(product_id, location_id)
stock_adjustments: id PK, product_id FK, location_id FK, user_id FK,
                   delta INT (signed!), reason, created_at

Every line above is a decision the endpoint list only implied:

  • Cardinality. stock_levels is * ──── 1 per location because "how many of A1 at dock 3" must be one row — that is what forces UNIQUE(product_id, location_id). Without the unique index, two concurrent adjustments create duplicate rows and every SELECT silently double-counts. Unique constraints are how a sketch becomes a correctness guarantee rather than documentation.
  • Nullability. Is stock_levels optional for a product? If a brand-new product has zero locations, the answer is yes — which in turn decides that DELETE /products cannot blindly cascade into rows you may still want to report on. Nullable columns are policy, so each one gets justified in the sketch.
  • Signed vs unsigned quantity. stock_levels.quantity is INT UNSIGNED (you cannot hold −3 units), while stock_adjustments.delta is signed because it routinely equals −10. Getting this backwards makes the audit table reject the exact rows it exists to store.
  • Money. unit_price DECIMAL(10,2) — never FLOAT, whose binary representation makes 0.1 + 0.2 unequal 0.3 and produces reconciliation bugs that only appear in totals. Store cents as INT if you prefer, but pick one and apply it everywhere.

Then the delete question, which interviewers love: a product is deleted while stock rows and adjustments still reference it. Three legitimate answers, each with a different blast radius:

FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT;   -- block the delete (409 to the client)
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;    -- erase history with the row
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE SET NULL;   -- needs a nullable column

CASCADE on stock_levels (derived state can be rebuilt) and RESTRICT on stock_adjustments (history must not vanish) is the pairing that fits an inventory domain: cascade derived data, restrict recorded events. The alternative — no foreign key at all — produces orphan rows that pass every unit test and corrupt every report.

Column choices belong in the sketch too, because they are contract decisions: sku VARCHAR(64) bounds the 422 payload you will validate against, role ENUM('admin','worker') decides whether adding a manager role later costs a migration (it does), and an archived_at DATETIME NULL instead of a delete flag tells you that DELETE /products may eventually become a soft archive without changing its endpoint. Ten minutes here removes a week of migration rewrites — every column you defer is a column you will add under a running API.

The sketch's final job is contradiction-hunting. If GET /products/{sku} should return a quantity but the sketch has no path from product to stock, the list and the model disagree — resolve it now, while changing your mind costs a pencil stroke. Once the entities, keys, and cascade rules agree with the endpoint list, they become real in Modeling the Database Schema, where the same decisions appear as CREATE TABLE statements and a migration order you can run repeatedly.

Writing the Acceptance List That Becomes the Test Suite

Two artifacts now exist: the endpoint list and the entity sketch. The third completes the plan, and it is the only one that tells you when you are done — a numbered acceptance list of observable behaviors. Each line states a request, the exact response a client will see, and nothing about implementation.

Write it as Given/When/Then so the sentence already reads like a test:

AC-01  Given an empty database, When POST /products with valid JSON,
       Then respond 201, Location: /products/SKU-1, and the body contains that sku.
AC-02  Given the same request twice with the same sku,
       Then the second responds 422 with error.code = "sku_taken".
AC-03  Given no Authorization header, When POST /products,
       Then respond 401 with WWW-Authenticate: Bearer.
AC-04  Given an authenticated worker (non-admin), When DELETE /products/{sku},
       Then respond 403 — authenticated, but not permitted.
AC-05  Given a token, When GET /products?limit=500,
       Then the limit is capped at 100 and the response reports meta.limit = 100.
AC-06  Given 121 products, When GET /products?page=2&limit=100,
       Then page 2 holds 21 items and meta.total = 121.
AC-07  When GET /products/NOPE, Then 404 with the same error envelope as AC-02.

Three properties make this list useful rather than decorative:

Every line is externally observable. Status code, header, body field. Nothing says "the service validates the input" — validation is only proven when a specific bad payload produces 422 with a specific error code. That constraint is exactly what lets a test assert it: the acceptance line and the PHPUnit method are the same statement in two syntaxes, which is why AC-03 becomes test_unauthenticated_create_returns_401 with no interpretation step in between.

Failures are enumerated, not implied. Notice 401 (no credentials) and 403 (wrong role) are separate lines. Conflating them is one of the most common API mistakes, and a plan that never distinguishes them guarantees the implementation will not either. Same for 404 on read versus 422 on malformed body: a client cannot write correct error handling against an unspecified error.

Numbers are concrete. meta.total = 121 with limit=100 is testable; "pagination works" is not. Pick the awkward cases now — exactly one page, a page past the end (page=99 → empty data, 200), a limit above the cap — because those are where off-by-one bugs hide and where interviewers drill next.

The list also becomes the scope knife. Tag each item must/should/could against the same three tiers used for endpoints; if AC-06 is must but barcode scanning was only could, you know precisely what ships in v1. A behavior you refuse to assert is a behavior you have decided not to ship — that sentence is your definition of done, and it is what you tell a stakeholder who asks "is it ready?"

Budget the writing: for a four-resource API, thirty to forty lines takes under an hour and survives the whole build. Skipping it means the first honest review of your work happens during the demo.

One process rule ties it together: write the failing test before the handler, always. When a bug escapes anyway — and one will — the fix starts with an acceptance-style test that reproduces it, so the regression is documented permanently rather than "verified once on my machine." That discipline is the subject of Covering the API with PHPUnit; the plan you have now — resources with decided status codes, a sketch that agrees with them, and numbered behaviors — is the checklist that suite will be built against.

Quiz

1. A PM asks for an endpoint that increases a product's stock. Which design keeps the API consistent with the rest of the resource list?

Question 1 options

2. Your endpoint list says POST /products returns 201 with a Location header, but the list never decides what invalid input returns. What is the actual consequence?

Question 2 options

3. The endpoint list reads GET /getProducts, POST /updateStock, and DELETE /deleteProductBySku. What is wrong with this list?

Question 3 options

4. While sketching the data model you discover that a product can exist with no stock row, and stock rows reference a location table you never listed as a resource. What does that tell you?

Question 4 options

Flashcards

Question

How do you turn a vague feature request into an endpoint list?

Answer

Extract the nouns → resources (plural, path-named), assign each operation an HTTP verb (GET list/read, POST create, PUT replace, PATCH partial, DELETE remove), and decide the status code for success and every failure per row. Any operation that resists a verb is a modeling problem, not a URL problem.

Question

How do you model an action like 'reset password' or 'increase stock' in a REST API?

Answer

Model it as a noun-resource: POST /password-reset or POST /stock-adjustments with a body describing the change. The side effect is stored (token row, adjustment row), so it is auditable and listable via GET, instead of being an invisible state change hidden in an action path.

Question

What does sketching the data model before writing migrations actually catch?

Answer

Cardinality and nullability questions the endpoint list implies but never states: is stock optional per product, can a user own many adjustments, is SKU unique globally or per location, what happens to child rows on delete. Each answer becomes a column, constraint, or cascade rule instead of a mid-project migration rewrite.

Question

What is an acceptance list, and why must it be written before the code?

Answer

A numbered list of observable behaviors ("POST /products with valid JSON → 201 and the body contains the SKU"; "unauthenticated POST → 401") written as Given/When/Then. It is the checklist the PHPUnit suite is generated from, so a feature is done when its behaviors are asserted, not when the code looks finished.

Question

How do you cut capstone scope without breaking the contract?

Answer

Sort features into must/should/could before building, cut from the bottom, and ship only must-list endpoints in v1 — but never change the status codes, paths, or envelope of an endpoint already published to clients or tests. Scope is cut by removing resources, not by weakening the semantics of the ones you keep.

Revision Notes

Key Takeaways

  • 1. Nouns become plural path resources and operations become HTTP verbs; a status code for success and each failure is decided per endpoint during planning, not during implementation
  • 2. Verb-shaped actions (increase stock, reset password) are modeled as noun resources so the effect is recorded, listable, and auditable
  • 3. The data-model sketch is where cardinality, nullability, and delete rules get decided — contradictions with the endpoint list are found here or found in production
  • 4. Every acceptance behavior is written as an observable request/response pair so it maps directly onto a PHPUnit test name
  • 5. Scope is cut by dropping resources from the must/should/could bottom tier; an endpoint already asserted by tests never changes its contract

Interview Tips

  • • "Design an inventory API" — open with resources and verbs, then status codes, then pagination and auth; interviewers reward the order because it shows you model before you code
  • • "How would you implement 'apply a discount' / 'reset password'?" — answer with a noun-resource (POST /discount-rules, /password-reset) and explain that storing the event gives you an audit trail and idempotency options
  • • "How do you decide when an API is ready to build?" — when every endpoint has a method, path, success code, failure codes, and at least one acceptance behavior; name the acceptance list as your definition of done
  • • "What do you do when the data model contradicts the endpoint list?" — you reconcile in the sketch phase, decide the rule (cascade, restrict, hidden resource), and record it as an acceptance case; never discover it in a migration halfway through

Cheat Sheet

Scoping the Capstone API

Nouns → resources, verbs → methods (decide status codes during planning):

Operation Method + path Success Failures
list (paginated) GET /products 200 —
read one GET /products/{sku} 200 404
create POST /products 201 + Location 401 422
full replace PUT /products/{sku} 200 401 404 422
partial update PATCH /products/{sku} 200 401 404 422
remove DELETE /products/{sku} 204 401 404

Actions are resources: POST /stock-adjustments ({sku, delta, reason}), POST /password-resets, POST /discount-rules — the event is stored, so GET on the same path returns the audit history. No verbs in paths (/getProducts is RPC), plural nouns, one nesting level, query strings only for filter/sort/pagination.

Sketch checklist (answer before any migration): cardinality (1 ── *, * ── 1), nullability, unique constraints (UNIQUE(product_id, location_id) stops double-counting), signed vs unsigned (delta INT signed, quantity INT UNSIGNED), money as DECIMAL(10,2) not FLOAT, and delete rules:

FOREIGN KEY (product_id) REFERENCES products(id)
  ON DELETE CASCADE;    -- derived state (stock_levels)
  ON DELETE RESTRICT;   -- recorded history (stock_adjustments) → 409 to client

Acceptance list = the test suite. Given/When/Then, externally observable only (status, header, body field), concrete numbers, failures enumerated — 401 no credentials vs 403 wrong role vs 404 unknown vs 422 bad body. AC-03 becomes test_unauthenticated_create_returns_401 with zero interpretation.

Scope: tag endpoints and acceptance lines must/should/could, cut from the bottom. Definition of done = every must behavior has a passing test; an endpoint with an unspecified contract does not ship.

Continue Learning