Skip to main content

16 / WORK / CASE STUDY

FoodSelector — Food Picker

A food-discovery site on a Lumen (PHP) JWT API with a React + Vite frontend: first repair the coursework backend’s auth bypass, dead account lock and broken routes, then add a frontend that runs end-to-end with no PHP and no database.

TYPE
FULL STACK
SCOPE
Coursework backend → full-stack rebuild · solo
STACK
PHP · LUMEN · JWT · MYSQL · REACT · VITE · TAILWIND
LINKS

01 / PROBLEM

“Hang on”, “whatever”, “anything’s fine” — the three least useful sentences at any group meal. This project pushes restaurant data down to dish level (item, price, category, popularity) so it can be searched and filtered, then adds a single “decide for me” button that draws a store or a dish. The backend is a 2024 coursework project — a Lumen (PHP) JWT API; this 2026 round was about making it presentable: fix what was genuinely broken in the backend, then build the frontend that never existed.

To be clear up front: “decide for me” is not a recommender. The entire picking logic is a 22-line RandomController whose two methods each call inRandomOrder()->limit(4) — ORDER BY RAND() LIMIT 4 in SQL. No personalization, no weighting, no ML. The engineering worth talking about is elsewhere: JWT auth with account locking, per-route RBAC, and the adapter that lets one frontend run against either seed data or the real API.

02 / CONSTRAINTS

  • The backend is existing coursework code: this round only repaired what was demonstrably broken — the auth bypass, the dead lock, commented-out routes, a seeder that created no roles — without rewriting the architecture or adding features.
  • No PHP or Composer on this machine — the backend was never booted. Every backend claim traces to a source line; the new PHPUnit route tests are written, not run, and that is stated as-is rather than passed off as a verified result.
  • The frontend has to deploy statically (GitHub Actions → GitHub Pages) and be fully usable with no backend at all, so the data layer had to be switchable rather than hard-wired to an API host.

03 / ARCHITECTURE

Backend: PHP 8.1 / Lumen 10 with Eloquent and tymon/jwt-auth 2 on MySQL. routes/web.php registers 25 routes — 13 public (login, forgot-password, categories, stores, products, search, the two pickers, photos) and 12 behind check.permission:<action>. RBAC is modeled across four tables (roles, permissions, role_permissions, user_roles), with 12 permission strings split into buyer (three favourite actions) and seller (nine store-info and product-CRUD actions). Persistence is 14 tables from five migrations. AuthMiddleware doubles as the global middleware (pass through when no permission argument is given) and as the implementation behind the check.permission alias.

Frontend: React 18 + Vite 6 + Tailwind v4 with hand-rolled shadcn-style primitives — 24 .js/.jsx source files, ~2,300 lines, seven routes (home, browse, store, random, favorites, login, 404). At its centre is a data adapter: 12 exported functions that follow VITE_API_MODE into either demo (local seed) or live (fetch against the Lumen API), with both branches returning identical shapes. The seed supplies 8 categories, 8 stores, 26 dishes and 6 reviews; favorites persist in localStorage. Tests are 39 Vitest cases (run, passing), and CI is two GitHub Actions workflows: test + build, and a demo-mode Pages deploy.

04 / RESPONSIBILITIES

All 38 commits in the repo are mine (three git identities, one person): 20 commits in June 2024 built the Lumen backend during coursework; the 18 commits in July and August 2026 are this rebuild — the frontend from scratch, the backend permission and data-layer repairs, tests on both sides, the CI/Pages workflows and the README.

05 / CHALLENGES → SOLUTIONS

CHALLENGE

The permission middleware had a silent bypass: on an invalid token, hasPermission() returned response()->json([...], 401), while the caller treated the return value as a boolean — a JsonResponse object is always truthy, so both a missing and a malformed token sailed through all 12 guarded routes.

SOLUTION

Split authentication from authorization: verifyToken() resolves the private id first and returns 401 on failure; only then does hasPermission() run, now returning a real boolean and yielding 403. Route-level PHPUnit tests were added to assert each of the 12 guarded routes carries the right check.permission:<action> and that public routes carry none — written, but not yet executed, since this machine has no PHP.

CHALLENGE

The account lock looked finished but had never once engaged: locks.status is a string column, yet the code compared it with === 1 — a strict integer comparison that is always false — and add() never wrote status when creating a row.

SOLUTION

Compare against the string '1', and have add() write '0' explicitly. With that fixed, the real behaviour was drawn as a state machine rather than written as a claim: because checklock() runs before attempt(), the counter must already be 5, so the lock trips on the sixth try; and the only way out of Locked is answering both security questions to reset the password — no admin unlock, no TTL, no scheduled job.

CHALLENGE

RBAC had four tables, a middleware and permission strings, and still could not pass: RoleSeeder was a copy of PermissionsSeeder — it created no roles and wrote neither role_permissions nor user_roles, so every guarded route was guaranteed to 403.

SOLUTION

Rewrote RoleSeeder: create the buyer and seller roles, resolve permission ids by action_name into role_permissions, assign user_roles, and seed the buyers’ member rows; DatabaseSeeder now runs in FK-safe order. Also added the collect migration that never existed — the table the favourites feature depends on was simply absent. These repairs rest on source evidence only; there is no record of the migrations and seeders actually being run.

CHALLENGE

The whole product had to be operable with no PHP and no MySQL — both for a GitHub Pages demo and for developing the frontend at all.

