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.






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.
NEXT
Multi-tenant SaaS HRIS