In Module 06, we built a JSON API backed by PostgreSQL. Clients like curl and Postman send HTTP requests and receive JSON responses — great for machine-to-machine communication, but there’s no user interface. A real user can’t easily create, edit, or delete stories without writing curl commands.
In this module, we add server-rendered HTML pages with forms. Instead of curl or Postman, users interact through a web browser. They see HTML tables, click links, fill out forms, and submit data — all without writing a single line of JavaScript.
To keep this manageable, we organize the code using the MVC pattern: the Model handles data access, the View renders HTML, and the Controller connects them by processing requests and deciding what to show.
Before starting, make sure you have:
pdo_pgsql extension. Check with: php -m | grep pdo_pgsqlstories_demo database from Module 06If you haven’t completed Module 06 yet, follow the database setup instructions to create the database and load the schema before continuing.
Setup and run:
php -S localhost:8080 # Open http://localhost:8080/app.php/stories in a browser
In Module 06, every request went to db-demo.php, which parsed the URL and routed to the right CRUD operation using a switch statement. Module 07 takes the same idea and gives it a name: the front controller.
All requests go through a single entry point — app.php. It parses the URL, creates the dependencies (database connection, model, controller), and dispatches to the right controller method. This centralizes routing, error handling, and shared setup in one place.
Under PHP’s built-in server, URLs look like:
http://localhost:8080/app.php/stories http://localhost:8080/app.php/stories/3 http://localhost:8080/app.php/stories/new
Under Apache with .htaccess rewriting, URLs become clean:
http://localhost:8080/stories http://localhost:8080/stories/3 http://localhost:8080/stories/new
The .htaccess file that makes this work:
RewriteEngine On
# If the requested file or directory exists, serve it directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Otherwise, rewrite everything to app.php
RewriteRule ^(.*)$ app.php/$1 [QSA,L]
This tells Apache: “If someone requests /stories, and there’s no actual file or directory called stories, rewrite the request to app.php/stories.” The QSA flag preserves any query string parameters.
Each layer has a clear responsibility:
models/Story.php) — Encapsulates all database queries. Knows nothing about HTTP, HTML, or user input. Takes data in, returns data out.views/*.php) — Pure HTML templates with embedded PHP for loops and conditionals. Knows nothing about the database. Receives data from the controller and renders it.controllers/StoryController.php) — The coordinator. Reads user input from $_POST and URL parameters, calls the model to fetch or save data, then picks the right view to render. Contains the application logic but no SQL and no HTML.app.php) — Parses the URL, creates dependencies, and dispatches to the right controller method. A thin routing layer.The model class wraps the same PDO queries from Module 06 in a reusable class. Instead of inline SQL scattered inside a switch statement, each operation gets its own method:
<?php
class Story {
private $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function findAll(): array {
$stmt = $this->pdo->query('SELECT * FROM stories ORDER BY created_at DESC');
return $stmt->fetchAll();
}
public function findById(int $id): ?array {
$stmt = $this->pdo->prepare('SELECT * FROM stories WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
public function create(array $data): array {
$stmt = $this->pdo->prepare(
'INSERT INTO stories (title, description, priority, status)
VALUES (:title, :description, :priority, :status)
RETURNING *'
);
$stmt->execute([
':title' => trim($data['title']),
':description' => $data['description'] ?? null,
':priority' => $data['priority'] ?? 'medium',
':status' => $data['status'] ?? 'todo',
]);
return $stmt->fetch();
}
public function update(int $id, array $data): ?array {
$existing = $this->findById($id);
if (!$existing) return null;
$stmt = $this->pdo->prepare(
'UPDATE stories
SET title = :title, description = :description,
priority = :priority, status = :status
WHERE id = :id
RETURNING *'
);
$stmt->execute([
':title' => isset($data['title']) ? trim($data['title']) : $existing['title'],
':description' => $data['description'] ?? $existing['description'],
':priority' => $data['priority'] ?? $existing['priority'],
':status' => $data['status'] ?? $existing['status'],
':id' => $id,
]);
return $stmt->fetch();
}
public function delete(int $id): ?array {
$stmt = $this->pdo->prepare('DELETE FROM stories WHERE id = :id RETURNING *');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
}
Compare to Module 06: The SQL is identical — same SELECT, INSERT, UPDATE, DELETE with RETURNING *. The difference is organizational. In Module 06, these queries lived inside a switch statement mixed with HTTP logic. Now they’re encapsulated in a class that can be tested, reused, and reasoned about independently.
PDO object through its constructor instead of creating one internally. This is dependency injection — the caller decides which database to connect to. This makes testing easier (you can pass a test database) and keeps the model focused on queries, not configuration.
The controller handles the HTTP request/response cycle. Each public method corresponds to a user action:
<?php
class StoryController {
private $model;
private $baseUrl;
public function __construct(Story $model, string $baseUrl) {
$this->model = $model;
$this->baseUrl = $baseUrl;
}
// GET /stories — list all stories
public function index(): void {
$stories = $this->model->findAll();
$title = 'All Stories';
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/index.php';
require __DIR__ . '/../views/layout.php';
}
// GET /stories/new — show empty form
public function create(): void {
$story = null;
$errors = [];
$isEdit = false;
$title = 'New Story';
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/form.php';
require __DIR__ . '/../views/layout.php';
}
// GET /stories/{id} — show one story
public function show(int $id): void {
$story = $this->model->findById($id);
if (!$story) {
http_response_code(404);
echo 'Story not found.';
return;
}
$title = htmlspecialchars($story['title']);
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/show.php';
require __DIR__ . '/../views/layout.php';
}
// GET /stories/{id}/edit — show pre-filled form
public function edit(int $id): void {
$story = $this->model->findById($id);
if (!$story) {
http_response_code(404);
echo 'Story not found.';
return;
}
$errors = [];
$isEdit = true;
$title = 'Edit Story';
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/form.php';
require __DIR__ . '/../views/layout.php';
}
// POST /stories — validate, create, redirect
public function store(): void {
$data = $_POST;
$errors = $this->validate($data);
if (!empty($errors)) {
// Re-render form with errors (no redirect)
$story = $data;
$isEdit = false;
$title = 'New Story';
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/form.php';
require __DIR__ . '/../views/layout.php';
return;
}
$this->model->create($data);
header('Location: ' . $this->baseUrl . '/stories');
exit;
}
// POST /stories/{id} — validate, update, redirect
public function update(int $id): void {
$story = $this->model->findById($id);
if (!$story) {
http_response_code(404);
echo 'Story not found.';
return;
}
$data = $_POST;
$errors = $this->validate($data);
if (!empty($errors)) {
// Re-render form with errors
$story = array_merge($story, $data);
$isEdit = true;
$title = 'Edit Story';
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/form.php';
require __DIR__ . '/../views/layout.php';
return;
}
$this->model->update($id, $data);
header('Location: ' . $this->baseUrl . '/stories/' . $id);
exit;
}
// POST /stories/{id}/delete — delete, redirect
public function destroy(int $id): void {
$this->model->delete($id);
header('Location: ' . $this->baseUrl . '/stories');
exit;
}
private function validate(array $data): array {
$errors = [];
if (empty(trim($data['title'] ?? ''))) {
$errors[] = 'Title is required.';
}
return $errors;
}
}
Notice that store(), update(), and destroy() all end with header('Location: ...') followed by exit. This is the POST/Redirect/GET (PRG) pattern:
Without PRG, refreshing the page after a form submission would re-send the POST request, potentially creating duplicate records. The redirect converts the POST into a GET, so the browser’s “current page” is the list view — refreshing it just fetches data, never re-submits.
header('Location: ...') must be called before any output. If your code has already sent HTML, whitespace, or even a BOM character to the browser, PHP cannot send redirect headers. This is why the controller calls exit immediately after the redirect — to ensure nothing else outputs content.
When validation fails, the controller does not redirect. Instead, it re-renders the form with error messages and the user’s submitted values pre-filled. This way the user doesn’t lose what they typed:
<?php
$errors = $this->validate($data);
if (!empty($errors)) {
// No redirect — re-render form with errors
$story = $data; // Pass submitted values back to the form
$isEdit = false;
$title = 'New Story';
$baseUrl = $this->baseUrl;
$viewFile = __DIR__ . '/../views/stories/form.php';
require __DIR__ . '/../views/layout.php';
return;
}
// Validation passed — save and redirect
$this->model->create($data);
header('Location: ' . $this->baseUrl . '/stories');
exit;
?>
The flow is: validate → if errors, re-render with messages (no redirect) → if valid, save and redirect (PRG). This is the standard pattern in server-rendered web applications.
The layout file provides the shared HTML structure — the <head>, navigation, and footer. The page-specific content is injected via require $viewFile:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($title) ?> - Stories App</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
nav { margin-bottom: 20px; }
nav a { margin-right: 15px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background: #777BB3; color: white; }
.badge { padding: 3px 8px; border-radius: 3px; color: white; font-size: 12px; }
.badge-high { background: #e74c3c; }
.badge-medium { background: #f39c12; }
.badge-low { background: #27ae60; }
.badge-todo { background: #95a5a6; }
.badge-in-progress { background: #3498db; }
.badge-done { background: #27ae60; }
.error { color: #e74c3c; font-weight: bold; }
label { display: block; margin-top: 15px; font-weight: bold; }
input, textarea, select { width: 100%; padding: 8px; margin-top: 5px; box-sizing: border-box; }
button { margin-top: 20px; padding: 10px 20px; background: #777BB3; color: white; border: none; cursor: pointer; border-radius: 4px; }
button:hover { background: #5a5d8a; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
</style>
</head>
<body>
<nav>
<a href="<?= $baseUrl ?>/stories">All Stories</a>
<a href="<?= $baseUrl ?>/stories/new">New Story</a>
</nav>
<h1><?= htmlspecialchars($title) ?></h1>
<?php require $viewFile; ?>
</body>
</html>
The $viewFile variable is set by the controller before requiring the layout. Each controller method chooses which view to render by setting $viewFile to the appropriate path, then requiring layout.php. This gives every page a consistent shell without duplicating HTML.
<table>
<tr>
<th>ID</th>
<th>Title</th>
<th>Priority</th>
<th>Status</th>
<th>Actions</th>
</tr>
<?php foreach ($stories as $s): ?>
<tr>
<td><?= $s['id'] ?></td>
<td>
<a href="<?= $baseUrl ?>/stories/<?= $s['id'] ?>">
<?= htmlspecialchars($s['title']) ?>
</a>
</td>
<td><span class="badge badge-<?= $s['priority'] ?>"><?= $s['priority'] ?></span></td>
<td><span class="badge badge-<?= $s['status'] ?>"><?= $s['status'] ?></span></td>
<td>
<a href="<?= $baseUrl ?>/stories/<?= $s['id'] ?>/edit">Edit</a>
<form method="POST" action="<?= $baseUrl ?>/stories/<?= $s['id'] ?>/delete"
style="display:inline">
<button type="submit" class="btn-danger"
onclick="return confirm('Delete this story?')"
style="padding:3px 8px; font-size:12px">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
Each row displays a story with colored badges for priority and status. The delete button is a form that POSTs to the delete route (more on why below in the routing section).
<?php if (!empty($errors)): ?>
<div class="error">
<?php foreach ($errors as $e): ?>
<p><?= htmlspecialchars($e) ?></p>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="POST"
action="<?= $baseUrl ?>/stories<?= $isEdit ? '/' . $story['id'] : '' ?>">
<label>Title
<input type="text" name="title"
value="<?= htmlspecialchars($story['title'] ?? '') ?>" required>
</label>
<label>Description
<textarea name="description" rows="4"><?= htmlspecialchars($story['description'] ?? '') ?></textarea>
</label>
<label>Priority
<select name="priority">
<?php foreach (['low', 'medium', 'high'] as $p): ?>
<option value="<?= $p ?>"
<?= ($story['priority'] ?? 'medium') === $p ? 'selected' : '' ?>>
<?= ucfirst($p) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Status
<select name="status">
<?php foreach (['todo', 'in-progress', 'done'] as $st): ?>
<option value="<?= $st ?>"
<?= ($story['status'] ?? 'todo') === $st ? 'selected' : '' ?>>
<?= ucfirst($st) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button type="submit"><?= $isEdit ? 'Update Story' : 'Create Story' ?></button>
</form>
The $isEdit flag controls two things: the form’s action URL (create vs. update route) and the submit button label. When editing, the form is pre-filled with the existing story’s values via htmlspecialchars($story['title'] ?? '').
Every time user data appears in the HTML, it must be wrapped in htmlspecialchars(). This function converts characters that have special meaning in HTML into safe HTML entities:
| Character | Entity | Why It’s Dangerous |
|---|---|---|
< |
< |
Opens an HTML tag |
> |
> |
Closes an HTML tag |
& |
& |
Starts an entity reference |
" |
" |
Breaks out of attribute values |
Suppose someone creates a story with the title <script>alert('xss')</script>. Without escaping, the browser would execute that JavaScript. With htmlspecialchars(), the output becomes:
<!-- Without htmlspecialchars() — DANGEROUS -->
<td><script>alert('xss')</script></td>
<!-- With htmlspecialchars() — SAFE -->
<td><script>alert('xss')</script></td>
The browser renders the escaped version as visible text rather than executing it as code.
htmlspecialchars(). Forgetting even once creates an XSS vulnerability. This applies to all user-supplied values: form inputs, database fields, URL parameters, and anything else that could contain user-controlled content.
The front controller parses the URL path and dispatches to the appropriate controller method:
<?php
// --- Database connection ---
$host = getenv('DB_HOST') ?: 'localhost';
$dbname = getenv('DB_NAME') ?: 'stories_demo';
$user = getenv('DB_USER') ?: 'postgres';
$pass = getenv('DB_PASSWORD') ?: '';
$pdo = new PDO("pgsql:host=$host;dbname=$dbname", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// --- Load classes ---
require __DIR__ . '/models/Story.php';
require __DIR__ . '/controllers/StoryController.php';
// --- Create dependencies ---
$model = new Story($pdo);
$baseUrl = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
$controller = new StoryController($model, $baseUrl);
// --- Parse the URL ---
$pathInfo = $_SERVER['PATH_INFO'] ?? '/';
$parts = explode('/', trim($pathInfo, '/'));
$method = $_SERVER['REQUEST_METHOD'];
$resource = $parts[0] ?? '';
$id = isset($parts[1]) && is_numeric($parts[1]) ? (int)$parts[1] : null;
$action = end($parts);
// --- Dispatch ---
if ($resource !== 'stories') {
header('Location: ' . $baseUrl . '/stories');
exit;
}
if ($method === 'GET') {
if ($id && $action === 'edit') { $controller->edit($id); }
elseif ($id) { $controller->show($id); }
elseif ($action === 'new') { $controller->create(); }
else { $controller->index(); }
} elseif ($method === 'POST') {
if ($id && $action === 'delete') { $controller->destroy($id); }
elseif ($id) { $controller->update($id); }
else { $controller->store(); }
} else {
http_response_code(405);
echo 'Method not allowed.';
}
?>
The routing logic maps URL patterns to controller methods. Here’s the complete route table:
| Method | URL | Controller Method | Purpose |
|---|---|---|---|
| GET | /stories |
index() |
List all stories |
| GET | /stories/new |
create() |
Show empty form |
| GET | /stories/{id} |
show($id) |
Show one story |
| GET | /stories/{id}/edit |
edit($id) |
Show pre-filled form |
| POST | /stories |
store() |
Handle create → redirect |
| POST | /stories/{id} |
update($id) |
Handle edit → redirect |
| POST | /stories/{id}/delete |
destroy($id) |
Handle delete → redirect |
GET and POST requests. There’s no <form method="DELETE"> in the HTML spec. So for a browser-based MVC app, we use POST /stories/{id}/delete as a convention. The REST API (Module 06) still uses the proper DELETE method because its clients (curl, JavaScript) can send any HTTP method.
With the stories_demo database already set up from Module 06, you can launch the app immediately:
# From the 07-server-mvc directory php -S localhost:8080
http://localhost:8080/app.php/stories
You should see a table listing the stories from the database, with colored priority and status badges.
Click “New Story” in the navigation. Fill out the form and submit. You’ll be redirected back to the list with your new story visible.
Click a story title to see its detail page. Click “Edit” to modify it. After saving, you’ll be redirected to the detail page with the updated values.
Click the red “Delete” button next to any story. Confirm the deletion. The story is removed and you’re redirected to the list.
Stop the server with Ctrl+C, then restart it. Browse to the list again — all your data is still there. The stories live in PostgreSQL, not in PHP memory or a temporary file.
php -S), the .htaccess rewrite rules kick in and URLs become clean: /stories instead of /app.php/stories. No code changes required — the front controller reads PATH_INFO either way.
| Aspect | Module 06 REST API | Module 07 MVC App |
|---|---|---|
| Response format | JSON | Server-rendered HTML |
| Client | curl, Postman, JavaScript | Web browser |
| Data submission | JSON body (php://input) |
HTML form ($_POST) |
| HTTP methods | GET, POST, PUT, DELETE | GET and POST only |
| After creating | Returns 201 + JSON | Redirects to list page |
| Validation error | Returns 400 + JSON | Re-renders form with error |
| User interface | None (API only) | Full HTML pages |
| Output | echo json_encode() |
require view files |