Wire a real backend
Save · load · upload · auth · multi-tenant — 60 minutes round-trip.
The shipped demo/backend/save.php writes builder JSON to disk next to the sample it loaded (demo/themes/<dir>/<sample>.json). That's fine for the demo. For production you'll swap it for your own stack — MySQL / Postgres / S3 / Firebase — plus auth and tenant-scoping. This doc walks the shipped contract and four adapter patterns. Time budget: 60 minutes.
Switch the demo backend on
The package ships with every write endpoint switched off. Until you configure it, Save, image upload, theme import and the examples/backend/* handlers answer 403 and write nothing — the builder still loads, edits and exports. The switch is one file, demo/backend/config.php:
mode |
Who can write | Use it for |
|---|---|---|
'disabled' (default) |
Nobody. | A copy you only browse, or any server you have not secured yet. |
'key' |
A browser session signed in at /backend/unlock.php with accessKey (16+ characters). |
Anything other people can reach — a staging server, a shared demo. |
'open' |
Anyone who can reach the server. No authentication. | Your own machine, or a sandbox you are prepared to wipe. |
Edit the file, or leave it untouched and set environment variables instead:
What the gate (demo/backend/_lib/BackendAccess.php) enforces once a mode is on:
- Same-origin requests only — a page on another site cannot POST on a visitor's behalf.
- Theme import is authenticated-only. A bundle puts HTML templates under the web root, so
theme-upload.phpruns in mode'key'only, never in'open'. It unpacks an allow-list of theme file types and rejects the whole bundle on a.phpfile, a hidden file, a symlink or a..path. - Uploads are raster images only (JPEG / PNG / WebP / GIF), identified from the file's bytes and stored under a server-generated name. SVG is refused: it can carry script, and serving it from the builder's origin is stored XSS.
save.phponly overwrites a sample that already exists, or writes into a scratch directory whose name starts with_.
Two files add a second lock on Apache — demo/uploads/.htaccess and demo/themes/.htaccess switch script execution off in the directories PHP writes to. On nginx, make sure no location ~ \.php$ block applies to /uploads/ or /themes/.
The gate protects the demo. It is not your application's auth: a real integration replaces these handlers and checks its own users (see Auth — wrap the handler).
The save / load contract
When the buyer clicks Save, the engine POSTs to whatever URL you configured as saveUrl:
Your handler returns JSON:
The shipped demo/backend/save.php handler
Read it (about 120 lines). It's the buyer's reference for a minimum-viable backend — condensed here:
- Gate first —
BackendAccess::requireWrite()runs before anything reads the request. In your app this line is your own auth check. - Validator pipeline — chain
required(),regex(),maxLength(), thenvalidateOrFail(). The library isdemo/backend/_lib/Validator.php— copy it into your project. - Paths built from plain segments —
sampleis slash-separated[A-Za-z0-9_-]+segments with no dots, so a request cannot reach../, a dotfile, orx.php.html; the handler adds the extension itself. Apply the same rule to every user-controlled path. - Validate, then write atomically — the JSON is decoded before any file is touched, and each file is written to a temp name and
rename()d into place. For real DBs use a transaction; for S3 use a singlePutObject(it's atomic).
Adapter — MySQL
Replace the file write with a query. The shipped examples/backend/1-mysql/ walks this end-to-end. The skeleton:
Adapter — S3 / object storage
For static-site or serverless deployments, write the HTML + JSON to object storage:
Auth — wrap the handler
Every save is authenticated. The demo's own handlers do it with the shared access gate (BackendAccess::requireWrite(), see Switch the demo backend on); in your app, replace that line with whatever your app uses:
Multi-tenant — closure capture
The W6.D lesson: the engine stays tenant-naive. Tenant identity lives in the host's Builder constructor closure, not on the engine instance.
Asset upload
The engine never opens a file input or POSTs an upload itself. Instead it
calls your onBrowse(handleUrl) callback when the buyer clicks Browse on any
image-bearing control. The host opens its own picker (modal / native / 3rd
party), uploads to its own endpoint, then calls handleUrl(url) with the
resulting public URL:
DemoFileBrowser.pickFile() reads result.url and hands it back through the
handleUrl callback. The same JSON contract works with any picker —
your host's file picker in your host app, an S3 pre-signed picker, a custom asset library,
etc.
Async hand-off for heavy exports
The W6.C.15 lesson: HTML / ZIP / Bundle exports can be slow on big pages. Don't block the buyer's UI on a 30-second export. Pattern:
- POST
/api/v1/exports/queuewith the page id. - Server enqueues a background job, returns
{ "jobId": "abc-123" }. - Buyer's UI shows a "exporting…" toast.
- Server-Sent Events (SSE) or polling against
/api/v1/exports/status?jobId=abc-123returns progress. - On completion, the response includes a download URL.
This pattern matters for large galleries, multi-language exports, and any case where post-processing exceeds 2-3 seconds.
Did you make it?
You should now be able to:
- ✅ State the save contract: 4 form fields + JSON status response.
- ✅ Adapt
save.phpto MySQL with apagestable and user/tenant scoping. - ✅ Adapt
save.phpto S3 / object storage with aPutObjectcall. - ✅ Add an auth wrapper (session OR JWT) to any backend handler.
- ✅ Use closure-capture for multi-tenant Builder instances.
If a step didn't work:
- 🩹 Save returns 422 → your validator caught a malformed field. Check the response's
errorsarray — each entry names which field failed. - 🩹 Save returns 500 → tail your PHP error log. Most often: DB connection failed (wrong host/creds), filesystem write failed (permissions), or a fatal in your handler.
- 🩹 Builder doesn't fire the Save POST → confirm
saveUrlis set in the constructor options. If not set, clicking Save throwssaveUrl is not configured. Pass it in the constructor options. - 🩹 Multi-tenant accidentally crosses tenants → the closure isn't capturing the right
tenantId. Re-read the closure — everynew Builder(...)call MUST have its own server-rendered constants.
What's next?
- → i18n locale packs — multi-language locale support, the production polish for SaaS builds.
- 🔗
examples/backend/1-mysql/— runnable MySQL adapter end-to-end. - 🔗
examples/backend/2-s3/— S3 + MinIO. - 🔗
examples/backend/3-auth/— session + JWT. - 🔗
examples/backend/4-realtime/— WebSocket multi-window sync. - 🔗
examples/extensions/5-multi-tenant/— closure-capture pattern.