Async PHP integration for efficient Transloadit use
Users need to wait for an upload to finish, but they need not keep a browser open during image processing. Assembly Notifications let Transloadit tell your PHP backend when processing ends. Your backend can record that outcome independently of the browser.

This updates Joseph’s July 2021 walkthrough with Uppy’s Dashboard and Transloadit plugin, replacing the retired Robodog uploader. We’ll build a small, single-operator PHP application with a MySQL receipt table, server-signed uploads, and an authenticated callback. It uses PHP 8.2 or newer with PDO MySQL, and keeps all account secrets on the server.
Setting up the website
Create a project containing common.php and a public/ directory. Only public/ is the document
root. The application’s operator logs in using HTTP Basic authentication over HTTPS; notifications
instead authenticate with Transloadit’s signature. This keeps both signing and receipt viewing
private without requiring a full user-account system for the tutorial.
Supply these environment variables to PHP through your deployment’s secret manager:
TRANSLOADIT_KEY,TRANSLOADIT_SECRET, andTRANSLOADIT_TEMPLATE_IDfrom your account.APP_USERand a long, randomAPP_PASSWORDfor this tutorial’s operator.APP_ORIGIN: the exact HTTPS origin of the app, with no trailing slash.TRANSLOADIT_NOTIFY_URL: that origin followed by/notify.php.DB_DSN: for example,mysql:host=127.0.0.1;dbname=transloadit;charset=utf8mb4.DB_USERandDB_PASSWORD: a database account limited toSELECTandINSERTon the receipt table.
Keep secret files outside public/ and source control. In a multi-user application, replace Basic
authentication with your existing session authorization, scope receipts to their owner, and enforce
per-user upload quotas and rate limits at the signing endpoint.
Setting up the database
Using an administrative MySQL connection, create the database and table:
CREATE DATABASE transloadit CHARACTER SET utf8mb4;
USE transloadit;
CREATE TABLE assemblies (
id CHAR(32) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
status VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
http_code SMALLINT UNSIGNED NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
The Assembly ID is the receipt’s unique key. Notification retries must not create extra rows or repeat a business action. This example stores only the terminal status; it does not publish result files or execute downstream jobs.
Creating our Template
Create a Template in your account with these Instructions and copy its ID into the server’s configuration. The callback destination will be supplied by the signing endpoint, not by a browser field or a Template expression based on user input.
{
"steps": {
":original": { "robot": "/upload/handle" },
"resized": {
"robot": "/image/resize",
"use": ":original",
"width": 500,
"format": "jpeg",
"resize_strategy": "fit",
"imagemagick_stack": "v3"
}
}
}
Enable Signature Authentication for your Auth Key so modified or
unsigned Instructions are rejected. The server signs the exact serialized params string with
HMAC-SHA384, matching the format used by
@transloadit/utils.
Exposing local Notifications during development
For this walkthrough, use a Cloudflare Quick Tunnel.
After installing cloudflared, start the tunnel:
cloudflared tunnel --url http://127.0.0.1:8000
Set APP_ORIGIN to the assigned HTTPS origin and TRANSLOADIT_NOTIFY_URL to its /notify.php
URL. Then start PHP from the project directory after configuring the other environment variables:
php -d display_errors=0 -d log_errors=1 -d post_max_size=512K \
-d upload_max_filesize=256K -d max_input_vars=10 -d file_uploads=0 \
-S 127.0.0.1:8000 -t public
Open the HTTPS tunnel URL in your browser. Uppy continues to use
https://api2.transloadit.com; uploads bypass PHP. Stop both processes with Ctrl+C after testing.
Update the two URL settings and restart PHP whenever the temporary tunnel hostname changes.
The first-party notification relay is a different development option: it must receive Assembly creation traffic through its local proxy, poll Assembly status, and sign forwarded notifications using your Auth Secret. Merely starting it while Uppy uses the normal API endpoint will not relay this app’s notifications. The tunnel configuration above does not require the relay or a localhost callback in the Template.
Connecting to our database
Save this as common.php, outside the public document root. It supplies configuration, login,
database access, and one sanitized error boundary for all four PHP files.
<?php
declare(strict_types=1);
set_exception_handler(function (Throwable $error): void {
error_log('Notification application request failed.');
respond(500, 'Request could not be completed.');
});
function respond(int $status, string $message): never {
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
function setting(string $name): string {
$value = getenv($name);
if ($value === false || $value === '') {
throw new RuntimeException('Missing server configuration.');
}
return $value;
}
function requireOperator(): void {
if (!hash_equals(setting('APP_USER'), $_SERVER['PHP_AUTH_USER'] ?? '') ||
!hash_equals(setting('APP_PASSWORD'), $_SERVER['PHP_AUTH_PW'] ?? '')) {
header('WWW-Authenticate: Basic realm="Upload demo", charset="UTF-8"');
respond(401, 'Sign in to continue.');
}
header('Cache-Control: no-store');
}
function database(): PDO {
return new PDO(setting('DB_DSN'), setting('DB_USER'), setting('DB_PASSWORD'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
Save the signing endpoint as public/sign.php. It accepts no caller-supplied Instructions. The
Origin and custom-header checks protect this credentialed POST from cross-site form submissions;
do not add permissive CORS headers. The short expiration and signed upload-size limit apply even
if somebody bypasses Uppy’s client-side restrictions.
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/common.php';
requireOperator();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
respond(405, 'Use POST.');
}
$origin = setting('APP_ORIGIN');
if (!preg_match('~\Ahttps://[a-zA-Z0-9.-]+(?::[0-9]+)?\z~D', $origin) ||
setting('TRANSLOADIT_NOTIFY_URL') !== $origin . '/notify.php') {
throw new RuntimeException('Invalid server URL configuration.');
}
if (($_SERVER['HTTP_ORIGIN'] ?? '') !== $origin ||
($_SERVER['HTTP_X_UPLOAD_REQUEST'] ?? '') !== '1') {
respond(403, 'Request not allowed.');
}
if (!preg_match('/\A[a-f0-9]{32}\z/D', setting('TRANSLOADIT_TEMPLATE_ID'))) {
throw new RuntimeException('Invalid Template configuration.');
}
$params = json_encode([
'auth' => [
'key' => setting('TRANSLOADIT_KEY'),
'expires' => gmdate('Y-m-d\TH:i:s\Z', time() + 300),
'max_size' => 10 * 1024 * 1024,
],
'template_id' => setting('TRANSLOADIT_TEMPLATE_ID'),
'notify_url' => setting('TRANSLOADIT_NOTIFY_URL'),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$signature = 'sha384:' . hash_hmac('sha384', $params, setting('TRANSLOADIT_SECRET'));
header('Content-Type: application/json');
echo json_encode(['params' => $params, 'signature' => $signature], JSON_THROW_ON_ERROR);
Adding data to the database
Save this as public/notify.php. Verify the original transloadit form-field bytes before
decoding JSON. Re-encoding JSON first would change the signed message. The supported algorithm
allowlist includes the SDK’s legacy SHA-1 format for notification compatibility; new upload
Instructions above use SHA384. Unknown algorithms, malformed fields, and invalid signatures fail.
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/common.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
respond(405, 'Use POST.');
}
$raw = $_POST['transloadit'] ?? null;
$signature = $_POST['signature'] ?? null;
if (!is_string($raw) || strlen($raw) > 262144 || !is_string($signature) ||
strlen($signature) > 140 || count($_POST) !== 2 || count($_FILES) !== 0) {
respond(400, 'Invalid notification.');
}
$parts = explode(':', $signature, 2);
[$algorithm, $digest] = count($parts) === 2 ? $parts : ['sha1', $signature];
$lengths = ['sha1' => 40, 'sha256' => 64, 'sha384' => 96, 'sha512' => 128];
if (!isset($lengths[$algorithm]) || strlen($digest) !== $lengths[$algorithm] ||
!ctype_xdigit($digest) ||
!hash_equals(hash_hmac($algorithm, $raw, setting('TRANSLOADIT_SECRET')), $digest)) {
respond(403, 'Invalid notification signature.');
}
try {
$assembly = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
respond(400, 'Invalid notification JSON.');
}
if (!is_array($assembly) || array_is_list($assembly)) {
respond(400, 'Invalid notification.');
}
$id = $assembly['assembly_id'] ?? null;
$status = $assembly['error'] ?? $assembly['ok'] ?? null;
$httpCode = $assembly['http_code'] ?? null;
if (!is_string($id) || !preg_match('/\A[a-f0-9]{32}\z/D', $id) ||
!is_string($status) || !preg_match('/\A[A-Z][A-Z0-9_]{0,63}\z/D', $status) ||
!is_int($httpCode) || $httpCode < 100 || $httpCode > 599 ||
(!isset($assembly['error']) && !in_array($status, ['ASSEMBLY_COMPLETED', 'ASSEMBLY_CANCELED'], true))) {
respond(400, 'Invalid terminal Assembly status.');
}
$db = database();
try {
$insert = $db->prepare('INSERT INTO assemblies (id, status, http_code) VALUES (?, ?, ?)');
$insert->execute([$id, $status, $httpCode]);
} catch (PDOException $error) {
// MySQL error 1062 is the unique receipt key, not a successful new delivery.
if (($error->errorInfo[1] ?? null) !== 1062) {
throw $error;
}
$existing = $db->prepare('SELECT status, http_code FROM assemblies WHERE id = ?');
$existing->execute([$id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row || $row['status'] !== $status || (int) $row['http_code'] !== $httpCode) {
respond(409, 'Conflicting notification receipt.');
}
}
respond(200, 'Notification recorded.');
An identical retry returns 200 after confirming the saved outcome. A conflicting outcome for the same Assembly ID returns 409 for investigation; database failures return a generic 500 so the sender can retry. The primary key also protects concurrent deliveries. If you add downstream jobs, insert a job into an outbox in the same database transaction as the receipt, then process it with an idempotent worker. Do not send mail or publish files before recording the receipt.
Retrieving data from the database
Save this complete upload page as public/index.php. It shows the five most recently received
Assembly outcomes to the authenticated operator and escapes every database value before rendering.
The Uppy Transloadit plugin asks our backend for signed options.
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/common.php';
requireOperator();
$rows = database()->query(
'SELECT id, status, received_at FROM assemblies ORDER BY received_at DESC, id DESC LIMIT 5'
)->fetchAll(PDO::FETCH_ASSOC);
function escape(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Asynchronous image uploads</title>
<link rel="stylesheet" href="https://releases.transloadit.com/uppy/v5.2.1/uppy.min.css">
</head>
<body>
<h1>Upload an image</h1>
<p>Wait until the upload finishes. Processing continues after you close this page.</p>
<div id="dashboard"></div>
<h2>Recent processing outcomes</h2>
<p>Refresh this page to see newly received notifications.</p>
<ul>
<?php foreach ($rows as $row): ?>
<li><?= escape($row['id']) ?> — <?= escape($row['status']) ?> — <?= escape($row['received_at']) ?></li>
<?php endforeach; ?>
</ul>
<?php if (count($rows) === 0): ?><p>No notifications received yet.</p><?php endif; ?>
<script type="module">
import { Uppy, Dashboard, Transloadit } from 'https://releases.transloadit.com/uppy/v5.2.1/uppy.min.mjs'
new Uppy({ restrictions: { maxNumberOfFiles: 1, maxFileSize: 10 * 1024 * 1024, allowedFileTypes: ['image/*'] } })
.use(Dashboard, { target: '#dashboard', inline: true })
.use(Transloadit, {
waitForEncoding: false,
async assemblyOptions() {
const response = await fetch('/sign.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Upload-Request': '1' },
})
if (!response.ok) throw new Error('Could not authorize this upload.')
return response.json()
},
})
</script>
</body>
</html>
Testing
First verify that unauthenticated requests to the page and signing endpoint receive 401, and that
an unsigned POST to /notify.php receives 403 without inserting a row. A missing or malformed
payload receives 400. Posting an Assembly ID containing SQL syntax must also fail validation.
With your own account, an optional end-to-end check is to upload one small test image through the HTTPS page, wait for upload completion, and refresh after processing. A completed receipt should appear even if the upload tab was closed. A processing failure must be shown as a failure status, not as a completed image. Replaying a notification from the Assembly page should leave one receipt.
Test signed synthetic notifications locally as well: unchanged retries should return 200, a changed body with the old signature should return 403, and a signed conflicting terminal outcome should return 409. Never disable signature checks to make a tunnel test pass. The development body limits above suit this one-image example; set explicit reverse-proxy and PHP limits appropriate to your production notification payloads and monitor rejected deliveries.
Finishing up
The browser now handles uploading while PHP records authenticated processing outcomes. Keep receipt retention long enough for notification retries and your own replays, and monitor failed notifications in your account. Signature authentication establishes the sender; application authorization, receipt ownership, and idempotent downstream work remain your backend’s job.
