Sigue las subidas de PHP con el progreso de subida de sesión
PHP puede informar cuánto ha recibido de una subida mientras el navegador aún envía el archivo. Las solicitudes de subida y de progreso deben ejecutarse simultáneamente, y una lectura del progreso nunca debe reemplazar el resultado final de la subida. Este ejemplo local acepta un JPEG, PNG o PDF de hasta 10 MiB, muestra el progreso durante una transferencia lenta y confirma cuándo PHP ha almacenado el archivo.
Requisitos de configuración del servidor
Usa Docker con contenedores de Linux y un navegador actual. La siguiente configuración usa PHP 8.4.26
con PHP-FPM y Nginx 1.28.3; se probó en Linux con Chromium 152. Crea un directorio nuevo llamado
php-upload-progress, con un subdirectorio public. Guarda los cuatro archivos de configuración siguientes en
php-upload-progress; los tres archivos de la aplicación de las secciones posteriores van en public.
Guarda esto como Dockerfile:
FROM php:8.4.26-fpm-alpine3.23
RUN apk add --no-cache nginx=1.28.3-r7 \
&& mkdir -p /var/lib/php-upload-demo /var/lib/php-upload-sessions \
&& chown www-data:www-data /var/lib/php-upload-demo /var/lib/php-upload-sessions \
&& chmod 0700 /var/lib/php-upload-demo /var/lib/php-upload-sessions
COPY php.ini /usr/local/etc/php/conf.d/zz-upload.ini
COPY fpm.conf /usr/local/etc/php-fpm.d/zz-upload.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY public/ /app/public/
CMD ["sh", "-c", "php-fpm -D && exec nginx -g 'daemon off;'"]
Guarda esto como php.ini. PHP analiza la subida antes de ejecutar upload.php, por lo que estos ajustes deben ir
en la configuración, no en una llamada a ini_set() dentro del controlador. La clave de progreso combina
upload_progress_ con el valor del campo PHP_SESSION_UPLOAD_PROGRESS del formulario.
El manual de PHP sobre el progreso de subida de sesión
describe este ciclo de vida.
session.save_handler = files
session.save_path = /var/lib/php-upload-sessions
session.name = PHPUPLOADDEMO
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_samesite = Strict
session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = 0.1
upload_max_filesize = 10M
post_max_size = 12M
max_input_time = 120
log_errors = On
display_errors = Off
Guarda esto como fpm.conf. Cuatro procesos de trabajo permiten ejecutar una solicitud de progreso mientras otro proceso recibe
la subida. Un único proceso ocupado no puede atender ambas solicitudes a la vez.
[www]
pm = static
pm.max_children = 4
request_terminate_timeout = 120s
Guarda esto como nginx.conf:
user www-data;
worker_processes 1;
error_log /dev/stderr warn;
events { worker_connections 128; }
http {
include /etc/nginx/mime.types;
access_log /dev/stdout;
server {
listen 8080;
root /app/public;
index index.html;
client_max_body_size 12m;
location / { try_files $uri $uri/ =404; }
location ~ \.php$ {
try_files $uri =404;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
fastcgi_request_buffering off;
fastcgi_read_timeout 120s;
}
}
}
El ajuste crucial es
fastcgi_request_buffering off:
Nginx reenvía los bytes entrantes a PHP de inmediato. fastcgi_buffering controla las respuestas, y
proxy_request_buffering se aplica a un servidor upstream HTTP; ninguno sustituye este ajuste para PHP-FPM.
El límite de 12 MiB por solicitud deja espacio para la sobrecarga multipart por encima del límite
de 10 MiB por archivo. Esta demo se conecta directamente a Nginx; otro proxy que almacene las
subidas en un búfer ocultaría el progreso intermedio.
Implementación del controlador de subidas
Guarda esto como public/upload.php. Comprueba el error de subida de PHP, inspecciona el tipo MIME
del archivo temporal y almacena los bytes aceptados con un nombre aleatorio fuera del directorio
raíz web. Nunca usa el nombre original del archivo como ruta. La
documentación de PHP sobre subidas
explica por qué el tipo MIME proporcionado por el navegador no sirve para validar el archivo.
<?php
// public/upload.php
declare(strict_types=1);
function reply(int $status, array $body): never {
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
}
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
reply(405, ['error' => 'Use POST to upload a file.']);
}
$file = $_FILES['file'] ?? null;
if (!is_array($file) || !isset($file['error'], $file['size'], $file['tmp_name']) ||
!is_int($file['error']) || !is_int($file['size']) || !is_string($file['tmp_name'])) {
reply(400, ['error' => 'Choose one file.']);
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['size'] > 10 * 1024 * 1024) {
reply(413, ['error' => 'The file exceeds 10 MiB.']);
}
if ($file['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
reply(400, ['error' => 'PHP did not receive a complete file.']);
}
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($extensions[$mime])) {
reply(415, ['error' => 'Choose a JPEG, PNG, or PDF.']);
}
$directory = '/var/lib/php-upload-demo';
if (!is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Private storage is unavailable');
}
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
$destination = $directory . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new RuntimeException('Could not store upload');
}
reply(201, ['success' => true, 'filename' => $filename, 'size' => $file['size']]);
} catch (Throwable $error) {
error_log('Upload failed: ' . get_class($error));
reply(500, ['error' => 'The upload service is unavailable.']);
}
Una respuesta satisfactoria significa que el archivo se ha movido al almacenamiento privado. No significa que sea seguro publicar su contenido. Las subidas repetidas que se aceptan reciben nombres de archivo aleatorios distintos; este ejemplo no elimina archivos duplicados ni reanuda una transferencia interrumpida.
Implementación del seguimiento del progreso
Guarda esto como public/progress.php. Una primera llamada sin ID establece la cookie de sesión.
Las llamadas posteriores usan la misma cookie para leer la entrada de la subida. Cierra la sesión
inmediatamente después de leerla: las sesiones de PHP basadas en archivos bloquean los datos mientras
están abiertas, lo que puede retrasar otras solicitudes que usen esa sesión. Consulta
session_write_close().
<?php
// public/progress.php
declare(strict_types=1);
header('Content-Type: application/json');
$id = $_GET['id'] ?? '';
if (!is_string($id) || ($id !== '' && !preg_match('/\A[0-9a-f-]{36}\z/', $id))) {
http_response_code(400);
echo json_encode(['error' => 'Invalid progress ID.']);
exit;
}
if (!session_start()) {
http_response_code(503);
echo json_encode(['error' => 'Tracking is unavailable.']);
exit;
}
$current = $_SESSION['upload_progress_' . $id] ?? null;
session_write_close();
header('Cache-Control: no-store');
echo json_encode(['progress' => $current === null ? null : [
'loaded' => $current['bytes_processed'],
'total' => $current['content_length'],
]], JSON_THROW_ON_ERROR);
El progreso mide la solicitud multipart, incluida su sobrecarga. Con
session.upload_progress.cleanup
habilitado, PHP elimina la entrada después de leer el cuerpo de la solicitud. Por lo tanto, la
ausencia de una entrada puede significar «no iniciada» o «ya recibida»; no permite determinar si
hubo éxito o un fallo. Solo upload.php informa si la validación y el almacenamiento se completaron correctamente.
Integración del lado del cliente
Guarda esto como public/index.html. El campo oculto de progreso va antes del archivo para que PHP vea su
ID antes de recibir los bytes del archivo. Construye FormData antes de deshabilitar los controles, porque los controles
deshabilitados se excluyen de los datos del formulario.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PHP upload progress</title>
</head>
<body>
<h1>Upload a JPEG, PNG, or PDF</h1>
<form id="upload-form">
<fieldset id="controls">
<legend>One file, up to 10 MiB</legend>
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" id="progress-id" />
<label for="file">File</label>
<input id="file" type="file" name="file" accept="image/jpeg,image/png,application/pdf" required />
<button type="submit">Upload</button>
</fieldset>
</form>
<label for="progress">Request received by PHP</label>
<progress id="progress" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<script>
const form = document.getElementById('upload-form')
const controls = document.getElementById('controls')
const progressId = document.getElementById('progress-id')
const bar = document.getElementById('progress')
const status = document.getElementById('status')
let activeJob = null
async function poll(job) {
try {
const response = await fetch(`progress.php?id=${job.id}`, {
cache: 'no-store',
signal: job.controller.signal,
})
if (!response.ok) throw new Error('Progress request failed')
const { progress } = await response.json()
// An old response can arrive after completion or during the next upload.
if (activeJob !== job) return
if (progress && progress.total > 0) {
const percentage = Math.min(100, Math.floor(100 * progress.loaded / progress.total))
bar.value = percentage
status.textContent = percentage === 100
? 'Request received; waiting for server confirmation.'
: `Uploading: ${percentage}%`
}
} catch {
if (activeJob === job) {
status.textContent = 'Progress unavailable; waiting for the upload response.'
}
}
// Schedule after this request settles, so polls never overlap.
if (activeJob === job) job.timer = setTimeout(() => poll(job), 500)
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (activeJob !== null) return
const job = { id: crypto.randomUUID(), controller: new AbortController(), timer: null }
progressId.value = job.id
const body = new FormData(form)
activeJob = job
controls.disabled = true
bar.value = 0
status.textContent = 'Starting upload…'
let message = 'Upload could not be confirmed. Check the server before retrying.'
let stored = false
try {
const session = await fetch('progress.php', { cache: 'no-store' })
if (!session.ok) throw new Error('Session initialization failed')
job.timer = setTimeout(() => poll(job), 500)
const response = await fetch('upload.php', { method: 'POST', body })
if (!response.ok) {
message = response.status === 413 ? 'The file exceeds the upload size limit.'
: response.status === 415 ? 'Choose a JPEG, PNG, or PDF.'
: response.status >= 500 ? 'The upload service is unavailable.'
: 'The upload was rejected. Choose a file and try again.'
throw new Error('Upload rejected')
}
const result = await response.json()
if (result.success !== true) throw new Error('Missing storage confirmation')
stored = true
message = `Upload complete! Stored as ${result.filename} (${result.size} bytes).`
} catch {
// A network failure can occur after storage; do not claim a safe automatic retry.
} finally {
activeJob = null
clearTimeout(job.timer)
job.controller.abort()
controls.disabled = false
status.textContent = message
if (stored) bar.value = 100
}
})
</script>
</body>
</html>
La página acepta una subida a la vez. Deshabilita la selección de archivos y el envío mientras esa
subida está pendiente, y el controlador también ignora los eventos de envío repetidos. Cada tarea
tiene su propio ID de progreso y controlador de cancelación. Al completarse, la tarea se invalida
antes de habilitar de nuevo el formulario. Aunque una consulta ya haya llegado a
response.json(), la comprobación de identidad impide que cambie la información de otra tarea.
Cancelar fetch también detiene el trabajo innecesario de las consultas pendientes.
Ejecuta una subida lenta
Después de guardar los siete archivos, pega esto desde el directorio que contiene php-upload-progress.
El subshell deja intacto tu directorio actual, y la cadena && se detiene si falla la navegación o
la construcción de la imagen. Docker necesita acceso a la red para la primera construcción.
Elige otro puerto del host si el 8080 ya está ocupado.
(
cd php-upload-progress &&
docker build -t php-upload-progress . &&
docker run --rm --name php-upload-progress \
--publish 127.0.0.1:8080:8080 php-upload-progress
)
Deja esa terminal en ejecución y abre http://127.0.0.1:8080/. En las herramientas de desarrollo de tu navegador,
establece un límite de ancho de banda de subida de unos 256 KiB/s, elige un JPEG, PNG o PDF de
aproximadamente 4 MiB y pulsa Upload. Deberían aparecer porcentajes intermedios antes de
Upload complete!, seguido del nombre de archivo generado y la cantidad de bytes.
Las subidas locales rápidas pueden finalizar entre consultas y pasar directamente al estado completado.
Si el progreso permanece en cero durante una transferencia lenta, comprueba que las cookies estén
habilitadas, que la solicitud inicial progress.php se haya completado correctamente y que su cookie acompañe a ambas solicitudes.
Después, comprueba el número de procesos de trabajo y el almacenamiento de solicitudes en búfer.
Una respuesta 413 puede provenir de PHP o de Nginx; una solicitud que supere el límite de 12 MiB de
Nginx nunca llega al controlador de PHP. Elegir un archivo de texto renombrado debería provocar un
rechazo por tipo, ya que el atributo HTML accept solo orienta al selector de archivos.
Los archivos aceptados se encuentran en /var/lib/php-upload-demo dentro del contenedor en ejecución. No tienen una
URL pública. Detén el contenedor en primer plano con Ctrl+C al terminar; --rm elimina entonces sus subidas y
datos de sesión. Es necesario reconstruir la imagen después de modificar un archivo guardado de
código fuente o de configuración.
Advertencias de seguridad
Esta demostración funciona solo en la interfaz de bucle local, sin inicio de sesión, token CSRF, cupos ni análisis de malware. La detección MIME restringe los formatos, pero no demuestra que un archivo sea inofensivo. Mantén el contenido privado hasta que terminen la validación y el procesamiento de tu aplicación. Una versión desplegada también necesita HTTPS, cookies de sesión seguras, control de acceso y una política explícita de retención; no expongas este contenedor como servicio de subida sin modificarlo.
Compatibilidad con navegadores
La página usa fetch, FormData, AbortController y crypto.randomUUID() sin polyfills.
randomUUID() requiere un contexto seguro:
HTTP en la interfaz de bucle local funciona para esta demo; el uso remoto requiere HTTPS. Las
cookies deben estar habilitadas. Al recargar o cerrar la página se pierde el seguimiento, y esta
implementación no ofrece ningún botón para pausar o reanudar.
