Destino Dev

Blog

Upload de arquivos grandes com Livewire e S3: chunked, presigned e progresso

Por John Pires · 19 de março de 2026

O que é e por que importa

Fazer upload de arquivos grandes em aplicações web não é só uma questão de enviar bytes: é sobre confiabilidade, custo, tempo de resposta e experiência do usuário. Quando um usuário tenta enviar um vídeo de centenas de megabytes, um envio direto pelo servidor da aplicação (POST multipart padrão) frequentemente causa timeouts, alto uso de CPU/RAM e custos de banda desnecessários no servidor.

Livewire facilita interação reativa no front-end usando PHP puro, mas o comportamento padrão de upload do Livewire não é ideal para arquivos muito grandes. A solução correta costuma envolver enviar os arquivos diretamente para o S3 usando uploads em partes (multipart), com URLs pré-assinadas (presigned) geradas pelo servidor e acompanhamento do progresso no cliente.

Este artigo mostra, no estilo code-review, o caminho errado e o caminho certo: primeiro o erro comum que iniciantes cometem, depois a implementação robusta com exemplos reais em Laravel, Livewire, JS e AWS SDK.

Porque esse tema importa

Uploads confiáveis reduzem abandonos de formulário, evitam sobrecarga do servidor e aumentam a escalabilidade. Usar S3 direto com multipart permite retomar uploads, distribuir carga e pagar apenas pela banda entre cliente e S3.

Resumo rápido da solução

Fluxo recomendado:

  • Cliente fragmenta o arquivo em partes (chunks).
  • Servidor inicia um multipart upload no S3 e gera presigned URLs para cada parte.
  • Cliente envia cada parte diretamente ao S3 usando as URLs presigned, reportando progresso ao Livewire.
  • Cliente finaliza o upload chamando o servidor para completar o multipart upload (com os ETags das partes).

Conceitos fundamentais com analogias

Multipart upload (analogia: montar um móvel)

Pense no arquivo grande como um móvel desmontado em caixas. Em vez de carregar o móvel inteiro por uma porta estreita (upload único), você entra com caixas menores (partes). No final, monta tudo com as instruções (completeMultipartUpload). Se uma caixa cai, você reenvia apenas aquela caixa — muito mais eficiente.

Presigned URLs (analogia: ingresso temporário)

Uma presigned URL é como um ingresso temporário que dá permissão ao cliente para enviar uma parte diretamente ao S3 sem precisar das credenciais AWS no navegador. O servidor gera esse ingresso para cada parte e define um tempo de validade.

Progresso e UX

Mostrar progresso não só melhora a experiência, mas permite tomar decisões (cancelar, re-tentar). Para uploads em partes, combine progresso por parte com progresso global para exibir uma barra precisa.

Porque não enviar pelo servidor (o jeito errado)

Enviar grandes arquivos via Livewire ou via endpoint POST que grava no servidor consome memória e E/S, faz escalonamento ruim e cria pontos únicos de falha. Este é o erro inicial comum que veremos em seguida.

Passo a passo com código comentado

Erro comum: upload direto pelo servidor (exemplo errado)

Exemplo de Controller que muitos iniciantes usam. Esse código aceita arquivo e faz Storage::put — funciona para arquivos pequenos, mas falha em produção para arquivos grandes.

// app/Http/Controllers/BadUploadController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class BadUploadController extends Controller
{
    public function store(Request $request)
    {
        $request->validate(['file' => 'required|file|max:102400']); // 100MB

        // JEITO ERRADO: carrega todo o arquivo para o servidor PHP
        $path = $request->file('file')->store('uploads');

        return response()->json(['path' => $path]);
    }
}

O que acontece: PHP precisa receber todo o conteúdo antes de executar o controlador. Em uploads grandes você vai atingir limites de tempo, memória ou o PHP-FPM vai morrer. Não escalável.

Jeito certo: dividir o fluxo e enviar direto ao S3

Vamos implementar endpoints para: 1) iniciar multipart upload e gerar presigned URLs de partes; 2) completar upload; 3) abortar se necessário. O cliente fará o split e enviará as partes.

Back-end: iniciar multipart upload e gerar URLs

A seguir, um controller em Laravel que usa o cliente S3 da SDK AWS e retorna os presigned URLs para cada parte. Este código cria um multipart upload e prepara as URLs para upload de cada parte.

// app/Http/Controllers/S3MultipartController.php
namespace App\Http\Controllers;

