Skip to content
Wire to production D1 5 min read Integration Copy-along · 60 min

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 under demo/uploads/. 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.

The save / load contract

When the buyer clicks Save, the engine POSTs to whatever URL you configured as saveUrl:

The body is `application/x-www-form-urlencoded` with **two engine-emitted fields**:
Two more fields are layered on by the demo's chrome (`demo/assets/js/builder-chrome.js`) for path-based persistence:
If you ship your own host page that doesn't wrap the engine's save call, your handler will receive only `html` + `data` and you map persistence by session / page id / whatever your domain owns. If you copy the demo's `save.php` verbatim, ALSO copy `wireSaveButton` from `builder-chrome.js` so the 4-field shape stays in sync.

Your handler returns JSON:

That's the whole contract. The engine doesn't care where you persist (file, DB, S3, blob storage); it only cares about the response shape.

The shipped demo/backend/save.php handler

Read it (60 LOC). It's the buyer's reference for a minimum-viable backend:

Three reusable patterns:
  1. Validator pipeline — chain required(), matches(), maxBytes(), isJson() calls. The library is demo/backend/_lib/Validator.php — copy it into your project.
  2. Path traversal guardrefuses('sample', '/\.\./') blocks ../etc/passwd style attacks. Apply to every user-controlled path.
  3. Atomic-ish writemkdir parent + file_put_contents. For real DBs use a transaction; for S3 use a single PutObject (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:

Schema:
The composite UNIQUE KEY makes "save same path again" upsert; without it you'd accumulate duplicate rows.

Adapter — S3 / object storage

For static-site or serverless deployments, write the HTML + JSON to object storage:

The shipped [`examples/backend/2-s3/`](/examples/backend/2-s3/) shows the AWS SDK setup + a MinIO fallback for local dev (no AWS credentials needed).

Auth — wrap the handler

Every save is authenticated. Wrap your handler with whatever your app uses:

For JWT / Bearer-token auth, swap the session check for a `verifyJwt($_SERVER['HTTP_AUTHORIZATION'])` call. The shipped [`examples/backend/3-auth/`](/examples/backend/3-auth/) walks both session + JWT patterns.

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.

Each Builder instance has its own closure; tenant A's instance can never accidentally save to tenant B's space because the URL is baked into the closure at boot. The shipped [`examples/extensions/5-multi-tenant/`](/examples/extensions/5-multi-tenant/) walks this end-to-end with `_lib/TenantRegistry.php` showing the host-side resolution.

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:

Shipped backend handler at `demo/backend/asset-upload.php` accepts a `multipart/form-data` POST with `file=<binary>` and returns:
`DemoFileBrowser.pickFile()` reads `result.url` and hands it back through the `handleUrl` callback. The same JSON contract works with any picker — `EasyFileBrowser` in Acelle, 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:

  1. POST /api/v1/exports/queue with the page id.
  2. Server enqueues a background job, returns { "jobId": "abc-123" }.
  3. Buyer's UI shows a "exporting…" toast.
  4. Server-Sent Events (SSE) or polling against /api/v1/exports/status?jobId=abc-123 returns progress.
  5. 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.php to MySQL with a pages table and user/tenant scoping.
  • ✅ Adapt save.php to S3 / object storage with a PutObject call.
  • ✅ 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 errors array — 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 saveUrl is set in the constructor options. If not set, clicking Save throws saveUrl 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 — every new Builder(...) call MUST have its own server-rendered constants.

What's next?