07. Server-Side MVC

Introduction

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.

Module 06 (REST API): Module 07 (MVC App): Browser/curl ──> Apache ──> PHP Browser ──> Apache ──> PHP │ │ JSON in/out app.php (front controller) │ ┌───────┼───────┐ switch/case │ │ │ │ Model Controller View PostgreSQL │ │ │ Story.php │ layout.php │ │ index.php PostgreSQL │ show.php routes form.php

Prerequisites

Before starting, make sure you have:

  1. PHP installed with the pdo_pgsql extension. Check with: php -m | grep pdo_pgsql
  2. PostgreSQL running with the stories_demo database from Module 06

If you haven’t completed Module 06 yet, follow the database setup instructions to create the database and load the schema before continuing.

Demo Files

Module Files

Setup and run:

php -S localhost:8080
# Open http://localhost:8080/app.php/stories in a browser

The Front Controller Pattern

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.

MVC File Structure

07-server-mvc/ ├── app.php <── Front controller (entry point) ├── .htaccess <── URL rewriting for Apache ├── models/ │ └── Story.php <── Data access (SQL queries) ├── controllers/ │ └── StoryController.php <── Request handling (logic) └── views/ ├── layout.php <── Shared HTML shell (header/footer) └── stories/ ├── index.php <── Story list page ├── show.php <── Single story detail page └── form.php <── Create/edit form

Each layer has a clear responsibility:

Building the Model (models/Story.php)

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.

Constructor injection: The model receives a 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.

Building the Controller (controllers/StoryController.php)

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;
    }
}

POST/Redirect/GET Pattern

Notice that store(), update(), and destroy() all end with header('Location: ...') followed by exit. This is the POST/Redirect/GET (PRG) pattern:

Browser Server │ │ │ POST /stories │ │ ──────────────────────> │ Controller: validate, save to DB │ │ │ 302 Redirect /stories │ │ <────────────────────── │ Redirect prevents duplicate POST on refresh │ │ │ GET /stories │ │ ──────────────────────> │ Controller: fetch all, render list │ │ │ 200 OK (HTML page) │ │ <────────────────────── │ Browser shows the updated list

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.

Validation with Re-render

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.

Building the Views

Layout (views/layout.php)

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.

List Page (views/stories/index.php)

<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).

Form (views/stories/form.php)

<?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'] ?? '').

XSS Prevention

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
< &lt; Opens an HTML tag
> &gt; Closes an HTML tag
& &amp; Starts an entity reference
" &quot; 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>&lt;script&gt;alert('xss')&lt;/script&gt;</td>

The browser renders the escaped version as visible text rather than executing it as code.

Every time you output user data in HTML, wrap it in 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.

Routing (app.php)

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
Why POST for delete instead of DELETE? HTML forms can only submit 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.

Running and Testing

With the stories_demo database already set up from Module 06, you can launch the app immediately:

Step 1: Start the server

# From the 07-server-mvc directory
php -S localhost:8080

Step 2: Open in a browser

http://localhost:8080/app.php/stories

You should see a table listing the stories from the database, with colored priority and status badges.

Step 3: Create a story

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.

Step 4: View and edit

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.

Step 5: Delete

Click the red “Delete” button next to any story. Confirm the deletion. The story is removed and you’re redirected to the list.

Step 6: Verify persistence

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.

Apache with .htaccess: If you deploy under Apache (instead of using 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.

REST API vs MVC App — Comparison

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
Neither approach is better — they serve different purposes. REST APIs are for machine-to-machine communication: mobile apps, single-page applications, and third-party integrations. MVC apps are for human users who interact through a browser. In Module 08, we’ll combine both approaches in a single application.