<!-- demo:embed example="backend/1-mysql" -->

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:

```bash
# Local evaluation — your machine only
BJS_DEMO_BACKEND=open php -S localhost:8000 -t demo/

# Reachable by others — require the access key
BJS_DEMO_BACKEND=key BJS_DEMO_BACKEND_KEY="$(php -r 'echo bin2hex(random_bytes(24));')" php -S 0.0.0.0:8000 -t demo/
```

On Apache use `SetEnv BJS_DEMO_BACKEND key` in the vhost; on nginx, `fastcgi_param BJS_DEMO_BACKEND key;`. An unknown mode or a short key is a configuration error — the endpoints answer `500` and name the problem rather than guess.

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.php` runs in mode `'key'` only, never in `'open'`. It unpacks an allow-list of theme file types and rejects the whole bundle on a `.php` file, 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.php` only 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](#auth-wrap-the-handler)).

## The save / load contract

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

```javascript
const builder = new Builder({
    mainContainer: '#MyCanvas',
    saveUrl: '/api/v1/pages/save',  // ← your URL
});
```

The body is `application/x-www-form-urlencoded` with **two engine-emitted fields**:

```
html    = "<html>…</html>"                        // builder.getHtml() output
data    = "{…}"                                  // JSON.stringify(builder.getData()) output
```

Two more fields are layered on by the demo's chrome (`demo/assets/js/builder-chrome.js`) for path-based persistence:

```
dir     = "default"                              // theme directory (chrome-added)
sample  = "master/sample/email/Welcome"          // sample path (chrome-added; no .json suffix)
```

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:

```json
{ "status": "success", "saved": true }
```

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 (about 120 lines). It's the buyer's reference for a minimum-viable backend — condensed here:

```php
<?php
declare(strict_types=1);

require_once __DIR__ . '/_lib/BackendAccess.php';
require_once __DIR__ . '/_lib/Validator.php';
require_once __DIR__ . '/_lib/JsonResponse.php';

use DemoBuilder\BackendAccess;
use DemoBuilder\Validator;
use DemoBuilder\JsonResponse;

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    JsonResponse::error('Method not allowed', 405);
}

BackendAccess::requireWrite();          // 403 / 401 unless config.php allows this request

$MAX_BYTES = 5 * 1024 * 1024;
$validated = Validator::make($_POST)
    ->required('dir')   ->regex('/^[A-Za-z0-9_\-]+$/')->maxLength(64)
    ->required('sample')->regex('#^[A-Za-z0-9_\-]+(/[A-Za-z0-9_\-]+){0,5}$#')->maxLength(200)
    ->required('html')  ->maxLength($MAX_BYTES)
    ->required('data')  ->maxLength($MAX_BYTES)
    ->validateOrFail();

$data = json_decode($validated['data'], true);
if (!is_array($data)) {
    JsonResponse::error('data is not valid JSON', 422);
}

$target = __DIR__ . '/../themes/' . $validated['dir'] . '/' . $validated['sample'];
if (!str_starts_with($validated['dir'], '_') && !is_file($target . '.json')) {
    JsonResponse::error('Unknown sample', 404);     // never creates a new theme directory
}

// …temp file + rename() for both files (see writeAtomic() in the real handler)…

JsonResponse::success(['saved' => true]);
```

Four reusable patterns:

1. **Gate first** — `BackendAccess::requireWrite()` runs before anything reads the request. In your app this line is your own auth check.
2. **Validator pipeline** — chain `required()`, `regex()`, `maxLength()`, then `validateOrFail()`. The library is `demo/backend/_lib/Validator.php` — copy it into your project.
3. **Paths built from plain segments** — `sample` is slash-separated `[A-Za-z0-9_-]+` segments with no dots, so a request cannot reach `../`, a dotfile, or `x.php.html`; the handler adds the extension itself. Apply the same rule to every user-controlled path.
4. **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 single `PutObject` (it's atomic).

## Adapter — MySQL

Replace the file write with a query. The shipped [`examples/backend/1-mysql/`](/examples/backend/1-mysql/) walks this end-to-end. The skeleton:

```php
require_once __DIR__ . '/_lib/Db.php';

$db = new \DemoBuilder\Db('mysql:host=localhost;dbname=builderjs', 'user', 'pass');

$db->query(
    'INSERT INTO pages (theme_dir, sample_path, html, data, updated_at)
     VALUES (?, ?, ?, ?, NOW())
     ON DUPLICATE KEY UPDATE html = VALUES(html), data = VALUES(data), updated_at = NOW()',
    [$validated['dir'], $validated['sample'], $validated['html'], $validated['data']]
);

JsonResponse::success(['saved' => true]);
```

Schema:

```sql
CREATE TABLE pages (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    theme_dir   VARCHAR(64)     NOT NULL,
    sample_path VARCHAR(255)    NOT NULL,
    html        MEDIUMTEXT      NOT NULL,
    data        JSON            NOT NULL,
    user_id     BIGINT UNSIGNED NOT NULL,           -- for auth scoping
    tenant_id   BIGINT UNSIGNED NOT NULL,           -- for multi-tenant
    updated_at  DATETIME        NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY (theme_dir, sample_path, user_id, tenant_id)
);
```

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:

```php
$s3 = new Aws\S3\S3Client([/* …credentials… */]);

$key = "{$validated['dir']}/{$validated['sample']}.json";
$s3->putObject([
    'Bucket'      => 'my-builderjs-pages',
    'Key'         => $key,
    'Body'        => $validated['data'],
    'ContentType' => 'application/json',
]);

JsonResponse::success(['saved' => true]);
```

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. The demo's own handlers do it with the shared access gate (`BackendAccess::requireWrite()`, see [Switch the demo backend on](#switch-the-demo-backend-on)); in your app, replace that line with whatever your app uses:

```php
session_start();
if (empty($_SESSION['user_id'])) {
    JsonResponse::error('Unauthorized', 401);
}

// …validation + persist…

// And include the user_id in the persistence layer:
$db->query(
    'INSERT INTO pages (..., user_id) VALUES (..., ?)',
    [..., $_SESSION['user_id']]
);
```

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.

```php
// Server-side: pick the tenant from the session
$tenantId = $_SESSION['tenant_id'];
?>

<script>
    // Closure-capture: tenantId baked into the constructor's onSave callback.
    const tenantId = <?= json_encode($tenantId) ?>;

    const builder = new Builder({
        mainContainer: '#MyCanvas',
        saveUrl: `/api/v1/pages/save?tenant=${tenantId}`,
    });
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>
```

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:

```javascript
const builder = new Builder({
    mainContainer: '#MyCanvas',
    onBrowse: (handleUrl) => {
        // Host owns the picker. Demo uses DemoFileBrowser; the host app uses
        // your host's file picker; your app can wire any picker that resolves to a URL.
        DemoFileBrowser.pickFile(
            (r) => handleUrl(r.url),
            { endpoint: '/api/v1/assets/upload' }   // ← your URL
        );
    },
});
```

Shipped backend handler at `demo/backend/asset-upload.php` accepts a
`multipart/form-data` POST with `file=<binary>` — a JPEG, PNG, WebP or GIF up
to 10 MB — and returns:

```json
{ "status": "success", "url": "https://your-host/uploads/asset_3f9c2a71d0b84e5c9a1b6d27.png", "filename": "asset_3f9c2a71d0b84e5c9a1b6d27.png" }
```

The file type is read from the bytes, not from the uploaded name, and the
stored name is generated server-side. Anything else — including SVG — is
answered with `422`.

`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:

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?

- → [i18n locale packs](/docs/14-i18n/) — multi-language locale support, the production polish for SaaS builds.
- 🔗 [`examples/backend/1-mysql/`](/examples/backend/1-mysql/) — runnable MySQL adapter end-to-end.
- 🔗 [`examples/backend/2-s3/`](/examples/backend/2-s3/) — S3 + MinIO.
- 🔗 [`examples/backend/3-auth/`](/examples/backend/3-auth/) — session + JWT.
- 🔗 [`examples/backend/4-realtime/`](/examples/backend/4-realtime/) — WebSocket multi-window sync.
- 🔗 [`examples/extensions/5-multi-tenant/`](/examples/extensions/5-multi-tenant/) — closure-capture pattern.
