Integración PHP asíncrona para usar Transloadit con eficiencia
Los usuarios deben esperar a que termine una subida, pero no necesitan mantener abierto el navegador durante el procesamiento de imágenes. Las Assembly Notifications permiten que Transloadit avise a tu backend PHP cuando termina el procesamiento. Tu backend puede guardar ese resultado independientemente del navegador.

Esta guía actualiza el tutorial de Joseph de julio de 2021 con el Dashboard de Uppy y su plugin de Transloadit, que sustituyen al componente de subida Robodog, ya retirado. Crearemos una pequeña aplicación PHP para un único operador con una tabla de registros de recepción en MySQL, subidas firmadas por el servidor y un callback autenticado. Utiliza PHP 8.2 o posterior con PDO MySQL y mantiene todos los secretos de la cuenta en el servidor.
Configurar el sitio web
Crea un proyecto que contenga common.php y un directorio
public/. Solo public/ es la raíz de documentos.
El operador de la aplicación inicia sesión mediante autenticación HTTP Basic sobre HTTPS;
las notificaciones, por su parte, se autentican con la firma de Transloadit. Así, tanto la firma
como la consulta de los registros de recepción se mantienen privadas sin requerir un sistema
completo de cuentas de usuario para el tutorial.
Proporciona estas variables de entorno a PHP mediante el gestor de secretos de tu despliegue:
TRANSLOADIT_KEY,TRANSLOADIT_SECRETyTRANSLOADIT_TEMPLATE_IDde tu cuenta.APP_USERy un valor largo y aleatorio paraAPP_PASSWORDpara el operador de este tutorial.APP_ORIGIN: el origen HTTPS exacto de la aplicación, sin barra final.TRANSLOADIT_NOTIFY_URL: ese origen seguido de/notify.php.DB_DSN: por ejemplo,mysql:host=127.0.0.1;dbname=transloadit;charset=utf8mb4.DB_USERyDB_PASSWORD: una cuenta de base de datos limitada aSELECTyINSERTen la tabla de registros de recepción.
Mantén los archivos secretos fuera de public/ y del control de versiones.
En una aplicación con varios usuarios, sustituye la autenticación Basic por la autorización de
sesión que ya utilices, restringe el acceso a los registros de recepción a su propietario y aplica
cuotas de subida y límites de frecuencia por usuario en el endpoint de firma.
Configurar la base de datos
Utiliza una conexión administrativa de MySQL para crear la base de datos y la tabla:
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
);
El ID de la Assembly es la clave única del registro de recepción. Los reintentos de notificación no deben crear filas adicionales ni repetir una acción de negocio. Este ejemplo almacena solo el estado final; no publica archivos resultantes ni ejecuta tareas posteriores.
Crear nuestro Template
Crea un Template en tu cuenta con estas Instructions y copia su ID en la configuración del servidor. El endpoint de firma proporcionará el destino del callback, que no procederá de un campo del navegador ni de una expresión del Template basada en datos introducidos por el usuario.
{
"steps": {
":original": { "robot": "/upload/handle" },
"resized": {
"robot": "/image/resize",
"use": ":original",
"width": 500,
"format": "jpeg",
"resize_strategy": "fit",
"imagemagick_stack": "v3"
}
}
}
Habilita Signature Authentication para tu Auth Key, de modo que se
rechacen las Instructions modificadas o sin firmar. El servidor firma la cadena serializada exacta
params con HMAC-SHA384, de acuerdo con el formato utilizado por
@transloadit/utils.
Exponer Notifications locales durante el desarrollo
Para este tutorial, utiliza un Cloudflare Quick Tunnel.
Después de instalar cloudflared, inicia el túnel:
cloudflared tunnel --url http://127.0.0.1:8000
Establece APP_ORIGIN en el origen HTTPS asignado y
TRANSLOADIT_NOTIFY_URL en su URL /notify.php.
Después, inicia PHP desde el directorio del proyecto, tras configurar las demás variables de entorno:
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
Abre la URL HTTPS del túnel en tu navegador. Uppy sigue utilizando
https://api2.transloadit.com; las subidas no pasan por PHP. Detén ambos procesos con Ctrl+C
cuando termines las pruebas. Actualiza las dos URL de la configuración y reinicia PHP cada vez que
cambie el nombre de host del túnel temporal.
El retransmisor de notificaciones oficial es otra opción para el desarrollo: debe recibir el tráfico de creación de Assemblies a través de su proxy local, consultar periódicamente el estado de las Assemblies y firmar las notificaciones reenviadas con tu Auth Secret. Iniciarlo mientras Uppy utiliza el endpoint habitual de la API no bastará para retransmitir las notificaciones de esta aplicación. La configuración del túnel anterior no requiere el retransmisor ni un callback a localhost en el Template.
Conectar con nuestra base de datos
Guarda lo siguiente como common.php, fuera de la raíz pública de documentos.
Proporciona la configuración, el inicio de sesión, el acceso a la base de datos y un único punto de
gestión de errores con mensajes depurados para los cuatro archivos PHP.
<?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,
]);
}
Guarda el endpoint de firma como public/sign.php. No acepta Instructions
proporcionadas por quien realiza la llamada. Las comprobaciones de Origin y de la cabecera
personalizada protegen esta solicitud POST con credenciales frente al envío de formularios desde
otros sitios; no añadas cabeceras CORS permisivas. El breve plazo de caducidad y el límite firmado
del tamaño de subida se aplican incluso si alguien elude las restricciones de Uppy del lado del
cliente.
<?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);
Añadir datos a la base de datos
Guarda lo siguiente como public/notify.php. Verifica los bytes originales del campo
de formulario transloadit antes de decodificar el JSON. Volver a codificar el
JSON primero cambiaría el mensaje firmado. La lista de algoritmos permitidos incluye el formato
heredado SHA-1 del SDK para mantener la compatibilidad de las notificaciones; las nuevas Instructions
de subida mostradas más arriba utilizan SHA384. Los algoritmos desconocidos, los campos mal formados y las
firmas no válidas provocan el rechazo de la solicitud.
<?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.');
Un reintento idéntico devuelve 200 después de confirmar el resultado guardado. Un resultado contradictorio para el mismo ID de la Assembly devuelve 409 para que se investigue; los fallos de la base de datos devuelven un 500 genérico para que el remitente pueda reintentarlo. La clave primaria también protege frente a entregas simultáneas. Si añades tareas posteriores, inserta una tarea en una bandeja de salida dentro de la misma transacción de base de datos que el registro de recepción y procésala después con un worker idempotente. No envíes emails ni publiques archivos antes de guardar el registro de recepción.
Recuperar datos de la base de datos
Guarda esta página completa de subida como public/index.php. Muestra al operador
autenticado los cinco resultados de Assemblies recibidos más recientemente y escapa cada valor de la
base de datos antes de renderizarlo.
El plugin de Transloadit para Uppy solicita opciones firmadas a
nuestro backend.
<?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>
Pruebas
Primero, verifica que las solicitudes sin autenticar a la página y al endpoint de firma reciban 401,
y que un POST sin firmar a /notify.php reciba 403 sin insertar ninguna fila.
Si falta el cuerpo de la solicitud o está mal formado, se devuelve 400. El envío de un ID de la Assembly
que contenga sintaxis SQL también debe fallar en la validación.
Con tu propia cuenta, puedes realizar una comprobación opcional de extremo a extremo: sube una imagen pequeña de prueba a través de la página HTTPS, espera a que termine la subida y actualiza la página después del procesamiento. Debe aparecer un registro de recepción con estado completado, aunque se haya cerrado la pestaña de subida. Un fallo de procesamiento debe mostrarse como un estado de error, no como una imagen completada. Reenviar una notificación desde la página de la Assembly debe dejar un solo registro de recepción.
Prueba también notificaciones sintéticas firmadas de forma local: los reintentos sin cambios deben devolver 200; un cuerpo modificado con la firma anterior debe devolver 403; y un resultado final contradictorio firmado debe devolver 409. Nunca desactives las comprobaciones de firma para que pase una prueba del túnel. Los límites de tamaño del cuerpo anteriores son adecuados para este ejemplo con una sola imagen; establece límites explícitos en el proxy inverso y en PHP que sean adecuados para los cuerpos de las notificaciones de producción y supervisa las entregas rechazadas.
Para terminar
Ahora el navegador se encarga de la subida mientras PHP guarda los resultados autenticados del procesamiento. Conserva los registros de recepción el tiempo suficiente para los reintentos de notificación y tus propios reenvíos, y supervisa las notificaciones fallidas en tu cuenta. La autenticación mediante firma verifica la identidad del remitente; la autorización de la aplicación, la propiedad de los registros de recepción y la ejecución idempotente de las tareas posteriores siguen siendo responsabilidad de tu backend.
