09. Adaptable MVC

Introduction

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:

This is progressive enhancement: the base experience works everywhere, and JavaScript makes it better when available.

Module 07: Browser ──GET /stories──> Server ──> HTML (always) Module 08: Browser ──fetch('/api/stories')──> Server ──> JSON (always) Module 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

Database required: This module uses the same 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.

Demo Files

Run locally: php -S localhost:8080http://localhost:8080/app.php/stories

Live demo: Try the Adaptable MVC demo

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 Module 07 and Module 09 controllers side by side. The Model and Views are unchanged — only the controller gains branching logic:

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

Module 09 (Adaptive)

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

Routes: Supporting Both Patterns

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 Views: Unchanged

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.

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.

First paint is instant. On the initial page load, the server renders full HTML. There’s no “Loading...” spinner. The page is immediately usable. enhance.js then quietly attaches event listeners. All subsequent interactions are enhanced with fetch() + DOM updates — no page reloads.

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

XSS test: Create a story with the title <script>alert('xss')</script>. It’s safe in both paths: server-rendered HTML uses htmlspecialchars(), and the JavaScript rendering uses textContent.

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

When to Use Each Pattern

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.

Real-world examples: GitHub uses progressive enhancement — the site works without JavaScript, but JS enhances the experience (inline editing, live updates). Many modern frameworks (Laravel Livewire, HTMX, Hotwire) build on this same principle of server rendering with client-side enhancement.