MVC tutorial · Chapter 10

Assemble the
MVC application.

Connect every layer and complete the task application's CRUD journey.

REQUESTUSE CASESTATERESPONSE

Every piece exists. Now build the complete journey.

Previous chapters isolated routes, controllers, views, storage, and models to understand every responsibility. We will connect them into a usable task application without abandoning that separation when the project becomes concrete.

You will follow each request from click to SQLite, then from model reading to HTML. This vertical view makes errors locatable, outcomes testable, and features evolvable.

ROUTECONTROLLERMODELVIEWRESPONSE
Chapter outcome

A complete application that lists, creates, edits, completes, reopens, and deletes tasks, with coherent 404s and an end-to-end verified journey.

10.1

Connect without mixing

An MVC application becomes useful when every layer cooperates while keeping its responsibility. The route recognizes HTTP intent, the controller orchestrates the use case, the model handles durable state, and the view turns the result into an interface.

Connecting layers does not mean merging them. A view that runs SQL, a model that builds redirects, or a route containing all creation logic makes every change riskier.

The journey stays readable in one direction: request, middleware, route, action, model, and response. This direction lets you locate an error without searching the whole project.

Remember

Each layer calls the next through a clear contract without taking its responsibility.

10.2

Map the Task resource

Before implementing, list the intents: display the collection, open forms, create, edit, complete, reopen, and delete. This list becomes the resource's public contract.

URLs identify resources and HTTP methods describe intent. GET observes, POST creates, PATCH changes, and DELETE removes. Method override can represent PATCH or DELETE from an HTML form.

Keep these declarations in routes/webapp.php. The file becomes a compact product map, never a second controller.

Remember

The route table should explain the product without exposing implementation.

phpaml — zsh
$router->get('/tasks', [TaskController::class, 'index']);
$router->get('/tasks/create', [TaskController::class, 'create']);
$router->post('/tasks', [TaskController::class, 'store']);
$router->get('/tasks/{id}/edit', [TaskController::class, 'edit']);
$router->patch('/tasks/{id}', [TaskController::class, 'update']);
$router->post('/tasks/{id}/toggle', [TaskController::class, 'toggle']);
$router->delete('/tasks/{id}', [TaskController::class, 'destroy']);
10.3

Display the list

index reads only allowed filters, asks the model for an ordered collection, then passes presentation-ready data to the view. The controller builds no HTML cards.

The view receives tasks and filter. It displays tasks, available actions, and a useful empty state without knowing PDO or query structure.

Impose stable ordering and constrain the collection. The interface should remain predictable with zero, one, or hundreds of tasks.

Remember

Prepare in the controller; present in the view.

phpaml — zsh
public function index(Request $request): Response
{
    $filter = $request->query('filter', 'all');
    $tasks = $this->tasks->forFilter($filter);
    return $this->view('tasks/index', compact('tasks', 'filter'));
}
10.4

Create with Post/Redirect/Get

GET serves the form and POST receives its submission. store reads fields, performs initial validation, asks the model to create, writes flash feedback, then redirects to the list.

This Post/Redirect/Get sequence prevents refresh from submitting the same task twice. The browser ends on a stable GET URL that is shareable and refreshable.

On error, render the form with old values and status 422. Chapter 11 will structure validation further and add CSRF.

Remember

After a successful write, redirect to a stable read.

phpaml — zsh
public function store(Request $request): Response
{
    $title = trim((string) $request->input('title'));
    if ($title === '') {
        return $this->view('tasks/create', ['error' => 'Title is required.'], 422);
    }
    $task = $this->tasks->create(['title' => $title]);
    $this->flash->success("Task #{$task->id} created.");
    return $this->redirect('/tasks');
}
10.5

Edit without duplication

Editing has two phases: GET loads the task and current values; PATCH receives new values and applies the transition. Both actions use the same missing-resource convention.

Centralize lookup in findOrFail, a resolver, or a small private method. Repeating the same null check in each action eventually creates inconsistent responses.

An update with no changes must never build UPDATE table SET WHERE. The model can treat it as successful without a query while the controller keeps a normal journey.

Remember

Factor resource resolution, not action responsibilities.

10.6

Complete and reopen

Completing a task is a business transition, not a column changed anywhere. complete and reopen are easy to authorize, audit, and test.

A toggle route may choose the transition from current state, but the controller then delegates to the model. It does not directly manipulate completed.

Flash feedback describes the observed result. After transition, redirect to a validated destination or /tasks by default.

Remember

Name transitions after the business and protect their invariants in the model.

10.7

Delete deliberately

Deletion is irreversible to the user. The interface should distinguish it from navigation and request progressive confirmation when risk warrants it.

The server must never depend on JavaScript for correctness. DELETE resolves an exact target, verifies the result, adds feedback, then redirects.

Never use GET to destroy data. Crawlers, prefetchers, and previews follow links; reading must cause no durable change.

Remember

A destructive action needs an appropriate method, exact target, and verified result.

10.8

Respond with a clean 404

A URL may target a deleted task, invented identifier, or inaccessible resource. This absence is normal on the Web; it should produce neither a fatal error nor an empty form.

The resolver throws NotFoundException, then the handler selects the representation: an HTML page for browsers or a JSON object for APIs.

A uniform response also avoids revealing the existence of a private resource. The controller stays focused on the successful journey.

Remember

A missing resource is an expected HTTP outcome, not a failure.

10.9

Test the complete journey

Test a sequence of observable states: empty list, creation, editing, completion, reopening, then deletion. Your scenario tells the entire life of a task.

At each step, verify HTTP status, redirect, visible feedback, and database row. A test limited to 302 may stay green when no task was stored.

Add negative paths: empty title, missing identifier, no-change update, and double deletion. They reveal integration defects hidden by the happy path.

Remember

An integration test proves that several layers produce the right result together.

10.10

Take the due_at challenge

Add a due date by evolving the entire feature vertically: migration, model, controller, forms, rendering, and tests. Leave no layer in an intermediate state.

The overdue rule belongs in the model: a task is overdue when its date has passed and it is incomplete. The color used to signal it belongs only in the view.

This separation lets the same rule serve an API, email, or command. It shows that clear architecture accelerates additions instead of slowing them down.

Remember

A feature is complete when it cleanly crosses every relevant layer.

Guided workshop

Assemble and verify TaskFlow.

  1. Declare the seven Task routes.
  2. Implement index and its empty state.
  3. Connect create and store with PRG.
  4. Add edit and update without duplicate lookup.
  5. Implement complete and reopen.
  6. Add deletion with confirmation.
  7. Turn every missing task into a 404.
  8. Test complete CRUD, then due_at.

Reasoned solution

Follow one task through its entire life.

Create “Prepare the demo”, locate its database identity, edit its title, complete it, reopen it, then delete it. After each request, inspect HTTP status, destination, message, and SQLite row. If all four observations tell the same story, your layers are connected correctly.

01The route recognizes intent
02The controller orchestrates
03The model guarantees state
04The view renders the result
05The test confirms the journey

In summary

Your first PHPAML MVC application works end to end.

Routes describe intentions, TaskController orchestrates, Task protects state, views present results, and every write returns to stable reading through Post/Redirect/Get.

  • Keep dependencies flowing in one direction.
  • Match each intent to the right HTTP method.
  • Centralize missing-resource resolution.
  • Verify response, interface, and database in one scenario.
  • Evolve a feature vertically.

In chapter 11, we will harden the application with structured validation, CSRF, sessions, security headers, error pages, and logs.

Chapter 09Chapter 11 · Coming soon 🔒