Client MVC

Building a single-page application backed by a PHP JSON API

Introduction

In Tutorial 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 Tutorial 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.

Module 07 (Server-Side MVC):          Module 08 (Client-Side MVC):

Browser  --GET /stories-->  Server     Browser  --fetch('api.php/stories')-->  Server
         <--HTML page----                       <--JSON array-----------
                                        JS builds DOM locally

Browser  --POST /stories-->  Server    Browser  --fetch('api.php/stories', {
         <--302 Redirect--                         method: 'POST',
         --GET /stories-->                         body: JSON.stringify(data)
         <--HTML page----                        })-->  Server
                                                <--JSON object--
                                        JS updates DOM locally (no reload)

Prerequisites

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

How a SPA Works

A traditional web app (Tutorial 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:

  1. The browser loads one HTML page (app.html) with a <script> tag
  2. JavaScript initializes and fetches data from the API using fetch()
  3. JavaScript builds the DOM — creates elements, sets text, appends them to the page
  4. When the user clicks something, JavaScript handles the event, fetches new data, and swaps out the DOM
  5. The browser never navigates to a new URL — the same HTML page stays loaded
1. Browser loads app.html
   +---------------------------------------------+
   |  
Loading...
| | | +---------------------------------------------+ 2. stories.js calls fetch('api.php/stories') Browser --GET api.php/stories--> Server (PHP) <--[{id:1, title:"..."}, ...]-- 3. JS builds table from JSON and inserts into #content +---------------------------------------------+ |
| |

All Stories

| | ...rows from JSON...
| |
| +---------------------------------------------+ 4. User clicks "Edit" -> JS fetches story, builds form (no page reload, just DOM swap)

The SPA Page

The HTML shell is minimal. It provides structure and styling, but the #content div is empty — JavaScript fills it:

Stories App (SPA)

Loading...

Stories App — Client-Side MVC Demo

Key differences from Tutorial 07's layout:

  • Navigation uses onclick handlers that call controller methods directly — no href, no page navigation
  • The #content div starts nearly empty. JavaScript fills it.
  • No PHP. No <?php ... ?>. No htmlspecialchars(). Just plain HTML.
  • app.html (not .php) — the server doesn't process this file at all, it serves it as-is

Client-Side MVC Structure

The stories.js file is organized into three sections that directly mirror the server-side MVC from Tutorial 07:

MVC Layer Tutorial 07 (Server-Side) Tutorial 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.

Building the Model

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)
  • All four HTTP methods — GET, POST, PUT, DELETE. Tutorial 07 was limited to GET and POST because HTML forms don't support PUT/DELETE. Since we are using 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 500

Building the View

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 strings
  • textContent (not innerHTML) — safely sets text without interpreting HTML. This prevents XSS.
  • Event handlers via callbacks — the View receives handler functions from the Controller. It doesn't know what happens when a button is clicked — it just calls the handler.
  • this.content.innerHTML = '' — clears the content area before rendering. This is the "swap" that replaces page navigation.

Building the Controller

The Controller coordinates Model and View. It has the same method names as Tutorial 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:

  1. User clicks something (e.g., "Edit" button)
  2. View calls the handler callback: handlers.onEdit(story.id)
  3. Controller method runs: edit(id)
  4. Controller calls Model: StoryModel.findById(id)
  5. Model calls API: fetch('api.php/stories/5')
  6. PHP processes the request and returns JSON
  7. Controller calls View: StoryView.renderForm(story, handlers)
  8. View builds DOM — form appears with pre-filled values

The API Server

The API is nearly identical to Tutorial 06's db-demo.php. Same PDO connection, same routing, same CRUD operations. The only difference is the filename:

Notice that unlike Tutorial 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.

Running and Testing

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

XSS in JavaScript

In Tutorial 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; // EXECUTES!
Approach Server-Side (Tutorial 07) Client-Side (Tutorial 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)

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.

Server-Side vs Client-Side MVC — Comparison

Aspect Tutorial 07 Server-Side MVC Tutorial 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, then redirect fetch() POST, then 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()