<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../backend/_lib/JsonResponse.php';
require_once __DIR__ . '/../../../backend/_lib/Db.php';
require_once __DIR__ . '/../../../backend/_lib/Validator.php';
use DemoBuilder\Db;
use DemoBuilder\JsonResponse;
use DemoBuilder\Validator;
$query = Validator::make($_GET)
->required('slug')->regex('/^[a-z0-9-]+$/i')->maxLength(120)
->optional('since')->regex('/^\d+$/')
->validateOrFail();
$slug = (string) $query['slug'];
$since = isset($query['since']) ? (int) $query['since'] : 0;
$pdo = Db::connect(sqliteDir: __DIR__);
$stmt = $pdo->prepare('SELECT html, data, version, updated_at
FROM page_versions WHERE slug = ?');
$stmt->execute([$slug]);
$row = $stmt->fetch();
if (!$row) {
JsonResponse::success(['changed' => false, 'version' => 0]);
}
$serverVersion = (int) $row['version'];
if ($serverVersion <= $since) {
JsonResponse::success(['changed' => false, 'version' => $serverVersion]);
}
JsonResponse::success([
'changed' => true,
'version' => $serverVersion,
'html' => (string) $row['html'],
'data' => (string) $row['data'],
'updated_at' => (string) $row['updated_at'],
]);
Realtime Sync
Multi-tab live sync over a tiny PHP polling endpoint. Swap one function for Pusher / Ably / WebSocket without touching the save side.
Walkthrough
-
Bump a version on every save
A single
page_versionsrow per slug;SELECT version, increment,UPSERTback. The version is the only "delta" the change feed needs — no per-event log, no Redis, no queue.PHP$pdo->beginTransaction(); $stmt = $pdo->prepare('SELECT version FROM page_versions WHERE slug = ?'); $stmt->execute([$slug]); $next = ((int) $stmt->fetchColumn()) + 1; $pdo->prepare('INSERT INTO page_versions … ON CONFLICT(slug) DO UPDATE SET …') ->execute([$slug, $html, $data, $next]); $pdo->commit(); -
Poll the change feed
Clients track the last-seen version in memory. Every ~2 s they GET
changes.php?slug=…&since=N; ifversion > N, the response carries the fresh state. No state on the server, no per-client subscription, no fan-out logic.JavaScriptlet lastVersion = 0; async function pollOnce() { const res = await fetch( '/examples/backend/4-realtime/changes.php?slug=' + slug + '&since=' + lastVersion); const json = await res.json(); if (json.changed) { lastVersion = json.version; render(json.html); } } setInterval(pollOnce, 2000); -
Try it — open this page in two tabs
Edit text in one tab, click Save; the other tab's viewer updates within 2 s. The status badge shows the current version, last-update timestamp, and connection state. The form is pure PHP behind it — no library, no compile step.
-
Swap polling for WebSockets
Polling is the transport you control end-to-end. For production scale, replace
pollOnce()with a Pusher / Ably / centrifugo channel subscription, and havesave.phppublish to the channel after the DB commit. The save handler's contract — version-bumped row — is what the channel publishes; consumers stay symmetric.JavaScript// Pusher example — drop-in replacement for the polling loop. import Pusher from 'pusher-js'; const pusher = new Pusher(KEY, { cluster: 'us-east' }); const channel = pusher.subscribe('page.' + slug); channel.bind('updated', ({ version, html }) => { if (version > lastVersion) { lastVersion = version; render(html); } });
The whole snippet
Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.
Notes
Try it. Open this page in two tabs side-by-side. Type in one tab's editor, click Save; within ~2 s the other tab's viewer mirrors the change.
Editor (this tab)
Viewer (any tab on this page)
— no save yet —
The viewer polls changes.php every 2 s. Version bumps from any tab propagate within one cycle.
One-time setup. Run php migrate.php in this dir on first use to provision the SQLite page_versions table. Re-running is idempotent.
Why polling, not WebSocket, in the example? Polling is the transport every PHP host already supports — no extra process, no Redis, no docker container, no rewriting the save side. It scales to ~1k concurrent clients per single PHP-FPM worker without breaking a sweat. WebSockets become worth the operational cost above that scale or below ~50 ms latency targets; the swap is documented in step 4.
Concurrent saves. The save handler runs the version bump inside a transaction, so two browsers saving at once produce two adjacent versions (N → N+1 → N+2) without losing one. SQLite serialises writes; MySQL / Postgres / others use the same row-level transaction. No optimistic-locking layer needed for this contract.
What about presence / "X is editing"? Add a page_editors table keyed by slug + user_id + last_seen; clients POST /examples/backend/4-realtime/heartbeat.php every ~5 s, changes.php reads + returns the active list. Same architectural shape — version-keyed feed; presence is just a second feed.
Combine with W5.3 auth. The current save.php is unauthenticated for demo simplicity. In production wrap it with $auth->requireUser(); the version field stays the source of truth for the change feed regardless of who triggered the bump.