Server MVC

Building a server-rendered MVC application with PHP templates

Introduction

In Tutorial 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 is 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 Tutorial 06

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

The Front Controller Pattern

In Tutorial 06, every request went to db-demo.php, which parsed the URL and routed to the right CRUD operation using a switch statement. Tutorial 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:

  • Model (models/Story.php) — Encapsulates all database queries. Knows nothing about HTTP, HTML, or user input. Takes data in, returns data out.
  • View (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.
  • Controller (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.
  • Front controller (app.php) — Parses the URL, creates dependencies, and dispatches to the right controller method. A thin routing layer.

Building the Model

The model class wraps the same PDO queries from Tutorial 06 in a reusable class. Instead of inline SQL scattered inside a switch statement, each operation gets its own method:

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 Tutorial 06: The SQL is identical — same SELECT, INSERT, UPDATE, DELETE with RETURNING *. The difference is organizational. In Tutorial 06, these queries lived inside a switch statement mixed with HTTP logic. Now they are encapsulated in a class that can be tested, reused, and reasoned about independently.

Building the Controller

The controller handles the HTTP request/response cycle. Each public method corresponds to a user action:

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.

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:

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:

<?= htmlspecialchars($title) ?> - Stories App

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)

ID Title Priority Status Actions
Edit

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)

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:

<script>alert('xss')</script>

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

Routing

The front controller parses the URL path and dispatches to the appropriate controller method:

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 is 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, then redirect
POST /stories/{id} update($id) Handle edit, then redirect
POST /stories/{id}/delete destroy($id) Handle delete, then redirect

Running and Testing

With the stories_demo database already set up from Tutorial 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 will 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 will 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 are 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.

REST API vs MVC App — Comparison

Aspect Tutorial 06 REST API Tutorial 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