<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../backend/_lib/Validator.php';
require_once __DIR__ . '/../../../backend/_lib/JsonResponse.php';
require_once __DIR__ . '/../../../backend/_lib/Db.php';
use DemoBuilder\Db;
use DemoBuilder\JsonResponse;
use DemoBuilder\Validator;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
JsonResponse::error('Method not allowed', 405);
}
// ─── BUYER CONFIG ─────────────────────────────────────────────────────
// Resolution order: explicit env var → SQLite in this dir.
$pdo = Db::connect(sqliteDir: __DIR__);
$payload = Validator::make($_POST)
->required('slug')->regex('/^[a-z0-9-]+$/i')->maxLength(120)
->required('html')->maxLength(5_000_000)
->required('data')->maxLength(10_000_000)
->validateOrFail();
$driver = (string) $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
$sql = match ($driver) {
'mysql' => 'INSERT INTO pages (slug, html, data) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE html = VALUES(html), data = VALUES(data),
updated_at = CURRENT_TIMESTAMP',
default => 'INSERT INTO pages (slug, html, data) VALUES (?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET html = excluded.html,
data = excluded.data,
updated_at = CURRENT_TIMESTAMP',
};
$stmt = $pdo->prepare($sql);
$stmt->execute([
(string) $payload['slug'],
(string) $payload['html'],
(string) $payload['data'],
]);
JsonResponse::success([
'slug' => (string) $payload['slug'],
'message' => 'Saved to database.',
'driver' => $driver,
]);
Save to MySQL
PDO + prepared statements with a SQLite fallback so it boots zero-config. One driver swap and the same handler talks to MySQL, MariaDB, or PostgreSQL.
Walkthrough
-
Bootstrap the schema
Run
php migrate.phponce in this directory. With noDB_DSNenv var set, it provisionsstorage.db(SQLite) right next to the example. The shippedschema.sqlalso includes commented-out MySQL + PostgreSQL DDL — copy that block into your DB tool of choice when you migrate to production.Bash# Zero-config dev (SQLite) php migrate.php # → Migrated 2 statements via DSN: sqlite (default) # Production (MySQL) export DB_DSN='mysql:host=localhost;dbname=builderjs;charset=utf8mb4' export DB_USER='your_user' export DB_PASS='your_password' mysql -u$DB_USER -p$DB_PASS builderjs < schema.sql -
Validate every $_POST through the shared validator
Keep the validator dependencies tight:
slugis regex-bounded so the storage key can never carry traversal characters;html+datahave explicit byte caps that fire before a multi-megabyte body hits PDO. Every validation failure becomes a uniform422response with field-level errors.PHPuse DemoBuilder\Validator; $payload = Validator::make($_POST) ->required('slug')->regex('/^[a-z0-9-]+$/i')->maxLength(120) ->required('html')->maxLength(5_000_000) ->required('data')->maxLength(10_000_000) ->validateOrFail(); -
Upsert via a single prepared statement
The handler uses
INSERT … ON CONFLICT DO UPDATE(SQLite + Postgres) orINSERT … ON DUPLICATE KEY UPDATE(MySQL). The driver name is read once from PDO and the right SQL is dispatched. No second SELECT-then-INSERT round-trip; one network hop per save.PHP$stmt = $pdo->prepare($sql); // dispatch by driver, prepared once $stmt->execute([ $payload['slug'], $payload['html'], $payload['data'], ]); -
Wire the builder
In
demo/builder.php, change one line under theendpointsblock:endpoints.save→ yourmysql-save.phpURL. Reload the builder; every Save click now lands in your DB. Round-trip on the next page load by reading the same row back intobuilder.load().PHP// demo/builder.php — top of file (the ONE labelled config block) $builderConfig = [ 'themesRoot' => __DIR__ . '/themes', 'endpoints' => [ 'save' => '/examples/backend/1-mysql/mysql-save.php', // ← here 'assetUpload' => '/backend/asset-upload.php', // … ], // … ];
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.
-- SQLite (zero-config dev default)
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
html TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX IF NOT EXISTS pages_slug_idx ON pages(slug);
-- MySQL 8.0 / MariaDB 10.6+
CREATE TABLE pages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
html MEDIUMTEXT NOT NULL,
data MEDIUMTEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX pages_slug_idx (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Notes
Why an UPSERT? The Builder writes the same slug on every Save — so the second call would conflict on the unique index. SQLite 3.24+, MySQL 8.0+, and PostgreSQL 9.5+ all support a single-statement UPSERT; older versions can fall back to INSERT … ON DUPLICATE KEY UPDATE (MySQL) or a manual SELECT-then-UPDATE/INSERT wrapped in a transaction.
Reading saved pages back. The round-trip is symmetric: SELECT data FROM pages WHERE slug = ?, json_decode the result, hand it to builder.load() as the seed JSON. The shipped builder.php already accepts ?slug= as a query param if you wire the loader for it.
Production swap matrix.
- MySQL / MariaDB. Set
DB_DSN='mysql:host=…;dbname=…;charset=utf8mb4'+DB_USER+DB_PASS. The handler'smatch ($driver)dispatches MySQL syntax automatically. - PostgreSQL. Set
DB_DSN='pgsql:host=…;dbname=…'. The defaultON CONFLICTbranch already speaks PG; the only extra step is running the commented-out PG block inschema.sql. - Cloudflare D1 / Turso / LibSQL. They all expose SQLite-compatible HTTP endpoints; replace
new PDO(…)inDb::connect()with the appropriate HTTP client (curl+ the vendor's auth header) and keep the SQL identical. - SQL Server. Swap the UPSERT for
MERGE INTO pages …in a fourthmatcharm; the rest of the handler is driver-neutral.
Atomicity & concurrency. The single INSERT … ON CONFLICT … DO UPDATE is atomic per-row at the engine level. For multi-row saves (e.g. a "publish all pages" admin action) wrap the calls in $pdo->beginTransaction() + commit(). Don't share PDO instances across forked workers — the connection cache in Db::connect() is request-scoped on purpose.
What about NoSQL? If your store is MongoDB / DynamoDB / Firestore, skip the SQL-flavour swaps above and treat mysql-save.php as the contract reference: validate the same three fields, write to your store's primary key (slug), respond with the same JSON shape. The Validator + JsonResponse helpers are storage-agnostic.