Backend · 4 of 4

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

  1. Bump a version on every save

    A single page_versions row per slug; SELECT version, increment, UPSERT back. 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();
  2. Poll the change feed

    Clients track the last-seen version in memory. Every ~2 s they GET changes.php?slug=…&since=N; if version > N, the response carries the fresh state. No state on the server, no per-client subscription, no fan-out logic.

    JavaScript
    let 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);
  3. 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.

  4. 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 have save.php publish 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);
      }
    });

Live demo

demo-mini-builder--minimal
Realtime editorversion: —

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.

<?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'],
]);

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)

Live · waiting for first save

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.