Small MVC-Style App
A small MVC-style application separates request translation, use-case decisions, persistence, and HTML rendering. Refactor the working product catalog one vertical slice at a time; MVC is an ownership pattern here, not a PHP language feature or a reason to invent a framework.
request -> front controller -> router -> controller
-> validator/repository -> renderer -> Response
Start with product list and create. Keep the application runnable before moving edit and delete.
Represent Request Data Explicitly
Controllers should not reach into superglobals throughout their methods. Capture the relevant arrays once while keeping tests able to construct requests directly:
<?php
declare(strict_types=1);
final readonly class Request
{
public function __construct(
public string $method,
public string $path,
public array $query = [],
public array $body = [],
) {}
public static function fromGlobals(): self
{
$method = is_string($_SERVER['REQUEST_METHOD'] ?? null)
? strtoupper($_SERVER['REQUEST_METHOD'])
: 'GET';
$target = $_SERVER['REQUEST_URI'] ?? '';
$path = is_string($target) ? parse_url($target, PHP_URL_PATH) : false;
if (!is_string($path) || $path === '' || $path[0] !== '/') {
throw new InvalidArgumentException('Invalid request target.');
}
return new self($method, $path, $_GET, $_POST);
}
public function bodyString(string $key): ?string
{
$value = $this->body[$key] ?? null;
return is_string($value) ? $value : null;
}
}
Request does not make input trusted. It records shape and gives controllers a warning-free way to reject array-valued fields. Uploaded files, raw JSON, cookies, and trusted proxy data need their own explicit boundaries rather than being added casually to one untyped bag.
Depend On The Persistence Contract
The controller needs product operations, not PDO itself:
<?php
declare(strict_types=1);
interface ViewRenderer
{
public function render(string $name, array $view = []): string;
}
interface ProductStore
{
public function page(int $limit, int $offset): array;
public function find(int $id): ?array;
public function insert(string $name, int $priceCents, string $status): int;
}
The existing PDO repository implements ProductStore and remains responsible for prepared SQL, fetch mapping, and database exceptions. Update the renderer declaration to final class TemplateRenderer implements ViewRenderer. These interfaces provide narrow test seams; they do not require forwarding wrappers around the concrete classes.
Return A Typed Validation Result
Move product input rules out of the template and keep the normalized values next to their errors:
<?php
declare(strict_types=1);
final readonly class ProductValidation
{
public function __construct(
public array $values,
public array $errors,
) {}
public function isValid(): bool
{
return $this->errors === [];
}
}
final class ProductValidator
{
public function validate(array $input): ProductValidation
{
$name = is_string($input['name'] ?? null) ? trim($input['name']) : '';
$price = is_string($input['price'] ?? null) ? trim($input['price']) : '';
$status = is_string($input['status'] ?? null) ? $input['status'] : '';
$priceCents = parsePriceCents($price);
$errors = [];
if ($name === '' || strlen($name) > 100) {
$errors['name'] = 'Enter a name up to 100 bytes.';
}
if ($priceCents === null) {
$errors['price'] = 'Enter a valid price.';
}
if (!in_array($status, ['draft', 'published'], true)) {
$errors['status'] = 'Choose draft or published.';
}
return new ProductValidation([
'name' => $name,
'price' => $price,
'price_cents' => $priceCents,
'status' => $status,
], $errors);
}
}
This reuses the integer-cent parser from the CRUD project. Validation does not write to PDO, render HTML, set a status code, or redirect.
Make The Controller Own HTTP Decisions
The controller coordinates collaborators and returns one Response:
<?php
declare(strict_types=1);
final class ProductController
{
public function __construct(
private ProductStore $products,
private ProductValidator $validator,
private ViewRenderer $views,
) {}
public function index(Request $request): Response
{
$page = filter_var($request->query['page'] ?? 1, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 1_000_000],
]);
if ($page === false) {
return new Response("Bad Request\n", 400, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
$fragment = $this->views->render('products/index', [
'products' => $this->products->page(50, ($page - 1) * 50),
]);
return $this->htmlPage('Products', $fragment);
}
public function store(
Request $request,
?array $currentUser,
string $expectedCsrfToken,
): Response {
if ($request->method !== 'POST') {
return new Response("Method Not Allowed\n", 405, [
'Allow' => 'POST',
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
if ($currentUser === null) {
return new Response("Authentication required.\n", 401, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
if (($currentUser['role'] ?? null) !== 'admin') {
return new Response("Forbidden\n", 403, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
$submittedToken = $request->bodyString('_csrf');
if ($submittedToken === null || !hash_equals($expectedCsrfToken, $submittedToken)) {
return new Response("Invalid request token.\n", 403, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
$result = $this->validator->validate($request->body);
if (!$result->isValid()) {
$fragment = $this->views->render('products/create', [
'values' => $result->values,
'errors' => $result->errors,
'csrf' => $expectedCsrfToken,
]);
return $this->htmlPage('Create product', $fragment, 422);
}
$id = $this->products->insert(
$result->values['name'],
$result->values['price_cents'],
$result->values['status'],
);
return new Response('', 303, ['Location' => '/products/' . $id]);
}
private function htmlPage(string $title, string $fragment, int $status = 200): Response
{
$html = $this->views->render('layout', [
'title' => $title,
'content' => new SafeHtml($fragment),
]);
return new Response($html, $status, [
'Content-Type' => 'text/html; charset=utf-8',
]);
}
}
Authorization occurs before CSRF, validation, and database work. The invalid form returns 422 with preserved values and field errors. Success uses POST/redirect/GET with 303. PDO exceptions are not converted here; the front controller's generic exception boundary owns unexpected 500 responses and logging.
A focused service earns its place when a use case coordinates more than this controller should own, such as inserting a product and writing an audit event in one transaction. Do not add CreateProductService merely to call ProductStore::insert() with the same arguments.
Build Dependencies In One Composition Root
Construct infrastructure once near public/index.php, not inside controllers or templates:
<?php
declare(strict_types=1);
// no-execute: requires project autoloading, PDO, templates, session, and current-user lookup.
$request = Request::fromGlobals();
$pdo = openDatabase(dirname(__DIR__) . '/storage/app.sqlite');
$products = new ProductRepository($pdo);
$views = new TemplateRenderer(dirname(__DIR__) . '/templates', $templateMap);
$productController = new ProductController($products, new ProductValidator(), $views);
$router->add('GET', '#\A/products\z#D',
static fn (array $parameters): Response => $productController->index($request));
$router->add('POST', '#\A/products\z#D',
static fn (array $parameters): Response => $productController->store(
$request,
$currentUser,
$_SESSION['csrf'],
));
The composition root may know concrete PDO and renderer classes. Business-facing classes receive only their declared collaborators. A framework container can automate this wiring later, but dependency injection itself is ordinary object construction.
Test Boundaries And Wiring
Use a fake ProductStore to verify that invalid input never inserts, valid input inserts normalized values, unauthorized requests do no validation or writes, and list pagination supplies the expected limit and offset. Use an in-memory SQLite repository test for real SQL. Add an HTTP-level test that boots the actual composition root; unit tests can pass while production wiring points to the wrong database or template map.
When adding a product field, expect coordinated changes in the migration, repository mapping, validator, templates, and tests. A SQL filter belongs in ProductStore; a markup change belongs in a template; authorization belongs before the protected use case. Those answers should be visible from dependencies, not hidden globals.
Practice
Practice: Refactor A Small MVC Product App
Refactor the working product catalog into the request, controller, persistence, validation, rendering, response, and composition-root boundaries from the lesson.
Requirements
- Capture method, path, query, and form arrays once in a testable
Request. - Reject malformed targets and array-valued scalar fields without warnings.
- Depend on a narrow product persistence interface rather than PDO in controllers.
- Return normalized values and field errors in a typed validation result.
- Keep SQL, HTML, request translation, validation, authorization, and response sending in their stated owners.
- Return
Responseobjects from every controller path. - Enforce method, authentication, role, and CSRF before validation and writes.
- Render invalid forms with
422; redirect successful creation with303. - Construct concrete infrastructure once in the composition root.
- Complete edit and delete with positive IDs, missing-row
404, authorization, CSRF, and POST/redirect/GET. - Add a service only when it owns real use-case coordination.
Add controller tests with a fake store, repository tests with SQLite :memory:, renderer tests with real temporary templates, and one HTTP-level wiring test. Record where product-field, validation, SQL-filter, authorization, and markup changes belong.
Show solution
Move the product list first, then create, edit, and delete while keeping each route runnable. The composition root constructs concrete dependencies once and route closures adapt the router signature to controller methods.
A fake store makes controller behavior observable without PDO:
<?php
declare(strict_types=1);
// no-execute: requires the ProductStore interface from the application.
final class FakeProductStore implements ProductStore
{
public array $rows = [];
public array $inserted = [];
public function page(int $limit, int $offset): array
{
return array_slice($this->rows, $offset, $limit);
}
public function find(int $id): ?array
{
return $this->rows[$id] ?? null;
}
public function insert(string $name, int $priceCents, string $status): int
{
$this->inserted[] = compact('name', 'priceCents', 'status');
return 42;
}
}
Make TemplateRenderer implement ViewRenderer, then test the controller with real temporary templates or a recording ViewRenderer fake. Verify these cases explicitly:
GET list page 2 -> store receives limit 50, offset 50
invalid page -> 400, no repository call
anonymous POST -> 401, no validation or insert
non-admin POST -> 403, no insert
wrong CSRF -> 403, no insert
invalid product -> 422 HTML with escaped values, no insert
valid product -> one normalized insert, 303 Location /products/42
repository exception -> handled only by the front-controller 500 boundary
Keep one integration test against the real PDO repository and one request test through the real route table. That combination catches both boundary behavior and composition mistakes that isolated fakes cannot detect.