Acompanhe uploads de arquivos em PHP com session upload progress
O PHP consegue informar quanto de um upload já recebeu enquanto o navegador ainda está enviando o arquivo. As requisições de upload e de progresso precisam rodar de forma concorrente, e uma leitura de progresso nunca deve substituir o resultado final do upload. Este exemplo local aceita um JPEG, PNG ou PDF de até 10 MiB, mostra o progresso durante uma transferência lenta e confirma quando o PHP armazenou o arquivo.
Requisitos de configuração do servidor
Use o Docker com contêineres Linux e um navegador atual. A configuração abaixo usa PHP 8.4.26 com
PHP-FPM e Nginx 1.28.3; ela foi testada no Linux com Chromium 152. Crie um novo diretório chamado
php-upload-progress, com um subdiretório public. Salve os quatro arquivos de configuração a seguir em
php-upload-progress; os três arquivos da aplicação das seções seguintes vão em public.
Salve isto 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;'"]
Salve isto como php.ini. O PHP processa o upload antes de executar upload.php, então essas configurações
devem ficar na configuração, e não em uma chamada ini_set() dentro do handler. A chave de progresso combina
upload_progress_ com o valor do campo PHP_SESSION_UPLOAD_PROGRESS do formulário.
O manual do PHP sobre session upload progress
descreve esse 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
Salve isto como fpm.conf. Quatro workers permitem que uma requisição de progresso rode enquanto outro worker
recebe o upload. Um único worker ocupado não consegue atender às duas requisições ao mesmo tempo.
[www]
pm = static
pm.max_children = 4
request_terminate_timeout = 120s
Salve isto 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;
}
}
}
A configuração crucial é
fastcgi_request_buffering off:
o Nginx encaminha os bytes recebidos ao PHP imediatamente. fastcgi_buffering controla as respostas, e
proxy_request_buffering se aplica a um upstream HTTP; nenhum dos dois substitui essa configuração para o PHP-FPM.
O limite de requisição de 12 MiB deixa espaço para o overhead do multipart acima do limite de arquivo
de 10 MiB. Esta demonstração se conecta diretamente ao Nginx; outro proxy que faça buffer de uploads
esconderia o progresso intermediário.
Implementação do handler de upload
Salve isto como public/upload.php. Ele verifica o erro de upload do PHP, inspeciona o tipo MIME do arquivo
temporário e armazena os bytes aceitos com um nome aleatório fora do webroot. Ele nunca usa o nome de
arquivo original como caminho. A documentação de upload do PHP
explica por que o tipo MIME fornecido pelo navegador não serve para validação.
<?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.']);
}
Uma resposta bem-sucedida significa que o arquivo foi movido para o armazenamento privado. Isso não significa que o conteúdo seja seguro para publicação. Uploads aceitos repetidos recebem nomes de arquivo aleatórios distintos; este exemplo não elimina arquivos duplicados nem retoma uma transferência interrompida.
Implementação do acompanhamento de progresso
Salve isto como public/progress.php. Chamá-lo primeiro sem um ID estabelece o cookie de sessão.
As chamadas seguintes usam o mesmo cookie para ler a entrada do upload. Feche a sessão imediatamente
depois de lê-la: as sessões em arquivo do PHP bloqueiam os dados enquanto estão abertas, o que pode
atrasar outras requisições que usam essa sessão. Consulte 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);
O progresso mede a requisição multipart, incluindo seu overhead. Com
session.upload_progress.cleanup
habilitado, o PHP remove a entrada depois de ler o corpo da requisição. Por isso, uma entrada ausente
pode significar “não iniciado” ou “já recebido”; ela não permite concluir sucesso nem falha. Somente
upload.php informa se a validação e o armazenamento foram bem-sucedidos.
Integração no lado do cliente
Salve isto como public/index.html. O campo oculto de progresso vem antes do arquivo para que o PHP veja
seu ID antes de receber os bytes do arquivo. Construa FormData antes de desabilitar os controles,
porque controles desabilitados são excluídos dos dados do formulário.
<!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>
A página aceita um upload por vez. Ela desabilita a seleção de arquivo e o envio do formulário
enquanto esse upload está pendente, e o handler também ignora eventos de submit repetidos. Cada
tarefa tem seu próprio ID de progresso e seu próprio abort controller. A conclusão invalida essa
tarefa antes de habilitar o formulário novamente. Mesmo que uma consulta já tenha chegado a
response.json(), a verificação de identidade impede que ela altere o feedback de outra tarefa.
Abortar o fetch
também interrompe o trabalho desnecessário de consultas pendentes.
Executar um upload lento
Depois de salvar os sete arquivos, cole isto a partir do diretório que contém php-upload-progress.
O subshell mantém seu diretório atual inalterado, e a cadeia && para se a troca de diretório
ou o build falhar. O Docker precisa de acesso à rede no primeiro build. Escolha outra porta do host
se a 8080 já estiver ocupada.
(
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
)
Deixe esse terminal rodando e abra http://127.0.0.1:8080/. Nas ferramentas de desenvolvedor do navegador,
defina um limite de banda de upload em torno de 256 KiB/s, depois escolha um JPEG, PNG ou PDF de
aproximadamente 4 MiB e clique em Upload. Porcentagens intermediárias devem aparecer antes de
Upload complete!, seguidas do nome de arquivo gerado e da contagem
de bytes. Uploads locais rápidos podem terminar entre consultas e pular direto para a conclusão.
Se o progresso ficar em zero durante uma transferência lenta, verifique se os cookies estão
habilitados, se a requisição inicial de progress.php foi bem-sucedida e se o cookie dela acompanha as
duas requisições. Em seguida, verifique a quantidade de workers e o buffering de requisições. Uma
resposta 413 pode vir do PHP ou do Nginx; uma requisição acima do limite de 12 MiB do Nginx nunca
chega ao handler PHP. Escolher um arquivo de texto renomeado deve gerar uma rejeição de tipo, já que
o atributo HTML accept é apenas uma dica para o seletor de arquivos.
Os arquivos aceitos ficam em /var/lib/php-upload-demo dentro do contêiner em execução. Eles não têm URL
pública. Pare o contêiner em primeiro plano com Ctrl+C ao terminar; o --rm então apaga os
uploads e os dados de sessão do contêiner. É necessário reconstruir a imagem depois de alterar um
arquivo de código-fonte ou de configuração salvo.
Ressalvas de segurança
Esta é uma demonstração apenas para loopback, sem login, token CSRF, cotas ou verificação de malware. A detecção de MIME restringe os formatos, mas não prova que um arquivo seja inofensivo. Mantenha o conteúdo privado até que a validação e o processamento da sua aplicação terminem. Uma versão implantada também precisa de HTTPS, cookies de sessão seguros, controle de acesso e uma política de retenção explícita; não exponha este contêiner como serviço de upload sem alterações.
Compatibilidade com navegadores
A página usa fetch, FormData, AbortController e crypto.randomUUID() sem polyfills.
randomUUID() exige um contexto seguro:
HTTP em loopback funciona para esta demonstração; o uso remoto exige HTTPS. Os cookies precisam
estar habilitados. Recarregar ou fechar a página abandona o feedback dela, e esta implementação não
oferece botão de pausar ou retomar.
