Zyel Lab / Coding tutorial / 03
Build the decision.
Then the interface.
A dealer portal is a series of decisions: what can be ordered, who can approve it, which price is authoritative, and what happens when a request is repeated. Make those rules explicit before making the page look finished.
By Chason Arthur · Zyel Lab · Tested dependency snapshot: October 25, 2026
Open the working demo ↗ · Setup reference · Verification evidence
Open the original computer-tutorial script and storyboard. Chapter links preserve these eight implementation steps; the browser scene links only navigate and focus existing regions.
Step 01 / Reproduce
Start with a repeatable environment.
Objective and prerequisites
Build the browser artifact and boot a disposable backend test environment. Use the existing repository in an isolated branch. You need Node 24, npm, PHP 8.4 with mbstring, OpenSSL, PDO SQLite, DOM/XML and fileinfo, and Composer. Do not copy a customer database or production environment file.
Recommended implementation and explained code
# From the repository root
cd lab/dealer-portal
npm ci
npm run typecheck
npm run build
npm test
cd backend
composer install --no-interaction --no-scripts
php vendor/bin/phpunit --colors=never
cd ../../..
LAB_PORT=4195 node lab/tools/serve.mjs .The lockfiles hold exact transitive dependency versions. dist/app.mjs is a browser-ready bundle; the public host does not need Node or a runtime build. The backend tests set APP_ENV=testing, an intentionally public test-only encryption key and an in-memory SQLite database. No login endpoint or real credential is created.
On Windows, scripts/php-test.ps1 supplies PHP extension flags to this process only. Set $env:LAB_PORT='4195' before the preview command. Do not change the machine's PHP configuration just for the demo.
Practical alternatives, tradeoffs and choice
A Docker development environment can standardize MySQL and PHP across a team, at the cost of another runtime and resource budget. An existing isolated development service is another option. The local SQLite path is small and reproducible here; it cannot establish MySQL locking or execution plans.
Common pitfalls
Do not use a floating CDN version, run migrations against an inherited database connection, reuse the public test key outside tests, or treat a successful TypeScript build as a backend test.
Verification and result before proceeding
Check php -m, run the commands, and open the local portal. The expected result is a labeled fixture page, seven domain tests and ten Laravel tests passing. The tested versions are Vue 3.5.44, TypeScript 7.0.2, esbuild 0.28.2, PHP 8.4.24, Laravel 13.38.0, PHPUnit 13.4.3 and Mockery 1.6.15. Frontend lockfile · Backend lockfile in the repository
Step 02 / Contract
Keep money, identity and history explicit.
Objective and prerequisites
Define a catalog-to-quote-to-order contract before adding buttons. Complete Step 1 and read src/domain.ts and the migration. The catalog has six invented products; dealers and user identities are synthetic. Fixture dealer IDs D001/D002 connect to the Lab's synthetic network by identifier, not to real businesses.
Recommended implementation and explained code
interface CartItem { sku: string; quantity: number }
interface QuoteLine extends CartItem { unitPriceCents: number }
interface Quote {
id: string; items: QuoteLine[]; subtotalCents: number;
status: 'pending' | 'approved' | 'converted'; expiresAt: number;
}The browser sends SKU and quantity. The server looks up its own unit price and stores a snapshot in each quote line. Integer cents avoid floating-point currency arithmetic. A quote expires after 24 hours; a converted quote has at most one synthetic order. A unique dealer/request key identifies retries. Audit events store event codes, internal IDs and time rather than free-text customer material.
dealers own users and quotes. quote_lines reference catalog SKUs. demo_orders.quote_id is unique. Foreign keys enforce relationships. The quote index begins with dealer_id and status because those are the filter predicates.
Retry an existing decision before evaluating a new one
The fixture checks the buyer role, an 8–80-character request key, and exactly SKU/quantity fields before touching its cache. Quantities must be whole numbers from 1–100; the array has 1–10 unique known products. It maps each item to {sku,quantity} and sorts by SKU, so item order and object-property insertion order do not change a logical request. An identical key/body returns a detached original quote, including its original expiry and converted status, before a new-stock check. A changed body with that key conflicts; a new key still checks current stock. Returning an old decision does not renew it or authorize a new action.
const sorted = items.map(({sku,quantity}) => ({sku,quantity}))
.sort((a,b) => a.sku.localeCompare(b.sku));
const previous = this.quoteRequests.get(key);
// Existing key: compare canonical body; return original decision.
// New key: now check current stock before creating a quote.Always applying current availability to a retry was a concrete defect: requesting 64 kits, converting to stock 0, then retrying the original quote incorrectly failed. The regression now proves the same quote is returned with no audit, stock, expiry or map mutation, including after a validated in-memory restore. Order keys use the same bounded fixture format. The Laravel controller separately requires UUID keys and its own HTTP validation; the server service already looked up scoped existing keys first. No backend or historical CI result changed for this fixture fix. Actual implementation cut, alternatives and pitfalls · Run the isolated fixture contract check
Practical alternatives, tradeoffs and choice
JSON line items simplify a prototype but weaken relational constraints and reporting. Fully normalized pricing/version tables support complex contracts but increase scope. This demo uses relational line snapshots: enough history to explain a decision without building a pricing platform.
Common pitfalls
Never accept client totals, store monetary decimals in JavaScript floats as authoritative values, or make a browser-supplied dealer ID the tenancy boundary. A displayed estimate is not a committed order.
Verification and result before proceeding
Run the server-price and duplicate-request tests. Two SVC-100 units produce 25,000 cents, a repeated key returns the same quote, and a changed body with that key returns 409. The domain regressions also request 64 kits, convert to stock 0, then retry the original body/key without renewing expiry or changing stock/audit/maps. A changed body conflicts, a new key checks current stock, and item/property order is canonical. Proceed with a schema and contract whose invariants can be named and tested.
Step 03 / Interface ownership
Let Vue and DataTables own different surfaces.
Objective and prerequisites
Render a usable catalog with persistent table preferences and an accessible product selector. Complete the data contract. Know which component owns each DOM subtree.
Recommended implementation and explained code
// Vue owns the surrounding workflow; the shared table owns this host.
table = await typedTable({
element: '#catalog-table', tableId: 'dealer-catalog',
columns: [{key: 'sku', title: 'SKU'}, /* stable named columns */],
data: rows(), onSelect: selectProduct
});
// Update the table through its API; never Vue v-for inside its tbody.
table.replace(rows());
// Bootstrap/restore discloses selection without changing the saved page.
table.select(selected.value.sku, {reveal:false});The compiled Vue app owns cart lines, role previews and notices. DataTables 3.1.4 with ColReorder 3.1.2 owns the catalog. The shared table adds keyboard sort controls, column filtering, visibility, order and a native readable fallback. A separate select control can choose every product without relying on pointer interaction in the table.
View preferences use zyel.lab:v2:demo:dealer-portal:view; table state has its own route/table namespace. The shared contract validates data, migrates supported version-1 preferences and chooses 30-day expiry deliberately instead of DataTables' default 7,200 seconds. Storage-denied and quota cases fall back to page memory. Cart, quotes, orders and the audit trail do not enter localStorage. These preferences are device/browser-local, not account sync.
Bootstrap now selects the default product with reveal:false. A saved second page remains the second page; the table status tells you when the selected product is elsewhere. Explicit product selection can reveal its page. Test both behaviors: automatically revealing the default record used to rewrite the saved page on reload. Reset table preferences clears this table’s configuration and updates its controls without reloading or discarding your cart/quote/audit. Restart synthetic session opens the guarded recording-scene confirmation.
Practical alternatives, tradeoffs and choice
The official Vue DataTables component can manage reactive data for a larger Vue application. A native table is lighter but needs sorting/paging work. The Lab's shared wrapper keeps one persistence implementation across demos and makes ownership clear. TypeScript boundary types describe that JavaScript API; they do not validate runtime inputs.
Common pitfalls
Do not combine v-for rows and DataTables DOM mutation. Do not use user content as a Vue template or v-html. Do not store session tokens or private records in a preference export. After a category changes, return to page one so a formerly valid page cannot appear empty.
Verification and result before proceeding
Change category, sorting, page length and column visibility, then reload. Check the separate product selector with a keyboard and confirm storage contains configuration only. Proceed when the catalog remains usable with enhanced table loading disabled. DataTables Vue guidance · State duration · Vue security
Step 04 / Boundary
Authorize the action where the record lives.
Objective and prerequisites
Protect the server independently of the displayed role. Complete the contract and interface ownership. The browser role selector is conspicuously simulated and is never an authentication credential.
Recommended implementation and explained code
// Controller: tenant scope before lookup, then server-side policy.
$quote = Quote::where('dealer_id', $request->user()->dealer_id)
->whereKey($id)->firstOrFail();
Gate::authorize('approve', $quote);
// Schema: bounded items, allowed fields, whole quantities.
'items' => ['required', 'array', 'min:1', 'max:10'],
'items.*' => ['required', 'array:sku,quantity'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],The policy checks the server-resolved user role and dealer. A foreign dealer's quote returns 404. A buyer cannot approve; a reviewer cannot create an order. SKU validation uses a bounded simple pattern plus catalog existence checks; it is format validation, not authorization. The middleware rejects bodies larger than 16 KiB and refuses to run unless the explicit demo flag and local/testing environment both match. Routes use session authentication, request-forgery middleware and a 30-per-minute limiter. No demo login is supplied.
Practical alternatives, tradeoffs and choice
OAuth or an existing identity gateway can establish identity; a server session can also work. Both require approved infrastructure and cookie/token lifecycle decisions. Tests use Laravel's actingAs with synthetic users to verify the policy without adding an identity system. The file cache profile can persist an isolated single-host limiter; a multi-host deployment needs an approved shared store and edge controls. The test array cache is only process-local.
Common pitfalls
Hidden buttons are not authorization. Never trust a role header, make an ID lookup before applying tenancy scope, leak other dealers' audit rows, or claim a regex or application limiter alone prevents denial of service.
Verification and result before proceeding
Run the guest, role, tenant, malformed-price and oversize tests. Expect 401, 403, 404, 422 and 413 at their respective boundaries with no quote writes. Proceed only when the protected API remains protected even if every browser control is bypassed. Laravel authorization · Validation
Step 05 / Transaction
Commit one complete decision.
Objective and prerequisites
Make retries safe and prevent a partial order. Complete server authorization. Use one database for quote, stock, order and audit records.
Recommended implementation and explained code
return DB::transaction(function () use ($actor, $quote, $key) {
$locked = Quote::whereKey($quote->id)->lockForUpdate()->firstOrFail();
// Return an existing same-key result before changing stock.
// Require approved + unexpired; lock products in stable SKU order.
// Re-check stock, decrement, create order, convert quote, append audit.
return [$order, true];
}, 3);QuoteWorkflow.php implements those comments. A quote request key is bound to a canonical sorted cart hash. The same key/body returns the same result; another body returns 409. The order path locks the quote, checks prior requests and locks products in sorted SKU order. A unique quote-to-order constraint backs up the application rule. All state changes and the audit event share the transaction. If any write fails, the database rolls them back.
Practical alternatives, tradeoffs and choice
A queue plus transactional outbox is appropriate when a committed order must trigger external work. It adds workers, retries and delivery semantics. This bounded demo performs no external fulfillment, so a single transaction is sufficient. Optimistic version checks can reduce lock contention, but callers must handle conflicts explicitly.
Common pitfalls
Do not make a payment or send an email inside a retried transaction. Do not decrement stock before checking the idempotency key, assume an application check replaces a unique constraint, or claim SQLite's transaction behavior proves InnoDB's row-lock behavior. A new key for an already converted quote is rejected.
Verification and result before proceeding
The suite injects a failure when the audit event is created after order/stock changes. It verifies no order, original stock and an approved quote remain, then retries successfully with the same key. Proceed with all-or-nothing behavior proven on SQLite. MySQL concurrency and row-lock verification remain a separate gate. Laravel database transactions
Step 06 / Recovery
Give waiting, failure and empty states a job.
Objective and prerequisites
Keep the workflow understandable when there is no cart, a request is pending, or storage fails. Complete transaction rules before simulating their UI.
Recommended implementation and explained code
const generation = ++requestGeneration;
await delay(activeRequest.signal);
if (generation !== requestGeneration || activeRequest.signal.aborted) return;
action(); // Fixture mutation occurs only after the current request is accepted.
function cancel() {
activeRequest?.abort(); ++requestGeneration;
busy.value = false;
}The demo uses a 300 ms fixture delay to make cancellation observable; that delay is not a performance measurement. A request generation and AbortController prevent stale fixture work from committing. Busy controls prevent double clicks. Status text is announced politely, errors use an alert, every input has a label, and actions remain available by keyboard. The empty cart explains the first action. A failed conversion preserves the approved quote and uses the same retry key. After Vue applies a transition, keyboard focus moves to the persistent role selector, the completed receipt heading, or the initiating action after cancellation. It never targets the disabled next button or silently falls back to the page body.
Temporary recording scenes and safe restore
Restart now opens an explicit confirmation. Before loading a clean synthetic scene, the route validates and retains the first settled session in private memory: catalog stock, request maps, quote/order IDs, cart, role preview and audit history. Repeated resets keep that original checkpoint. Pending fixture work is invalidated and is never replayed. Restore validates every reference and price/stock invariant before changing current state; malformed checkpoints fail closed. A reload deliberately loses both business records and the checkpoint.
// Dispose the persistent table before temporary data can reach its writer.
table?.destroy(); table = null;
sceneActive.value = true;
// All scene tables and their search/sort/visibility changes remain in memory.
await mountSceneTable(false, null); // persistState:false
// Category watchers skip preference writes until the original is restored.This in-memory checkpoint is not an export, account sync or customer-data recovery system. Appearance remains the user's current global setting; the recording controls never restore an older global preference. A full page reload is a simpler alternative but loses the original workflow. Serializing business state would survive reload at the cost of privacy and migration obligations, so this lesson deliberately avoids it. Verify repeated reset/restore, cancellation during a request, storage denial and interrupted reload separately from the ordinary happy path.
Practical alternatives, tradeoffs and choice
Optimistic updates can feel faster for reversible preferences. A quote/order transition deserves an authoritative result before advancing. A live network adapter must also reconcile an uncertain timeout by querying the original idempotency key: aborting a browser fetch does not undo a committed server transaction. The fixture cancels before mutation; it does not claim to simulate that network uncertainty.
Common pitfalls
Do not show success on click, erase a recoverable quote after an error, disable all navigation forever, rely on color alone, or infer backend acceptance from the public fixture. Respect global reduced-motion preferences and avoid trapping focus when the status changes.
Verification and result before proceeding
Walk through Buyer → quote → Reviewer → approval → Buyer → order. Select the conversion-failure checkbox, observe no receipt, then retry. Cancel a quote request while pending; no quote should appear. Run the E2E test at desktop and 390 px widths. Proceed when each state has a clear next action and no sensitive record persists after reload.
Step 07 / Evidence
Measure the query you actually run.
Objective and prerequisites
Compare the same result before and after an index, then exercise the whole decision path. Complete the earlier steps. Use disposable synthetic data and record the engine, query, bindings, row count and method.
Recommended implementation and explained code
# Windows, from lab/dealer-portal
./scripts/php-test.ps1 -Benchmark
# Other environments with PDO SQLite enabled:
php backend/tools/benchmark.php
CREATE INDEX dealer_status_recent
ON quote_history(dealer_id, status, created_at);The script seeds 50,000 deterministic rows across 250 dealers in an in-memory SQLite database, performs five warmups and thirty measured queries, uses hrtime, and compares result hashes. It records the raw plan. In the saved run on PHP 8.4.24 / SQLite 3.53.2 / Windows, the plan changed from a table scan plus temporary sort to an indexed search. Median query time was 2.363050 ms before and 0.008200 ms after. Both returned the same twenty rows. These are single-process, warm in-memory synthetic measurements; they do not predict network latency, concurrent load, disk I/O or MySQL performance.
Raw measurement and method · Benchmark source · Prepared MySQL plan commands
Executed MySQL follow-up. The hosted CI run completed successfully on October 25, 2026 for source d0b0ae1. Its MySQL 8.4.13 / PHP 8.4.27 Linux job passed the same ten Laravel tests with 46 assertions. A connection-local temporary InnoDB table held 50,000 synthetic rows. After five warmups and thirty runs, median/p95 were 8.536230 / 8.717561 ms before the index and 0.233308 / 0.249967 ms after. The raw plan changed from a 50,000-row scan plus sort to a reverse index lookup; both measurements returned the same twenty-row hash. This is a warm single-client query slice, with no claim about production throughput, distributed concurrency or persistent identity. The workflow requested PHP 8.4.24; the runner reported 8.4.27, so that observed runtime is recorded here.
Raw MySQL plans and method · CI source/run provenance · Guarded MySQL benchmark source
Practical alternatives, tradeoffs and choice
MySQL EXPLAIN ANALYZE should be used on an approved isolated InnoDB fixture. A production trace can reveal realistic distributions but carries privacy and operational costs. This demo uses deterministic synthetic data so anyone can reproduce the comparison. The benchmark table is a focused quote-history slice, not a throughput test of the entire application.
Common pitfalls
Do not compare different result sets, claim a ratio from one sample as production capacity, test destructive DDL on a live database, or report the prepared CI recipe as a completed hosted run. The migration's final ordering also includes an ID tie-breaker; inspect the actual MySQL plan for any remaining sort.
Verification and result before proceeding
Run seven domain tests, ten Laravel HTTP/database tests, TypeScript checking, build, and the rendered E2E. The PHP suite has 46 assertions, including tenant leakage and rollback/retry. The E2E checks the visible workflow, cancellation, storage boundary and mobile layout. Proceed with raw evidence and explicit untested areas. MySQL EXPLAIN reference
Step 08 / Release
Ship a bounded artifact with a way back.
Objective and prerequisites
Prepare a reviewable release without turning the Lab into production ordering infrastructure. Complete tests and independent review. Publication remains a separate approved action.
Recommended implementation and explained code
# Public static allowlist for this route
index.html tutorial.html recording.html portal.css
recording-plan.mjs recording-guide.mjs recording-snippets.mjs
teaching-examples.mjs retry-contract.mjs
dist/ source/ evidence/
README.md package.json package-lock.json
# Exclude: backend/ node_modules/ .tools/ tests/ .env
# CI recipe: ci/dealer-portal.yml
# Run a disposable MySQL service, composer install, PHPUnit,
# TypeScript checks, deterministic build, domain tests and rendered E2E.
# Expanded browser recipe also runs recording/session/storage/teaching/retry checks.The repository keeps backend source for inspection and isolated tests. Never copy that directory into a public static release. A later approved backend must serve only backend/public from a separate isolated document root, use a disposable demo database, and connect an existing approved identity/session system. Default API configuration remains disabled. The Apache deny file is defense in depth, not a substitute for the release allowlist or an appropriate Nginx/server rule.
Keep the previous static artifact and Git SHA. After authorized publication, read back the demo and tutorial URLs, run a smoke workflow and confirm Celebration.js still works. Roll back by restoring that prior static artifact. Do not reverse database migrations against an active service; for a disposable demo, restore the known fixture snapshot and retest before reopening it.
Practical alternatives, tradeoffs and choice
A full application deployment can exercise real sessions and MySQL but requires explicit resource, security and cost decisions. A static fixture is easier to host safely and still communicates the workflow. This release keeps those statuses separate: working synthetic browser demo, tested SQLite/MySQL backend, passed hosted CI, and a disabled live backend. Static publication is a separate approval.
Common pitfalls
Do not publish vendor files or environment material, silently add a real login/order service, run a workflow with production secrets, or present a draft PR as a reviewed release. No real orders, payments, customer records or external sends belong in this demo.
Verification and result before proceeding
Review the exact static file manifest and independent findings. Complete at least five substantive executor/independent-critic cycles for each update package, recording real findings, concrete fixes and rendered re-verification. Repeating unchanged tests or polishing wording does not count as another cycle. The integration writer promoted the CI recipe. Inspect the recorded run and source SHA for its executed result; later source changes need their own checks before extending that claim. The expanded recipe now includes the recording/session/storage/teaching/retry suites; it remains a prepared recipe until an actual new hosted run completes. Review dependency advisories, schema/index changes, fixture expiry rules, accessibility and storage migrations on each maintenance release. Proceed to publication only with the reviewed scope and required approval. Laravel deployment guidance
Sources and implementation references
Official documentation and registry metadata were checked during implementation on October 25, 2026. This is technical implementation evidence, not a claim of production scale, certification, client work or staff management.