Introduction
In Tutorial 07, the server always rendered HTML. Every request got a complete page back. In Tutorial 08, the server always returned JSON, and JavaScript rendered everything in the browser. Each approach had trade-offs: Tutorial 07 works without JavaScript but reloads on every action; Tutorial 08 feels smooth but requires JavaScript to function at all.
In this module, we build a single codebase that does both. The controller checks what the client wants and responds accordingly:
- Browser without JS → requests HTML, gets server-rendered pages (Tutorial 07 behavior)
- Browser with JS →
enhance.jsintercepts clicks, sendsfetch()withAccept: application/json, gets JSON, renders client-side (Tutorial 08 behavior)
This is progressive enhancement: the base experience works everywhere, and JavaScript makes it better when available.
Tutorial 07: Browser --GET /stories--> Server --> HTML (always)
Tutorial 08: Browser --fetch('/api')--> Server --> JSON (always)
Tutorial 09: Browser --GET /stories--> Server --> HTML or JSON (depends on Accept header)
JS enabled? -> enhance.js intercepts, fetches JSON, renders DOM
JS disabled? -> normal links/forms, server renders HTML
Prerequisites
- PHP 8.0+ installed — php.net
- PostgreSQL running with the
stories_demodatabase from Tutorial 06
The Adaptive Pattern
The key insight is content negotiation. HTTP has an Accept header that tells the server what format the client wants. Browsers send Accept: text/html by default. When enhance.js makes a fetch() call, it sets Accept: application/json.
The controller checks this header and branches:
+------------------+
| Controller |
| index() |
+--------+---------+
|
+--------+---------+
| wantsJSON()? |
+--------+---------+
yes | no
+--------------+--------------+
| |
json_encode($stories) require layout.php
| (with view partial)
JSON response |
| Full HTML page
enhance.js (browser displays)
renders DOM
Detection: How the Controller Knows
PHP reads the Accept header from the $_SERVER superglobal:
str_contains() (PHP 8.0+) checks whether application/json appears in the Accept header. When enhance.js makes requests, it sends Accept: application/json, so this returns true. Normal browser requests send Accept: text/html, so it returns false.
The enhance.js script explicitly sets the header on every request:
The Adaptive Controller
Compare the Tutorial 07 and Tutorial 09 controllers side by side. The Model and Views are unchanged — only the controller gains branching logic:
Tutorial 07 (HTML only)
Tutorial 09 (Adaptive)
For write operations (store, update), the controller also reads input differently based on the request type:
The delete and update follow the same pattern:
A small helper method keeps the JSON responses consistent:
Routes: Supporting Both Patterns
The front controller (app.php) extends Tutorial 07's routing with PUT and DELETE support for fetch() clients:
HTML forms still POST to /stories/:id (update) or /stories/:id/delete (delete). The enhance.js script uses PUT and DELETE directly.
The Views: Unchanged
The PHP view templates (index.php, show.php, form.php) are reused from Tutorial 07 without changes. The only layout modification is wrapping the content in a <div id="content"> and adding the enhance.js script:
The id="content" gives enhance.js a target element. On first page load, the server-rendered HTML is already inside this div. The script just attaches event listeners to enhance subsequent interactions.
Progressive Enhancement with enhance.js
The PHP version of enhance.js is nearly identical to the Node.js version. The main difference is how it determines the base URL, since PHP uses path-info style URLs (app.php/stories):
This auto-detection means the same script works whether the app runs under the built-in server (/app.php/stories) or Apache with .htaccess rewriting.
The rest of the script — API helper, render functions, and event interception — works the same way as the Node.js version. It intercepts clicks on links and form submissions from the server-rendered HTML, replacing them with fetch() calls that request JSON.
Running the Demo
1. Start PHP's built-in server:
2. Test with JavaScript enabled (default): Open http://localhost:8080/app.php/stories. Click stories, create, edit, delete. Notice that the page never reloads — enhance.js is intercepting everything and using fetch().
3. Test with JavaScript disabled: Open DevTools → Settings → Debugger → "Disable JavaScript". Reload. Click around. Everything still works, but now every action causes a full page reload. This is the Tutorial 07 experience.
4. Test content negotiation via curl:
5. Open the Network tab in DevTools with JavaScript enabled. You will see fetch requests with Accept: application/json headers. The responses are JSON, not HTML.
Three-Way Comparison
| Aspect | 07: Server-Side | 08: Client-Side | 09: Adaptable |
|---|---|---|---|
| Server response | HTML always | JSON always | HTML or JSON |
| Rendering | Server (PHP templates) | Browser (JS DOM) | Both (depends on client) |
| First paint | Fast (server HTML) | Delayed (JS must load + fetch) | Fast (server HTML, then JS enhances) |
| Subsequent actions | Full page reload | DOM swap (no reload) | DOM swap when JS available |
| Works without JS? | Yes | No | Yes |
| HTTP methods | GET + POST | GET, POST, PUT, DELETE | All (forms use POST, fetch uses PUT/DELETE) |
| Input reading | $_POST |
php://input |
Both (checks wantsJSON()) |
| Model changes | — | — | None (same as Tutorial 07) |
| View changes | — | All new (JS rendering) | Layout only (add wrapper + script) |
| Controller changes | — | All new (JS controller) | Add wantsJSON() branching |
| XSS prevention | htmlspecialchars() |
textContent |
Both |
When to Use Each Pattern
Server-Side MVC (Tutorial 07) — Content-heavy sites where SEO and accessibility matter. Works everywhere. Simple to reason about.
Client-Side MVC (Tutorial 08) — Highly interactive apps (dashboards, email clients, chat). Smooth feel with no reloads. Requires JavaScript.
Adaptable MVC (Tutorial 09) — The best of both worlds when you need it. More controller complexity, but maximum compatibility. Ideal when you want a fast first load with progressive enhancement.