SOLUTION

All data access converges into one adapter (12 exported functions) switched by VITE_API_MODE, with both branches returning the same shapes; the demo branch even reproduces the backend’s quirks (/product/info/{id}/ returns a one-element array) so switching to live needs no component changes. Most of the 39 Vitest cases guard that layer. Parity is not perfect, and knowingly so: the demo branch filters products.status === 1 (26 seeded dishes, 25 listed — hence the “25 dishes” chip on screen) while the real /search/ and both pickers apply no such predicate; and the picker asks the adapter for a pool of 8 stores / 12 dishes, but in live mode the backend always returns exactly 4. These are tracked as known issues rather than papered over — and they are not the only ones: the demo search also matches on description where the backend matches on name alone.

06 / SYSTEM FACTS

25

backend routes (12 permission-guarded)

14

tables (5 migrations)

39

frontend Vitest cases

22

lines: the entire picker

07 / LESSONS

Written is not the same as working. Both the permission check and the account lock existed and read plausibly, but one treated a JsonResponse as a boolean and the other compared a string column with === 1 — two security mechanisms failing silently, with no error anywhere. What exposed them was not re-reading the code: it was turning the route table into assertions and the lock’s lifecycle into a state machine.

Do not call random a recommendation. The product’s pitch is “decide for me”, and the implementation is ORDER BY RAND() LIMIT 4. What is more telling is that the system already accumulates view (look) and favourite (collect) data — those numbers just flow back into two seller-facing routes and never reach the buyer’s picker. Turning this into a real recommender does not need more data; it needs the existing signal wired in.

Making demo and live share one set of function signatures was the highest-return decision of this round — the frontend can be developed offline, deployed statically, and switched back to the real backend at any time. But the two branches drift (the status filter, the pool size), and nothing tests for it. Next time the cross-mode contract tests come first, before any features are stacked on top.

08 / SCREENS

All screens below show demo (mock) data — no real user data.

Home page: “what to eat today — let fate decide” hero, eight food categories, featured stores and popular dishes
Home — the demo-mode banner is driven by IS_DEMO; every category, store and dish on this page comes from the front-end seed, with no backend running
Store page: cover, rating, intro, address/hours/phone, tags, full menu and diner reviews
Store page — intro, opening info and menu on one screen; reviews read the seed in demo mode and degrade to an empty array in live mode, because no route in the backend points at CommentController
Browse page with the “yakiniku” category filter applied: search box, category chips, price range and a 3-dishes / 1-store result count
Browse with a category filter — keyword, category and price bounds map onto the backend’s /search/ query parameters; the “3 dishes / 1 store” chips are live counts for the same filter set
The random picker after the reel stops: a “this is the one!” result card with the picked store
The picker after a real spin — one uniformly random draw from the candidate pool, no personalization and no weighting; under prefers-reduced-motion it skips the 1.6-second reel and reveals the result directly
Favorites page: a grid of six saved dishes, with a count badge on the header nav
Favorites — saved items live only in the browser’s localStorage (fs_favorites); the backend does expose collect.create / read / delete behind permissions, but the frontend has never called them
Browse page at 390px: wrapped category chips, price range on its own row, a two-column card grid and a fixed four-tab bottom bar
Browse at 390px — chips wrap, the price range gets its own row and navigation switches to a fixed md:hidden bottom bar; the same page components serve both widths, differing only by breakpoint

09 / ARCHITECTURE DIAGRAMS

Every diagram below is derived from the project's actual source; each node traces back to a real file. They are wide — scroll sideways, or click to open the full-size SVG.

Sequence diagram of POST /login and GET /collect/, covering the lock check, JWT issuance and the per-route permission check
Login and authorization sequence — the left half is login: check locks, then attempt(), increment on failure and clear on success; the right half traces a guarded route through check.permission:collect.read. The figure deliberately keeps one fact visible: the controller decodes the same bearer token a second time, independently of the middleware.
Request pipeline and full route table: public routes, the buyer and seller permission-guarded groups, and the 401/403 branches
Request pipeline and route table — 13 of the 25 routes are public and 12 carry check.permission:<action>; the buyer/seller grouping is not an editorial choice: both role names and the permission split come straight from RoleSeeder, and every permission string has a matching row in PermissionsSeeder.
Account-lock state machine over the locks table: no row yet, unlocked and locked, with the transitions between them
Account-lock state machine — the two details most easily misstated are drawn in: checklock() runs before attempt(), so the counter must already sit at 5, meaning the lock trips on the sixth try; and the only exit from Locked is a security-question password reset — no admin unlock, no TTL, no scheduled job.
Data paths for discovery, random picking and popularity stats, split into public buyer routes and guarded seller routes
Discovery and picking paths — the most valuable part is the note bottom-right: the “recommendation” is inRandomOrder()->limit(4) — no user history, no weighting, no ML, no cache. And the popularity data that look/collect accumulate flows back only into two seller-facing routes; it never reaches the buyer’s picker.
ERD of 14 tables: accounts (private/member/store), the four RBAC tables, products and stores, locks, look and collect
The data model (14 tables, all from five migrations) — beyond the three-way private/member/store account split and the four RBAC tables, the figure annotates a few realities: member.safe_ans1/2 are stored in plaintext, user_roles has no unique constraint while the middleware reads only the first row, and the comment table has columns and a controller but no route that reaches it.

NEXT

Multi-tenant SaaS HRIS