← McKeen Asma · Portfolio

Case study · Full-stack · Code walkthrough

Tasks: a Laravel API and a Next.js frontend, built test-first with Claude Code in a 4-hour assessment

The brief: build any small module with full create, read, update and delete, backed by a database, with a Laravel backend and a Next.js frontend, then walk through the code and how AI helped. Here is what I built, why it's shaped this way, and the one thing the AI got wrong.

The 7-minute code walkthrough: the app working, the key code, the tests and how I used AI. Prefer reading? The full transcript is below. Also on Google Drive.
7 / 7Laravel feature tests, written first and watched fail
5 / 5steps of a real-browser Playwright test pass
6 commitsscaffold → API + tests → UI → docs → fix, all public

The shape: two apps, one repo

Browserplain HTML forms web/ Next.js 16Server Components readServer Actions write api/ Laravel 13JSON API /api/tasksvalidates everything SQLiteone table
  • The browser only talks to Next.js. Next.js calls Laravel from the server, so there's no CORS setup, the API address never reaches the browser, and the forms work before JavaScript loads.
  • Laravel is a plain JSON API. A FormRequest validates, an API Resource decides the JSON shape, and each controller action is a few lines.
  • SQLite, so a reviewer runs it with zero database setup. The migration works unchanged on MySQL or PostgreSQL.

The decisions that matter

DecisionWhy
Validation lives only in LaravelOne source of truth. On a 422 the Next.js form shows each error under its field and keeps what the user typed.
Updates are partial (PATCH)The title is sometimes on update, not required, so you can change only the status.
Allowed values in one placeTask::STATUSES and PRIORITIES feed validation, the factory and the tests.
Fresh data streams under <Suspense>Next.js 16 turns on Cache Components by default. Tasks must never be stale, so they aren't cached; they stream in behind a loading state.
Filter state in the URL/?status=done can be shared or bookmarked, and works without JavaScript.
Left out on purposeNo auth (not in the brief); no pagination (marked in the code as the next step).
The Laravel TaskController: index with an optional status filter, store returning 201, update with validated fields, destroy returning 204
The whole controller. Route model binding returns a 404 for a missing task on its own.
The New task form showing 'The title field is required.' under the empty title field
Laravel rejects an empty title with a 422; the Next.js form shows it under the field.

Proof: tests first, then a real browser

I wrote the 7 Laravel feature tests before the API, watched them fail, then wrote the code until they passed. They cover the list and its order, the filter, creating with defaults, rejecting bad input, a 404, a partial update and delete, on an in-memory database. A Playwright test then drives a real browser through validation, create, edit, filter and delete.

php artisan test output: 7 passed, 27 assertions
The real test run.

Testing a fresh clone also caught one bug before submitting: the empty tests/Unit folder isn't kept by git, so php artisan test failed on a new machine. It's fixed in the last commit.

How I used AI, and what it got wrong

  • Claude Code (Claude Opus) is my coding driver. It drafted the code, tests and README; I approved the design before any code and reviewed every diff.
  • The design calls were mine. I chose SQLite over PostgreSQL so a reviewer runs it with zero database setup; the migration works unchanged on MySQL or PostgreSQL.

The thing the AI got wrong

Claude's first browser test failed on the edit step: "element is not visible". It assumed only one form was on the page. But Next.js 16 keeps pages you've left mounted but hidden (React Activity) so going back is instant. After visiting "New task" and then "Edit", both forms were in the page, and the test grabbed the hidden one. I found it by listing every page section and whether it was visible, and fixed it by scoping the test to the visible page.

Before: page.getByLabel('Status') failed. Probe: Edit task visible, New task hidden, Tasks hidden. After: every lookup scoped to main:visible
Before, the probe that found it, and the fix.

A second one: the AI's training is older than Next.js 16, so I had it read the docs that ship inside the installed Next.js before writing the frontend.

Tools used

  • Laravel 13 (PHP 8.4), PHPUnit, SQLite
  • Next.js 16 (App Router, TypeScript, Tailwind), Playwright
  • Claude Code with Claude Opus

Code, README and AI notes: github.com/mcknx/caravea-tasks

Honest note: this is my submission for a hiring assessment (Caravea, Full-Stack Developer), built on 8 Oct 2026 in a 4-hour window. It is not a client product, and there are no client results here.

FAQ

Why does Next.js call Laravel from the server instead of the browser?

Server Components read the data and Server Actions save it, so the browser only ever talks to Next.js. That means no CORS setup, the API address never reaches the browser, and the forms work even before JavaScript loads.

Why is all validation in Laravel?

