In Module 07, the server did all the rendering. PHP loaded data from PostgreSQL, embedded it into HTML templates, and sent complete pages to the browser. The browser was passive — it displayed whatever HTML it received.
In this module, we invert that architecture. The server becomes a JSON API (like Module 06’s db-demo.php), and the browser takes over all rendering. JavaScript running in the browser calls fetch() to get JSON data, then builds the entire user interface by manipulating the DOM. No page reloads. No server-rendered HTML. The browser is in control.
This is a Single-Page Application (SPA). The browser loads one HTML file, and JavaScript handles everything from there.
pdo_pgsql extension. Check with: php -m | grep pdo_pgsqlstories_demo database from Module 06stories_demo database and stories table from Module 06. If you haven’t set that up yet, go back to Module 06 and run the schema setup first.
Run:
cd php-tutorial/08-client-mvc php -S localhost:8080 # Open http://localhost:8080/app.html
Live demo: Try the SPA demo (connects to a live PostgreSQL database)
A traditional web app (Module 07) works like this: every user action triggers a full page load. Click a link? New HTML page. Submit a form? POST, redirect, new HTML page. The browser is essentially a document viewer.
A SPA works differently:
app.html) with a <script> tagfetch()The HTML shell is minimal. It provides structure and styling, but the #content div is empty — JavaScript fills it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stories App (SPA)</title>
<style>/* All styles inline */</style>
</head>
<body>
<div id="app">
<nav>
<a onclick="StoryController.index()">All Stories</a>
<a onclick="StoryController.create()">New Story</a>
</nav>
<div id="content">
<p>Loading...</p>
</div>
<footer>Stories App — Client-Side MVC Demo</footer>
</div>
<script src="stories.js"></script>
</body>
</html>
Key differences from Module 07’s layout:
onclick handlers that call controller methods directly — no href, no page navigation#content div starts nearly empty. JavaScript fills it.<?php ... ?>. No htmlspecialchars(). Just plain HTML.app.html (not .php) — the server doesn’t process this file at all, it serves it as-isThe stories.js file is organized into three sections that directly mirror the server-side MVC from Module 07:
| MVC Layer | Module 07 (Server-Side) | Module 08 (Client-Side) |
|---|---|---|
| Model | models/Story.php — SQL queries via PDO |
StoryModel object — API calls via fetch() |
| View | views/stories/*.php — PHP templates rendered on server |
StoryView object — createElement + textContent in browser |
| Controller | controllers/StoryController.php — handles HTTP requests |
StoryController object — handles user events + coordinates |
The same methods exist in both: index(), show(), create(), store(), edit(), update(), destroy(). The pattern is identical; only the technology changes.
The Model wraps fetch() calls. It knows the API URL and how to send/receive JSON. It knows nothing about the DOM or user interface:
const StoryModel = {
API_URL: 'api.php/stories',
async findAll() {
const res = await fetch(this.API_URL);
if (!res.ok) throw new Error('Failed to load stories');
return res.json();
},
async create(data) {
const res = await fetch(this.API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to create story');
}
return res.json();
},
async update(id, data) {
const res = await fetch(`${this.API_URL}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
// ...
},
async delete(id) {
const res = await fetch(`${this.API_URL}/${id}`, {
method: 'DELETE'
});
// ...
}
};
Key patterns:
API_URL: 'api.php/stories' — the only difference from the Node.js version. PHP uses api.php/stories (via PATH_INFO) while Node.js uses /api/stories (via Express routing)fetch(), we can use any method.JSON.stringify() — convert JavaScript objects to JSON for the request body. The PHP server reads this with json_decode(file_get_contents('php://input'))response.ok — check before parsing. fetch() doesn’t throw on 404 or 500Story class called $pdo->prepare() to run SQL. Module 08’s StoryModel calls fetch() to hit the API. The interface is the same — findAll(), findById(), create(), update(), delete() — but the implementation is completely different because the Model now runs in the browser, not on the server.
The View creates and updates DOM elements. It knows nothing about fetch() or the API. Each render method clears #content and rebuilds it:
const StoryView = {
content: document.getElementById('content'),
renderList(stories, handlers) {
this.content.innerHTML = '';
const h = document.createElement('h1');
h.textContent = 'All Stories';
this.content.appendChild(h);
stories.forEach(story => {
const row = document.createElement('tr');
const titleCell = document.createElement('td');
const titleLink = document.createElement('a');
titleLink.textContent = story.title; // textContent, not innerHTML!
titleLink.addEventListener('click', () => handlers.onShow(story.id));
titleCell.appendChild(titleLink);
row.appendChild(titleCell);
// ... priority badge, status badge, action buttons ...
});
},
renderDetail(story, handlers) { /* ... */ },
renderForm(story, handlers) { /* ... */ },
showError(message) { /* ... */ }
};
Key patterns:
document.createElement() — builds elements programmatically instead of writing HTML stringstextContent (not innerHTML) — safely sets text without interpreting HTML. This prevents XSS.this.content.innerHTML = '' — clears the content area before rendering. This is the “swap” that replaces page navigation.index.php, show.php, form.php) with htmlspecialchars() for XSS prevention. Module 08 uses JavaScript methods that build the same UI with createElement + textContent. The visual result is the same — tables, badges, forms — but the rendering happens in the browser instead of on the server.
The Controller coordinates Model and View. It has the same method names as Module 07’s StoryController class:
const StoryController = {
async init() {
await this.index();
},
async index() {
try {
const stories = await StoryModel.findAll();
StoryView.renderList(stories, {
onShow: (id) => this.show(id),
onEdit: (id) => this.edit(id),
onDelete: (id) => this.destroy(id),
onCreate: () => this.create()
});
} catch (err) {
StoryView.showError(err.message);
}
},
async store(formData) {
// Client-side validation
if (!formData.title || formData.title.trim() === '') {
StoryView.showError('Title is required');
return;
}
try {
await StoryModel.create(formData);
await this.index();
} catch (err) {
StoryView.showError(err.message);
}
},
// ... show(), create(), edit(), update(), destroy()
};
The flow for each operation:
handlers.onEdit(story.id)edit(id)StoryModel.findById(id)fetch('api.php/stories/5')StoryView.renderForm(story, handlers)header('Location: ...'), and the browser made a second GET request. In Module 08, after creating a story the Controller simply calls this.index() which fetches the updated list and re-renders — no redirect, no page reload.
The API is nearly identical to Module 06’s db-demo.php. Same PDO connection, same routing, same CRUD operations. The only difference is the filename:
<?php
// Headers
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Database connection (same as Module 06)
$pdo = new PDO("pgsql:host=$host;dbname=$dbname", $user, $pass, [ /* ... */ ]);
// Parse request
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '/';
$parts = explode('/', trim($path, '/'));
// Route by HTTP method (same switch/case as Module 06)
switch ($method) {
case 'GET': // ... SELECT queries ...
case 'POST': // ... INSERT query ...
case 'PUT': // ... UPDATE query ...
case 'DELETE': // ... DELETE query ...
}
?>
Notice that unlike Module 07, there are no PHP views, no require of template files, no htmlspecialchars(), no $_POST. The server only speaks JSON. The app.html and stories.js files are served as static files by PHP’s built-in web server — no PHP processing needed.
1. Start the server:
cd php-tutorial/08-client-mvc php -S localhost:8080
2. Open your browser and go to http://localhost:8080/app.html. The story list loads without any page refresh.
3. Create a story: Click “New Story” in the nav bar. Fill in the form and click “Create Story”. The list reappears with your new story — no page reload.
4. Edit a story: Click “Edit” on any story. Change the title and click “Update Story”. You’re taken to the detail view — no page reload.
5. Delete a story: Click “Delete”. Confirm the dialog. The story disappears — no page reload.
6. Open the Network tab in your browser’s DevTools. You should see fetch requests using GET, POST, PUT, and DELETE methods — all returning JSON. No HTML responses, no 302 redirects.
app.html the entire time. All “navigation” is just JavaScript swapping the contents of #content.
In Module 07, PHP’s htmlspecialchars() prevented XSS by escaping HTML characters. In client-side JavaScript, you must handle this yourself.
The key rule: use textContent, not innerHTML, when inserting user data:
// SAFE: textContent treats everything as plain text
const td = document.createElement('td');
td.textContent = story.title; // <script> becomes visible text
// DANGEROUS: innerHTML interprets HTML tags
const td = document.createElement('td');
td.innerHTML = story.title; // <script>alert('xss')</script> EXECUTES!
| Approach | Server-Side (Module 07) | Client-Side (Module 08) |
|---|---|---|
| XSS prevention | htmlspecialchars() |
textContent |
| Unsafe equivalent | Forgetting htmlspecialchars() |
Using innerHTML |
| Safe by default? | No (must remember to call it) | No (must choose the right property) |
innerHTML with user data. Use textContent for text, createElement + setAttribute for structure. The only safe use of innerHTML is with static, developer-written strings (like clearing a container with innerHTML = '').
Test this in the app: create a story with the title <script>alert('xss')</script>. Because the View uses textContent, it displays as text instead of executing.
| Aspect | Module 07 Server-Side MVC | Module 08 Client-Side MVC |
|---|---|---|
| Server response | Full HTML pages | JSON data only |
| Rendering | Server (PHP templates) | Browser (JavaScript DOM) |
| Navigation | Full page reload | DOM swap (no reload) |
| Form submission | HTML form POST → redirect | fetch() POST → DOM update |
| HTTP methods used | GET + POST only | GET, POST, PUT, DELETE |
| Data format sent | URL-encoded form data ($_POST) |
JSON body (php://input) |
| Validation feedback | Server re-renders form | JS updates DOM immediately |
| Works without JS? | Yes | No |
| XSS prevention | htmlspecialchars() |
textContent |
| Output | require view files |
echo json_encode() |