use Aws\S3\S3Client;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class S3MultipartController extends Controller
{
    public function initUpload(Request $request)
    {
        $request->validate([ 'filename' => 'required|string', 'parts' => 'required|integer|min:1|max:10000' ]);

        // Pega o cliente S3 via Storage
        $s3Client = Storage::disk('s3')->getDriver()->getAdapter()->getClient();
        $bucket = config('filesystems.disks.s3.bucket');

        // Inicia multipart upload
        $result = $s3Client->createMultipartUpload([
            'Bucket' => $bucket,
            'Key' => 'uploads/' . basename($request->filename),
            'ACL' => 'private',
        ]);

        $uploadId = $result['UploadId'];

        // Gera presigned URLs para cada parte
        $parts = [];
        for ($i = 1; $i <= $request->parts; $i++) {
            $cmd = $s3Client->getCommand('UploadPart', [
                'Bucket' => $bucket,
                'Key' => 'uploads/' . basename($request->filename),
                'UploadId' => $uploadId,
                'PartNumber' => $i,
            ]);

            $requestPresigned = $s3Client->createPresignedRequest($cmd, '+30 minutes');
            $parts[$i] = (string) $requestPresigned->getUri();
        }

        return response()->json(['uploadId' => $uploadId, 'parts' => $parts]);
    }
}

O que acontece: o servidor faz apenas o trabalho de inicializar o upload e gerar URLs temporárias. O cliente vai usar essas URLs para enviar as partes diretamente ao S3.

Exemplo prático completo

Estrutura geral

Arquivos principais neste exemplo:

  • Controller: S3MultipartController (init/completion/abort)
  • Rota: /s3/init, /s3/complete
  • Livewire component: UploadLarge (apenas para UX e iniciar processo)
  • JS: uploader que fatia o arquivo e usa fetch/XHR para enviar cada parte

Endpoint para completar multipart upload

Depois que todas as partes forem enviadas ao S3, o cliente chama o servidor com a lista de ETags para completar o upload. O servidor faz a chamada CompleteMultipartUpload.

// app/Http/Controllers/S3MultipartController.php (continuação)
public function completeUpload(Request $request)
{
    $request->validate([
        'filename' => 'required|string',
        'uploadId' => 'required|string',
        'parts' => 'required|array',
    ]);

    $s3Client = Storage::disk('s3')->getDriver()->getAdapter()->getClient();
    $bucket = config('filesystems.disks.s3.bucket');

    // parts deve ser um array com ['PartNumber' => n, 'ETag' => '"..."']
    $result = $s3Client->completeMultipartUpload([
        'Bucket' => $bucket,
        'Key' => 'uploads/' . basename($request->filename),
        'UploadId' => $request->uploadId,
        'MultipartUpload' => ['Parts' => $request->parts],
    ]);

    return response()->json(['location' => $result['Location']]);
}

O que acontece: o S3 junta todas as partes publicado o objeto final. O campo ETag retornado para cada parte é necessário para identificar a versão enviada.

Livewire + Blade: componente de upload

O Livewire component serve para mostrar progresso e iniciar a sequência. O upload real é feito por JS que roda no browser, usando as presigned URLs retornadas pelo servidor.

// app/Http/Livewire/UploadLarge.php
namespace App\Http\Livewire;

use Livewire\Component;

class UploadLarge extends Component
{
    public $progress = 0;

    public function render()
    {
        return view('livewire.upload-large');
    }

    // Métodos public para eventos JS podem ser adicionados
}

View Blade: recursos mínimos para escolher arquivo e mostrar progresso. O JS será incluído e usará Livewire.emit para atualizar o progresso.

<!-- resources/views/livewire/upload-large.blade.php -->
<div x-data>
    <input id="fileInput" type="file" />
    <button id="startUpload">Enviar</button>

    <div class="progress">
        <div id="bar" style="width: 0%">0%</div>
    </div>
</div>

<script>
// Código JS explicado abaixo
</script>

O que acontece: o componente exibe controles; o JavaScript se encarrega do processo de fragmentação, upload e progresso. Livewire só fornece o contexto reativo e pode receber eventos para salvar metadados depois do upload.

JavaScript: fatiar e enviar partes com presigned URLs

Exemplo funcional de uploader JS. Este código divide o arquivo em chunks, pede ao servidor as URLs e sobe as partes com fetch. Ele coleta os ETags das respostas para a etapa de complete.

