Upload de arquivos no Laravel com Vue.js e Vite
Adicione um seletor de arquivos Vue.js à sua aplicação Laravel com autenticação e mostre o progresso do upload sem confundir a conclusão da transferência com a confirmação do armazenamento. O endpoint aceita arquivos JPEG, PNG e PDF em quarentena privada e retorna um comprovante. Receber um arquivo é diferente de aprová-lo para download.
Pré-requisitos
- Uma aplicação Laravel 13 existente com login por sessão funcionando, uma tabela
userse uma página Blade - PHP com as extensões exigidas pelo Laravel, Fileinfo e o driver PDO para seu banco de dados
- Composer e Node.js 24.21.0, com Yarn 4.12.0 selecionado pelo Corepack
- HTTPS de mesma origem para a página e seu endpoint JSON de upload
As versões fixadas para reproduzir o exemplo são Laravel 13.32.0, PHP 8.4.26, Composer 2.10.3, Node.js 24.21.0, Yarn 4.12.0 e Vue 3.5.42. Essas são versões testadas, e não os requisitos mínimos do Laravel. Use essa configuração para reproduzir o exemplo; teste-o novamente ao atualizar suas dependências.
A configuração da autenticação está fora do escopo desta integração de upload. Use um kit inicial
do Laravel ou seu login existente; nunca substitua auth por um ID de usuário
incondicional. Consulte os kits iniciais do Laravel.
Configurar o projeto Laravel
Execute estes comandos na raiz da aplicação, onde ficam artisan, package.json e seu próprio
yarn.lock. Defina "packageManager": "yarn@4.12.0" em package.json antes de prosseguir.
Instale os pacotes de front-end na ordem indicada; se a instalação falhar, corrija o erro e execute
novamente o bloco:
(
if [ ! -f artisan ] || [ ! -f package.json ] || [ ! -f yarn.lock ]; then
printf 'Use the Laravel application root with its own Yarn lockfile.\n' >&2
exit 1
fi
for reader_tool in node corepack; do
if ! command -v "$reader_tool" >/dev/null 2>&1; then
printf 'Install the prerequisite: %s\n' "$reader_tool" >&2
exit 1
fi
done
corepack yarn add --exact vue@3.5.42 axios@1.13.6 &&
corepack yarn add --dev --exact @vue/compiler-sfc@3.5.42 @vitejs/plugin-vue@6.0.9 vite@8.3.0 laravel-vite-plugin@3.2.0
)
Adicione resources/js/upload.ts à lista de entradas existente do Laravel Vite e inclua vue() em seus
plugins, caso esteja ausente. Preserve as outras entradas, os plugins e os aliases, especialmente
se seu login usa um kit inicial do Inertia. Para uma aplicação Blade mínima,
vite.config.ts fica assim:
import vue from '@vitejs/plugin-vue'
import laravel from 'laravel-vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [laravel({ input: ['resources/js/upload.ts'], refresh: true }), vue()],
})
O controlador exige que o disco local use o driver local com a raiz definida como
storage/app/private. Confira config/filesystems.php e qualquer configuração em cache: um disco chamado
local pode ter sido personalizado. Mantenha esse diretório fora da raiz web, sem URL pública nem
link simbólico de armazenamento. Mantenha a saída de depuração desativada em produção e permita que
o processo da aplicação grave em seu próprio armazenamento privado.
Consulte a configuração do sistema de arquivos do Laravel.
(Opcional) gerar a estrutura CRUD com Quick Admin Panel
QuickAdminPanel fazia parte do contexto original deste artigo. Se você usa recursos administrativos gerados, integre estas rotas ao login por sessão e às políticas existentes desses recursos. Confira a versão do Laravel da aplicação gerada antes de usar este exemplo de Laravel 13; não substitua integralmente seus provedores, middlewares ou regras de autorização.
Implementar uma API de upload segura
Este endpoint JSON usa routes/web.php de propósito: ele compartilha a sessão por cookie e a proteção CSRF
da página. Uma URL que começa com /api/ não torna uma rota automaticamente sem estado. Novas aplicações
Laravel não disponibilizam routes/api.php até que o roteamento de API seja instalado. Se você precisa de uma
implantação separada de SPA/API, siga php artisan install:api e a configuração de SPA com estado do Sanctum;
apenas adicionar um cabeçalho CSRF a uma rota de API que permanece sem estado não basta. Consulte o
roteamento do Laravel e a
autenticação de SPA do Sanctum.
Adicionar uma classe de requisição dedicada
Crie app/Http/Requests/FileUploadRequest.php. A validação MIME inspeciona o conteúdo; o atributo
accept do navegador e as verificações MIME no cliente são apenas recursos para auxiliar a seleção.
Isso ainda não comprova que um arquivo seja seguro para renderização ou execução.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class FileUploadRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'file' => ['required', 'file', 'min:1', 'max:10240', 'mimes:jpeg,png,pdf'],
];
}
}
Atualizar o controlador para usar a classe de requisição
Crie app/Http/Controllers/API/FileUploadController.php. Um ID de comprovante gerado e o ID do usuário autenticado
determinam o caminho de armazenamento; o nome do arquivo enviado nunca determina um diretório ou
sufixo. Os arquivos temporários e concluídos usam o mesmo sistema de arquivos local, portanto a
renomeação termina antes de o servidor retornar sucesso. Somente os nomes de comprovantes concluídos
devem ser usados por uma tarefa de revisão posterior.
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\FileUploadRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class FileUploadController extends Controller
{
public function upload(FileUploadRequest $request): JsonResponse
{
$id = (string) Str::uuid();
$directory = 'quarantine/'.$request->user()->getAuthIdentifier();
$temporary = $directory.'/'.$id.'.part';
$destination = $directory.'/'.$id;
$disk = Storage::disk('local');
try {
$stored = $request->file('file')->storeAs($directory, $id.'.part', 'local');
if ($stored === false || !$disk->move($temporary, $destination)) {
throw new RuntimeException('Private storage failed');
}
} catch (Throwable) {
// Log only the generated receipt, never a path, request body or exception trace.
Log::error('Private upload failed', ['receipt' => $id]);
try {
$disk->delete($temporary);
} catch (Throwable) {
Log::warning('Private upload cleanup failed', ['receipt' => $id]);
}
return response()->json(['message' => 'Upload could not be stored.'], 500);
}
return response()->json(['id' => $id, 'message' => 'File received for review.'], 201);
}
}
Este exemplo depende da semântica de renomeação do disco local. Um driver de armazenamento de
objetos precisa de seu próprio contrato de preparação e publicação. Agende a remoção de arquivos
.part abandonados e imponha cotas de retenção/armazenamento por usuário antes da implantação.
Os arquivos permanecem privados até que um fluxo separado e autorizado de revisão e download seja
implementado. Não há rota pública de download neste exemplo.
Proteger a rota
Adicione estas rotas a routes/web.php, preservando as outras rotas da aplicação:
<?php
use App\Http\Controllers\API\FileUploadController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
Route::view('/uploads', 'uploads');
Route::post('/api/upload', [FileUploadController::class, 'upload'])
->middleware('throttle:uploads');
});
Registre o limitador no AppServiceProvider::boot existente, e não em um
RouteServiceProvider não registrado. A seguir está um app/Providers/AppServiceProvider.php mínimo e completo;
integre o limitador a um provedor existente se ele já tiver outras responsabilidades:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(10)->by((string) $request->user()->getAuthIdentifier());
});
}
}
Mantenha AppServiceProvider em bootstrap/providers.php, como na estrutura padrão da aplicação. Mantenha
ativada a proteção padrão contra falsificação de requisições do grupo web e não isente este
endpoint. No Laravel 13, evidências seguras de mesma origem fornecidas pelo navegador podem ser
suficientes para passar pela verificação antes da validação do token; outras requisições recorrem
à verificação do token da sessão. A página envia esse token para essa verificação alternativa.
Consulte a proteção contra falsificação de requisições do Laravel.
Criar o componente Vue
Crie resources/js/upload.ts, mantendo intacto o ponto de entrada existente da aplicação. Montar uma
função de renderização evita depender do compilador de templates do Vue em tempo de execução para
um elemento personalizado incorporado ao Blade:
import { createApp } from 'vue'
import FileUpload from './components/FileUpload.vue'
createApp(FileUpload).mount('#app')
Crie resources/js/components/FileUpload.vue. O Axios constrói o delimitador multipart. O progresso
permanece abaixo de 100 até que a resposta 201 confirme o armazenamento. O tempo limite de 60
segundos cobre a transferência e a espera pela resposta, portanto até um arquivo com menos de
10 MiB pode exceder esse limite em uma conexão lenta. O cancelamento ou o esgotamento do tempo
limite não desfaz uma gravação no servidor. Não há nova tentativa automática: cada requisição
aloca um novo comprovante, então confira a quarentena do servidor antes de reenviar um upload
não confirmado.
<script setup>
import axios from 'axios'
import { onBeforeUnmount, ref } from 'vue'
const file = ref(null)
const fileInput = ref(null)
const progress = ref(0)
const message = ref('')
const uploading = ref(false)
let controller = null
function selectFile(event) {
const selected = event.target.files?.[0]
file.value = null
progress.value = 0
if (!selected) return
if (
selected.size < 1024 ||
selected.size > 10 * 1024 * 1024 ||
!['image/jpeg', 'image/png', 'application/pdf'].includes(selected.type)
) {
message.value = 'Choose a JPEG, PNG or PDF between 1 KiB and 10 MiB.'
return
}
file.value = selected
message.value = 'Ready to upload.'
}
async function uploadFile() {
if (!file.value || uploading.value) return
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
if (!csrf) {
message.value = 'Reload this page before uploading.'
return
}
uploading.value = true
controller = new AbortController()
progress.value = 0
message.value = 'Uploading…'
const body = new FormData()
body.append('file', file.value)
try {
const response = await axios.post('/api/upload', body, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': csrf },
signal: controller.signal,
timeout: 60_000,
onUploadProgress(event) {
if (event.total)
progress.value = Math.min(99, Math.floor((100 * event.loaded) / event.total))
},
})
if (response.status !== 201 || typeof response.data.id !== 'string' || !response.data.id) {
throw new Error('Invalid receipt')
}
progress.value = 100
message.value = 'File received for review.'
file.value = null
if (fileInput.value) fileInput.value.value = ''
} catch (error) {
const status = axios.isAxiosError(error) ? error.response?.status : undefined
message.value =
status === 422
? 'The server rejected this file. Check its size and type.'
: status === 401 || status === 419
? 'Your session expired. Sign in and reload this page.'
: status === 429
? 'Too many uploads. Wait before trying again.'
: 'Upload not confirmed. It may have reached the server; check before sending it again.'
} finally {
controller = null
uploading.value = false
}
}
function cancel() {
controller?.abort()
}
onBeforeUnmount(cancel)
</script>
<template>
<section aria-label="File upload">
<label>
File
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,application/pdf"
:disabled="uploading"
@change="selectFile"
/>
</label>
<button :disabled="!file || uploading" @click="uploadFile">Upload file</button>
<button :disabled="!uploading" @click="cancel">Cancel</button>
<progress aria-label="Upload progress" max="100" :value="progress"></progress>
<p role="status">{{ message }}</p>
</section>
</template>
Renderizar o componente no Blade
Crie resources/views/uploads.blade.php:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}" />
<title>File upload</title>
@vite('resources/js/upload.ts')
</head>
<body>
<h1>Upload a file for review</h1>
<div id="app"></div>
</body>
</html>
Depois de salvar os arquivos, compile os recursos:
corepack yarn vite build
Faça login e acesse /uploads pela origem HTTPS da sua aplicação. Escolha um JPEG, PNG ou PDF entre
1 KiB e 10 MiB e selecione Upload file. A página mostra
Uploading… até que o servidor confirme o armazenamento e, depois,
File received for review. Nesse momento, o progresso chega a 100.
O campo id da resposta JSON indica storage/app/private/quarantine/{authenticated-user-id}/{id};
compare os bytes desse arquivo com os do arquivo de entrada e confirme que não resta nenhum arquivo
.part correspondente.
Seguir boas práticas para arquivos grandes
Mantenha o limite da aplicação em 10 MiB. Configure upload_max_filesize do PHP como 10M,
post_max_size como 12M e o limite do corpo da requisição no proxy reverso como 12 MiB para
acomodar a sobrecarga do multipart. Defina limites para os tempos de espera de conexão e de
recebimento do corpo, além do tempo de execução do PHP, conforme sua implantação. Um proxy pode
rejeitar uma requisição grande antes de o Laravel executar; o componente trata esse caso como um
upload não confirmado.
Para transferências maiores, use um protocolo retomável como tus com um servidor de recebimento compatível. A revisão de conteúdo, a varredura antimalware e o download autorizado são trabalhos separados que precisam ser concluídos antes que os arquivos em quarentena fiquem disponíveis aos usuários.
Testar seu fluxo de upload
Crie tests/Feature/FileUploadTest.php na aplicação com autenticação. Estes testes passam pela
rota, pelo validador de requisição e pelo controlador reais, usando o armazenamento privado
simulado do Laravel:
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FileUploadTest extends TestCase
{
use RefreshDatabase;
public function test_private_upload_is_owned_and_complete(): void
{
Storage::fake('local');
$user = User::factory()->create();
$bytes = "%PDF-1.7\n".str_repeat('reader upload ', 160)."\n%%EOF\n";
$file = UploadedFile::fake()->createWithContent('review.pdf', $bytes);
$response = $this->actingAs($user)->postJson('/api/upload', ['file' => $file]);
$response->assertCreated()->assertJson(['message' => 'File received for review.']);
$path = 'quarantine/'.$user->id.'/'.$response->json('id');
Storage::disk('local')->assertExists($path);
$this->assertSame($bytes, Storage::disk('local')->get($path));
Storage::disk('local')->assertMissing($path.'.part');
$this->assertCount(1, Storage::disk('local')->allFiles());
}
public function test_anonymous_upload_is_rejected(): void
{
$this->postJson('/api/upload')->assertUnauthorized();
}
public function test_invalid_and_oversized_files_are_rejected(): void
{
Storage::fake('local');
$this->actingAs(User::factory()->create());
$this->postJson('/api/upload')->assertUnprocessable();
$this->postJson('/api/upload', [
'file' => UploadedFile::fake()->create('large.pdf', 10241, 'application/pdf'),
])->assertUnprocessable();
$this->postJson('/api/upload', [
'file' => UploadedFile::fake()->create('script.txt', 2, 'text/plain'),
])->assertUnprocessable();
$this->assertSame([], Storage::disk('local')->allFiles());
}
public function test_upload_rate_limit_is_enforced(): void
{
$this->actingAs(User::factory()->create());
for ($attempt = 0; $attempt < 10; $attempt++) {
$this->postJson('/api/upload')->assertUnprocessable();
}
$this->postJson('/api/upload')->assertTooManyRequests();
}
}
Execute os testes de funcionalidades:
php artisan test --filter=FileUploadTest
Esses testes com armazenamento simulado não comprovam a proteção contra falsificação de requisições
na aplicação em execução: o Laravel ignora esse middleware nos testes de funcionalidades. Na
aplicação em execução, teste uma requisição autenticada de outra origem sem token e espere 419.
Teste também a verificação alternativa por token com Sec-Fetch-Site ausente: um token ausente ou
incorreto deve receber 419, enquanto um token de sessão válido deve chegar à validação. Uma
requisição segura de mesma origem feita pelo navegador sem token pode passar pela política padrão
do Laravel 13.
Teste falhas de armazenamento, limitação de requisições, expiração de sessão e logout enquanto a página estiver aberta. Selecione Cancel durante um upload e confira se o resultado é informado como não confirmado, sem presumir que o servidor removeu um arquivo concluído. Atrase ou descarte a resposta final para testar o mesmo limite: apenas os bytes transferidos não devem produzir uma mensagem de sucesso. Ao adicionar uma rota de download, teste se um usuário não consegue recuperar o arquivo de outro usuário que está em quarentena.
Conclusão
Adicione sua política de retenção, revisão e download antes de disponibilizar os arquivos recebidos. Para ingestão e processamento gerenciados, consulte o 🤖 Robot /upload/handle da Transloadit.
