Implement the complete TaskController for the course project.
Between the route and the response, a decision must be made.
In the previous chapter, you built the application's HTTP map. You now know that GET /tasks should display tasks and POST /tasks should create one. Yet a route performs nothing by itself: it recognizes an intention and hands execution to an action. That action lives in the controller.
The controller is often the first class where a beginner places everything that seems necessary: form reading, SQL queries, validation, HTML building, email delivery, and error handling. It may work initially, then quickly becomes impossible to understand. One change breaks another action, tests require a real database, and every method grows until the business scenario disappears.
This chapter is therefore not merely about memorizing seven method names. You will learn to treat the controller as a boundary. On the left is HTTP, with methods, parameters, headers, and untrusted data. On the right is the application, with models, rules, and services. The controller translates cleanly between them.
Our task application must list, display, create, edit, complete, and delete a task. Every operation must produce the correct response, reject invalid input, protect another user's tasks, and remain easy to test.
By the end of this chapter, you will know how to
- explain the exact role of an MVC controller
- connect a route to a coherent CRUD action
- read and validate a Request without directly using globals
- choose between a view, redirect, JSON, and an error response
- separate orchestration, persistence, and business rules
- write actions clear enough to test
Working method
Before writing an action, tell its story.
Consider store(). The user submits a form. The application must identify accepted fields, verify them, ask the model to create the task, then tell the browser what happened. That sentence already contains the action's four stages: receive, validate, execute, and respond.
This method prevents coding without direction. For every action, first write the expected outcome and possible failures. show() can succeed or fail to find the task. update() can also receive invalid input or an unauthorized user. destroy() must confirm deletion without rendering an object that no longer exists.
Questions to ask for every action
- What data comes from the route, form, or session?
- Which data is allowed and which rules must be checked?
- Which model or service truly owns the operation?
- What happens when the resource is missing or the action is forbidden?
- Which response lets the client understand the outcome?
Keep this checklist beside you throughout the chapter. It matters more than exact syntax because it will continue to work when your project uses an API, AML View, or PHPAML Data.
Controllers orchestrate
A controller turns an HTTP request into a use case and then a response. It receives Request, coordinates a model or service, and returns a view, redirect, or JSON.
SQL, HTML, complex business rules, and external calls in one action create a giant controller. A short action should tell one complete, readable scenario.
Controllers direct; models and services execute business behavior.
CRUD actions
A resource uses index, create, store, show, edit, update, and destroy. index lists; create displays the form; store handles submission.
show displays one task; edit prepares changes; update saves; destroy removes. This vocabulary naturally connects routes and controllers.
One action pursues one observable outcome.
Request and validation
Request gathers parameters, body, files, headers, and user identity without direct $_POST coupling. Browser input remains untrusted even with HTML required.
Select allowed fields, normalize, then validate presence, type, format, and length. Web returns errors; APIs use 422. Never write before validation.
Only allowed, validated data reaches the model.
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
]);Render a view
view() defines a private contract: explicit names and only required data. Load it before rendering to avoid surprise template queries.
Controllers choose what to display; views decide how. HTML, escaping, loops, and partials belong to presentation.
Controllers prepare; views present.
Redirect after forms
Post/Redirect/Get redirects a POST, PATCH, or DELETE mutation toward a GET page. Refresh no longer resubmits and the final URL becomes shareable.
A flash message confirms the outcome once. APIs normally return 201 after creation; Web redirects to the list or resource.
A successful Web mutation generally ends with a redirect.
JSON, errors, and statuses
HTML and JSON share business behavior, not necessarily representation. Use 200 to read, 201 to create, 204 to delete, 404 when missing, and 403 when forbidden.
Never serialize every model property automatically. Design a stable public resource and exclude secrets and internal fields.
Status and body tell the same story.
return json(['data' => $tasks], 200);Injection and tests
Injection makes repositories, clocks, and services replaceable instead of secretly constructed inside an action.
Test status, view, data, redirect, and persistence. Cover success, empty input, missing tasks, and denied access without freezing internal details.
An injected dependency also documents what the controller needs. Its constructor becomes an honest collaborator list and quickly reveals a class doing too much.
A thin controller has visible dependencies and testable scenarios.
Route parameters and missing resources
The identifier from /tasks/{id} selects a resource, but its URL presence proves neither validity nor existence. Route constraints check shape; the controller handles absence.
Convert the parameter to the expected type and find the task once. If missing, stop immediately with 404 instead of letting null travel through the application.
An API should keep a stable structured error. Web can render its 404 page while preserving the same status for browsers, caches, and crawlers.
Fail early and precisely when a requested resource does not exist.
$task = Task::find($id);
if ($task === null) {
return response('Task not found', 404);
}Authorization and security
Being signed in does not mean every task can be changed. Authentication identifies the user; authorization decides whether that person can act on this resource.
Apply shared middleware to the route group, then verify ownership or task permission. A 403 says identity is known but the action is forbidden.
Never trust user_id submitted by a form. Use the secure session identity and prevent mass assignment of sensitive fields.
Validation asks “is the data correct?”; authorization asks “who may act?”.
$this->authorize('update', $task);
$task->update($validated);Transactions and side effects
Some actions change several records: create a task, append history, and update a counter. Without a transaction, an intermediate failure leaves partial state.
Put the coherent operation in a transactional business service. The controller invokes that use case and translates its result instead of managing commit and rollback details.
Email and remote calls need another strategy. Avoid holding a SQL transaction during slow networking; record the intention and execute the effect in a controlled way.
The controller bounds the scenario; the service guarantees business consistency.
Web or API controller
One use case can serve two interfaces, but their input and output differ. Web uses sessions, CSRF, forms, views, and redirects; APIs use tokens, JSON, and explicit statuses.
As the project grows, separate TaskController and Api/TaskController to avoid repeated “if JSON” branches. Both call the same service and choose their own Response.
Version API contracts when external clients depend on them. A Web template change must never break a mobile app consuming /api/v1/tasks.
Share the use case; specialize the transport contract.
Build a real testing strategy
One happy-path test is insufficient. For store, verify valid creation, empty title, oversized title, unexpected fields, signed-out users, and users without permission.
For show, cover existing and missing resources. For update, add a concurrent edit. For destroy, confirm status or redirect and the task's actual absence.
Test the observable interface rather than every private call order. Tests should survive implementation improvements but fail when user contracts break.
Every important scenario branch deserves reproducible evidence.
$response = $this->post('/tasks', ['title' => '']);
$response->assertStatus(422);Complete example
TaskController
Follow input, validation, model, and response: every line has an identifiable responsibility.
final class TaskController
{
public function store(Request $request): Response
{
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:1000'],
]);
Task::create($data);
return redirect(route('tasks.index'))
->with('success', 'Task created.');
}
public function show(int $id): Response
{
$task = Task::find($id);
if ($task === null) return response('Not found', 404);
return view('tasks/show', ['task' => $task]);
}
}Guided workshop
Complete the CRUD controller.
- Implement seven CRUD actions.
- Validate title and description.
- Handle 404 and 403.
- Apply Post/Redirect/Get.
- Add a safe JSON response.
- Test success, 422, 404, and 403.
Reasoned solution
Verify every boundary.
Route selects, Request transports, validation secures, the model executes, and Response expresses the result.
Why no PDO here?
Storage belongs to the model so the controller stays independent from SQLite, SQL, or MongoDB.
In summary
A good controller makes the request journey obvious.
You began this chapter with a route that could recognize an URL but could not yet complete the scenario. You now have a layer capable of receiving the request, protecting input, invoking the correct behavior, and producing a coherent HTTP response.
Controller quality is not measured by how many operations it can perform. It is measured by how easily another developer can follow the scenario. A successful action reads almost like a list: find the task, check permission, validate input, request the change, then redirect. No technical detail should hide that story.
The essential points
- a route chooses the action, while the controller orchestrates the scenario
- all external input must be restricted and validated
- a response needs a coherent status, body, and intention
- persistence and business rules must not invade the controller
- expected failures are part of the contract and must be tested
Common mistakes to avoid
Do not read $_POST directly in every action. Do not trust a user identifier submitted by a form. Do not return 200 when a resource is missing. Do not duplicate business rules across Web and API controllers. Finally, do not render directly after POST when a redirect can prevent resubmission.
Without looking at the examples, can you explain an action's four stages, choose the status for a missing resource, justify Post/Redirect/Get, and say when to extract a service? If so, you are ready to build presentation.
What comes next
The controller now owns the data and knows how to choose a response. In chapter 6, we will build views and partials that turn this data into readable, accessible, and safe HTML pages. You will learn to pass variables, escape output, display validation errors, and reuse headers and footers.