One source of truth. The Next.js form sends what the user typed; if Laravel answers 422, the form shows each error under its field and keeps the input. Two copies of the rules would drift apart.

How much of it did AI write?

Claude Code wrote the first drafts of the code, the tests and the README. I directed it, approved the design before any code, and reviewed every diff. The tests were written first and watched fail, so every behaviour is checked, not assumed.

Is this a client project?

No. It is my submission for a hiring assessment (Caravea, Full-Stack Developer), built in a 4-hour window. The code, tests and video are real; there are no client results here.

Video transcript

Full narration of the 7-minute walkthrough, with start times.

Hi Chris. This is McKeen. This is my submission for the Caravea technical assessment: a small task tracker, with a Laravel API and a Next.js frontend. I'll show it working, walk through the key parts of the code, and explain how I used AI to build it.

Here's the shape of it. There are two apps in one repo. The api folder is Laravel 13. It's a plain JSON API, on top of SQLite. The web folder is Next.js 16. The browser only talks to Next.js, and Next.js talks to Laravel from the server. So there's no cross origin setup, the API address never reaches the browser, and the forms work even before JavaScript loads.

Here it is running. This is the task list, newest first. Each task has a status, a priority, and an optional due date.

If I try to create a task without a title, Laravel rejects it with a 422, and the error shows up right under the field. The Next.js form does no validation of its own. Laravel is the single source of truth.

Now I'll give it a title and high priority, and create it. It lands at the top of the list.

Editing works the same way. I'll mark this one as done.

The filter uses the status in the URL, so a filtered view can be shared or bookmarked.

And delete asks for confirmation first, then removes it.

Now the code, starting with the database. The migration is one table: title, notes, a due date, status, and priority. Status has an index, because the list filters on it. Status and priority default to todo and medium in the database.

In the model, the allowed statuses and priorities live in two constants. Validation, the factory, and the tests all read from them, so there's one list to change. Fillable protects against mass assignment. And the attribute defaults mirror the database defaults, so a freshly created task returns the same values the database stores.

Validation lives in a form request. Creating needs a title. Updating uses sometimes, instead of required, so a PATCH can change only the status, without resending the whole task. The due date must be a real date in year, month, day format. Status and priority must be one of the allowed values.

The controller stays small. Index validates the optional status filter, then uses when, to apply it only if it's there. It sorts newest first, with the id as a tie breaker. Store returns a 201. Update saves only the validated fields. Destroy returns a 204, with no content. And route model binding means a missing task returns a 404 automatically.

The resource decides the exact JSON shape, so the table can change later without breaking the frontend.

I wrote these seven feature tests first, watched them fail, then wrote the code until they passed. They cover the list and its order, the filter, creating with defaults, rejecting bad input, showing one task and the 404, a partial update, and delete. They run on an in memory SQLite database, so they're fast and never touch real data.

On the Next.js side, this file is the only place that knows the API address. Every request asks for JSON in its headers, so Laravel always answers errors as JSON, instead of an HTML page.

Saving goes through server actions. Save task handles both create and update. It sends a POST or a PATCH. If Laravel answers 422, it returns the errors, and what the user typed, back to the form. On success, it revalidates the list and redirects home.

The form is a client component, using useActionState. It shows each Laravel error under its field, keeps the user's input after a failed save, and disables the button while saving.

The list page wraps the data in Suspense. Next.js 16 turns on cache components by default. Task data must always be fresh, so instead of caching it, the list streams in behind a loading state.

Finally, a real browser test with Playwright clicks through the whole flow: validation, create, edit, filter, and delete. All five steps pass.

Now, how I used AI. Claude Code, with Claude Opus, is my coding driver. It wrote the first drafts of the code, the tests, and the README. I directed it, and reviewed every diff. I made the design calls myself. I chose SQLite so a reviewer runs it with zero database setup.

Here's one thing the AI got wrong. Claude's first browser test failed on the edit step. It said the status field was not visible. It assumed only one form was on the page. But Next.js 16 keeps pages you've left mounted, just hidden, so going back is instant. After visiting New task and then Edit, both forms were in the page, and the test grabbed the hidden one. I found it by listing every page section and whether it was visible, and fixed it by scoping the test to the visible page. A second one: the AI's training is older than Next.js 16, so I had it read the docs that ship with Next.js, before writing the frontend.

My setup is in the repo: context files for Laravel and Next.js, my own rules file, and agent skills for design first, test driven development, and keeping the code minimal. And the process is visible in the commit history: scaffold, tests and API, frontend, docs, and a fix I caught by testing a fresh clone.

Everything is in the repo, with a README that explains how to run it, and why I built it this way. Thanks for watching, Chris.