// public/js/chunk-uploader.js
async function uploadFileInChunks(file, partsCount = 10) {
  // 1) pede URLs ao servidor
  const res = await fetch('/s3/init', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
    body: JSON.stringify({ filename: file.name, parts: partsCount })
  });
  const data = await res.json();
  const uploadId = data.uploadId;
  const partsUrls = data.parts; // indexado por numero da parte

  const chunkSize = Math.ceil(file.size / partsCount);
  const etags = [];

  for (let i = 1; i <= partsCount; i++) {
    const start = (i - 1) * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const blob = file.slice(start, end);

    // upload direto ao S3 via presigned URL
    const uploadRes = await fetch(partsUrls[i], {
      method: 'PUT',
      body: blob
    });

    if (!uploadRes.ok) throw new Error('Falha ao enviar parte ' + i);

    // ETag geralmente disponível no header
    const etag = uploadRes.headers.get('ETag');
    etags.push({ PartNumber: i, ETag: etag });

    // atualiza UI de progresso
    const percent = Math.round((i / partsCount) * 100);
    document.getElementById('bar').style.width = percent + '%';
    document.getElementById('bar').textContent = percent + '%';
  }

  // 3) completa o upload
  const completeRes = await fetch('/s3/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
    body: JSON.stringify({ filename: file.name, uploadId: uploadId, parts: etags })
  });

  return await completeRes.json();
}

// hook de UI
document.getElementById('startUpload').addEventListener('click', async () => {
  const file = document.getElementById('fileInput').files[0];
  try {
    const result = await uploadFileInChunks(file, 10);
    console.log('Upload completo', result);
  } catch (e) {
    console.error(e);
    alert('Erro no upload: ' + e.message);
  }
});

O que acontece: o JS realiza todos os uploads diretamente ao S3. Repare que usamos PUT nas presigned URLs; para cada resposta devemos coletar o ETag para a chamada de complete.

Erros comuns e armadilhas

Listagem das armadilhas encontradas em code review e como evitar:

  1. Partes com tamanho irregular: escolher número de partes adequadas. O S3 exige parte mínima de 5MB (exceto a última).
  2. ETags faltando: dependendo da CDN ou proxy, o header ETag pode ser modificado; garantir que o S3 esteja recebendo as requisições diretamente.
  3. Tokens CSRF: chamadas de inicialização e finalização passam por Laravel, garanta CSRF ou use Sanctum/token.
  4. Retentativa e idempotência: implemente re-tries e lógica para reusar uploadId se parte já foi enviada.
  5. Limites de partes: S3 permite até 10.000 partes; calcule partesCount = ceil(size / desiredPartSize).
Tip: prefira calcular partSize em bytes (ex: 10MB) e derivar partsCount automaticamente; evite fixed partsCount sem considerar tamanho do arquivo.

Próximos passos para aprofundar

Melhorias e produção

Para produção considere:

  • Usar workers/queues para pós-processamento do arquivo (transcoding, thumbnails).
  • Salvar metadados no banco (uploadId, status, partes enviadas) para retomar uploads incompletos.
  • Implementar backoff exponencial e lógica de retomada no cliente.

Testes e monitoramento

Escreva testes de integração que simulam upload em partes usando a SDK localstack ou mocks do S3. Monitore o número de uploads incompletos e configure tarefas para abortar uploads antigos e liberar espaço.

Conclusão prática

Enviar arquivos grandes via Livewire direto ao servidor é a rota curta e perigosa. A solução escalável é delegar o payload ao S3 via presigned URLs e multipart upload. O servidor apenas orquestra: inicia, fornece URLs e finaliza. O cliente gerencia a fragmentação, upload paralelo/sequencial e o progresso.

Resumo das ações práticas que você deve aplicar hoje:

  • Não envie arquivos grandes pelo backend PHP diretamente.
  • Implemente init/complete endpoints que usem o S3Client da AWS PHP SDK.
  • Faça o cliente dividir o arquivo e enviar usando presigned URLs (fetch/XHR).
  • Colete ETags e chame completeMultipartUpload no servidor.

Com isso ganha-se robustez, menor custo de infraestrutura e melhor UX com progresso real. Teste com arquivos reais (vários tamanhos) e ajuste partSize para equilibrar overhead e confiabilidade.

Erros de implementação são fáceis de detectar em code review: se você vê o servidor recebendo o arquivo inteiro, é sinal de problema. Prefira sempre a arquitetura que mantém o servidor fora do caminho dos bytes pesados.

Pronto para avançar: implemente cálculo dinâmico de partSize, retries, upload paralelo e integração com Filament para painéis de administração que mostram status dos uploads.

Assuntos: Laravel, livewire, s3, aws-s3, file-upload

Leia também