In Module 07, the server always rendered HTML. Every request got a complete page back. In Module 08, the server always returned JSON, and JavaScript rendered everything in the browser. Each approach had trade-offs: Module 07 works without JavaScript but reloads on every action; Module 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:
enhance.js intercepts clicks, sends fetch() with Accept: application/json, gets JSON, renders client-side (Module 08 behavior)This is progressive enhancement: the base experience works everywhere, and JavaScript makes it better when available.
stories_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.
<div id="content"> + <script>)Run locally: php -S localhost:8080 → http://localhost:8080/app.php/stories
Live demo: Try the Adaptable MVC demo
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:
PHP reads the Accept header from the $_SERVER superglobal:
private function wantsJSON(): bool {
$accept = $_SERVER['HTTP_ACCEPT'] ?? 'text/html';
return str_contains($accept, 'application/json');
}
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:
fetch(url, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
Compare the Module 07 and Module 09 controllers side by side. The Model and Views are unchanged — only the controller gains branching logic:
public function index(): void {
$stories = $this->story->findAll();
$pageTitle = 'All Stories';
$baseUrl = $this->baseUrl;
$viewFile = $this->viewDir . '/stories/index.php';
require $this->viewDir . '/layout.php';
}
public function index(): void {
$stories = $this->story->findAll();
if ($this->wantsJSON()) {
$this->jsonResponse($stories); // ← JSON path
return;
}
// HTML path (same as Module 07)
$pageTitle = 'All Stories';
$baseUrl = $this->baseUrl;
$viewFile = $this->viewDir . '/stories/index.php';
require $this->viewDir . '/layout.php';
}
For write operations (store, update), the controller also reads input differently based on the request type:
public function store(): void {
if ($this->wantsJSON()) {
// JSON client (fetch) → read from php://input
$data = json_decode(file_get_contents('php://input'), true);
$title = $data['title'] ?? '';
} else {
// HTML form → read from $_POST
$title = $_POST['title'] ?? '';
}
// ... validation, create, respond ...
}
php://input? PHP auto-populates $_POST for URL-encoded form data, but JSON request bodies need to be read manually from the php://input stream and decoded with json_decode().
The delete and update follow the same pattern:
public function destroy(int $id): void {
$deleted = $this->story->delete($id);
if (!$deleted) {
if ($this->wantsJSON()) {
$this->jsonResponse(['error' => 'Story not found'], 404);
return;
}
http_response_code(404);
echo 'Story not found';
return;
}
if ($this->wantsJSON()) {
$this->jsonResponse(['message' => 'Story deleted']);
return;
}
header('Location: ' . $this->baseUrl . '/stories');
exit;
}
A small helper method keeps the JSON responses consistent:
private function jsonResponse(mixed $data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
}
The front controller (app.php) extends Module 07’s routing with PUT and DELETE support for fetch() clients:
// Existing GET/POST routes (same as Module 07)
if ($method === 'GET') { /* index, show, new, edit */ }
elseif ($method === 'POST') { /* store, update, destroy */ }
// New: PUT and DELETE for fetch() clients
elseif ($method === 'PUT' && $id) {
$storyController->update($id);
} elseif ($method === 'DELETE' && $id) {
$storyController->destroy($id);
}
HTML forms still POST to /stories/:id (update) or /stories/:id/delete (delete). The enhance.js script uses PUT and DELETE directly.
The PHP view templates (index.php, show.php, form.php) are reused from Module 07 without changes. The only layout modification is wrapping the content in a <div id="content"> and adding the enhance.js script:
<!-- layout.php changes from Module 07 -->
<div id="content">
<?php require $viewFile; ?>
</div>
<!-- Before </body> -->
<script src="<?= $baseUrl ?>/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.
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):
// Detect base URL from the script tag
var scripts = document.getElementsByTagName('script');
var scriptSrc = '';
for (var i = 0; i < scripts.length; i++) {
if (scripts[i].src && scripts[i].src.indexOf('enhance.js') !== -1) {
scriptSrc = scripts[i].getAttribute('src');
break;
}
}
// The script src is "{baseUrl}/enhance.js" — strip /enhance.js to get base
var BASE_APP = scriptSrc.replace(/\/enhance\.js$/, '');
var BASE = BASE_APP + '/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.
enhance.js then quietly attaches event listeners. All subsequent interactions are enhanced with fetch() + DOM updates — no page reloads.
1. Start PHP’s built-in server:
cd php-tutorial/09-adaptable-mvc php -S localhost:8080
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 Module 07 experience.
4. Test content negotiation via curl:
# Default request → HTML curl http://localhost:8080/app.php/stories # Returns full HTML page # JSON request → JSON curl -H "Accept: application/json" http://localhost:8080/app.php/stories # Returns JSON array
5. Open the Network tab in DevTools with JavaScript enabled. You’ll see fetch requests with Accept: application/json headers. The responses are JSON, not HTML.
<script>alert('xss')</script>. It’s safe in both paths: server-rendered HTML uses htmlspecialchars(), and the JavaScript rendering uses textContent.
| 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 Module 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 |
Server-Side MVC (Module 07) — Content-heavy sites where SEO and accessibility matter. Works everywhere. Simple to reason about.
Client-Side MVC (Module 08) — Highly interactive apps (dashboards, email clients, chat). Smooth feel with no reloads. Requires JavaScript.
Adaptable MVC (Module 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.