Backend · 2 of 4

Save Assets to S3

Stream uploaded images to any S3-compatible store. Ships with a one-command MinIO docker-compose so dev runs without an AWS account.

Walkthrough

  1. Spin up MinIO

    One-command local stack — MinIO at :9000 (S3 API) + :9001 (web console). The companion bucket-create service provisions builderjs-assets on first run, so your first upload lands in a bucket that already exists. Login: minioadmin / minioadmin.

    Bash
    cd demo/examples/backend/2-s3
    docker-compose up -d
    # → Bucket builderjs-assets ready.
    
    # Verify:
    curl -s http://localhost:9001 | grep -o '<title>.*</title>'
    # → <title>MinIO Console</title>
  2. Upload via the SigV4-signed handler

    The handler hashes the body, signs an AWS SigV4 PUT, and streams to your bucket. No composer install. The SigV4 signing lives in backend/_lib/Storage.php — buyers who already pull aws/aws-sdk-php can replace one method and keep everything else.

    Bash
    curl -F file=@/path/to/logo.png \
         http://localhost:8000/examples/backend/2-s3/s3-upload.php
    # → {"status":"success",
    #     "url":"http://localhost:9000/builderjs-assets/2026/05/07/<hash>.png",
    #     "key":"2026/05/07/<hash>.png",
    #     "mime":"image/png","size":12345}
  3. Wire the builder

    In demo/builder.php, change one line under the endpoints block: endpoints.assetUpload → your S3 upload handler URL. Reload the builder; every image upload now lands in your bucket. The returned url is what the canvas references — same shape as the W2 handler, just hosted somewhere else.

    PHP
    // demo/builder.php — top of file
    $builderConfig = [
        'themesRoot' => __DIR__ . '/themes',
        'endpoints' => [
            'save'         => '/backend/save.php',
            'assetUpload'  => '/examples/backend/2-s3/s3-upload.php',  // ← here
            // …
        ],
        // …
    ];
  4. Swap MinIO for any S3-compatible store

    Same handler, six env vars. AWS S3 → real region + bucket; Backblaze B2 → their endpoint URL; Cloudflare R2 → R2 endpoint. The handler reads the env vars first, then falls back to MinIO defaults — production deploy is one setenv step.

    Bash
    # AWS S3
    export S3_ENDPOINT='https://s3.us-east-1.amazonaws.com'
    export S3_BUCKET='your-bucket'
    export S3_REGION='us-east-1'
    export S3_KEY='AKIA…'
    export S3_SECRET='…'
    export S3_PUBLIC_URL='https://your-bucket.s3.amazonaws.com'
    
    # Cloudflare R2 (drop-in)
    export S3_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com'
    export S3_REGION='auto'
    export S3_PUBLIC_URL='https://your-public-domain.com'

Live demo

demo-mini-builder--rich-3col-header
S3 asset uploadTry it — select an Image element on the canvas, click Upload in its settings panel, pick a file → engine POSTs to s3-upload.php; URL response replaces src

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.

2 files · 78 lines · 2.6 KB
<?php
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/Validator.php';
require_once __DIR__ . '/../../../backend/_lib/JsonResponse.php';
require_once __DIR__ . '/../../../backend/_lib/Storage.php';

use DemoBuilder\JsonResponse;
use DemoBuilder\Storage;
use DemoBuilder\Validator;

$file = Validator::file('file')
    ->maxBytes(10 * 1024 * 1024)
    ->allowedMimes(['image/jpeg','image/png','image/webp','image/gif','image/svg+xml'])
    ->allowedExtensions(['jpg','jpeg','png','webp','gif','svg'])
    ->maxDimensions(8000, 8000)
    ->validateOrFail();

// ─── BUYER CONFIG ─────────────────────────────────────────────────
$useS3 = (getenv('USE_S3') ?: '1') !== '0';
$s3Config = [
    'endpoint'   => getenv('S3_ENDPOINT')   ?: 'http://localhost:9000',
    'bucket'     => getenv('S3_BUCKET')     ?: 'builderjs-assets',
    'region'     => getenv('S3_REGION')     ?: 'us-east-1',
    'key'        => getenv('S3_KEY')        ?: 'minioadmin',
    'secret'     => getenv('S3_SECRET')     ?: 'minioadmin',
    'public_url' => getenv('S3_PUBLIC_URL') ?: 'http://localhost:9000/builderjs-assets',
];
// ─────────────────────────────────────────────────────────────────

$key = sprintf('%s/%s.%s', date('Y/m/d'),
    bin2hex(random_bytes(8)), $file['extension']);

$storage = $useS3
    ? Storage::s3($s3Config)
    : Storage::local(__DIR__ . '/uploads', '/examples/backend/2-s3/uploads');

$url = $storage->put($key, $file['tmp_name']);

JsonResponse::success([
    'url'  => $url,
    'key'  => $key,
    'mime' => $file['mime'] ?? '',
    'size' => (int) $file['size'],
]);
services:
  minio:
    image: minio/minio:latest
    container_name: builderjs-minio
    ports:
      - "9000:9000"  # S3 API
      - "9001:9001"  # console
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 5s
      timeout: 3s
      retries: 5

  bucket-create:
    image: minio/mc:latest
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio:9000 minioadmin minioadmin &&
      mc mb --ignore-existing local/builderjs-assets &&
      mc anonymous set download local/builderjs-assets
      "

volumes:
  minio-data:

Notes

No composer dependency. The SigV4 signing lives in _lib/Storage.php — pure PHP + cURL. Total cost: ~30 lines, fully audited. If you'd rather use aws/aws-sdk-php, replace S3StorageDriver::put() with $client->putObject([...]); the surrounding handler is contract-stable.

Skip Docker? Set USE_S3=0 and the handler falls back to local-disk under uploads/. Same JSON response shape; the URL becomes /examples/backend/2-s3/uploads/<key> served by your PHP host.

S3-compatible matrix.

  • AWS S3. S3_ENDPOINT='https://s3.us-east-1.amazonaws.com'; rotate IAM keys per the AWS IAM best practices.
  • Cloudflare R2. S3_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com', S3_REGION='auto'. Public URL = your custom-domain or the public R2 bucket URL.
  • Backblaze B2. S3_ENDPOINT='https://s3.<region>.backblazeb2.com'. The bucket-id-as-region quirk doesn't apply to the S3-compatible API.
  • Wasabi / DigitalOcean Spaces / Linode Object Storage. All speak S3; only the endpoint URL changes.

Production hardening. Enable bucket-level encryption at rest, lock the bucket policy to deny public PUT, and rotate access keys with short-lived STS credentials in production. The public_url config is the one your CDN serves to end-users — never echo the signed PUT endpoint to the canvas.

Deletion. The example only ships put(). S3StorageDriver is intentionally minimal — buyers who need delete() + list() can extend the driver class with two more SigV4-signed methods following the same pattern, or swap to the AWS SDK in one method.