Adaptable MVC

Progressive enhancement with content negotiation in PHP

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 JSenhance.js intercepts clicks, sends fetch() with Accept: 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_demo database 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:

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' } });

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)

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'; }

Tutorial 09 (Adaptive)

public function index(): void { $stories = $this->story->findAll(); if ($this->wantsJSON()) { $this->jsonResponse($stories); // <- JSON path return; } // HTML path (same as Tutorial 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 ... }

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); }

Routes: Supporting Both Patterns

The front controller (app.php) extends Tutorial 07's routing with PUT and DELETE support for fetch() clients:

// Existing GET/POST routes (same as Tutorial 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 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):

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

Running the Demo

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