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:
- PHP installed with the
pdo_pgsqlextension. Check with:php -m | grep pdo_pgsql - PostgreSQL running with the
stories_demodatabase 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:
Under Apache with .htaccess rewriting, URLs become clean:
The .htaccess file that makes this work:
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$_POSTand 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:
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:
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:
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) ?>
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 |
|---|---|---|---|---|
| = $s['id'] ?> | = htmlspecialchars($s['title']) ?> | = $s['priority'] ?> | = $s['status'] ?> | 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)
= htmlspecialchars($e) ?>
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 |
|---|---|---|
< |
< |
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:
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:
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
Step 2: Open in a browser
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 |