Subida de archivos en Laravel con Vue.js y Vite
Este tutorial añade un selector de archivos de Vue y un indicador de progreso a una aplicación Laravel con autenticación. Recibe archivos JPEG, PNG y PDF en una cuarentena privada; recibir un archivo es una operación distinta de aprobar su descarga. El artículo original usaba Laravel 11. El siguiente ejemplo está pensado para Laravel 13.32.0 y Vue 3.5.42, con middleware explícito de sesión y CSRF.
Requisitos previos
- Una aplicación Laravel 13 existente con inicio de sesión funcional basado en sesiones y una tabla
users - PHP 8.3 o posterior con las extensiones requeridas por Laravel, además de Fileinfo y el controlador PDO para tu base de datos
- Composer, Node.js 24.15 o posterior de la línea Node 24 y Yarn 4
- HTTPS con el mismo origen para la página y su endpoint JSON de subida
La configuración de la autenticación queda fuera de esta integración de subida. Usa un kit de
inicio de Laravel o tu inicio de sesión existente; nunca sustituyas
auth por un ID de usuario incondicional. Consulta los
kits de inicio de Laravel.
Configura el proyecto Laravel
Dentro de esa aplicación, instala los paquetes exactos de frontend que se usan aquí:
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
Usa vite.config.ts:
import vue from '@vitejs/plugin-vue'
import laravel from 'laravel-vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [laravel({ input: ['resources/js/app.ts'], refresh: true }), vue()],
})
Compila con corepack yarn vite build. Los ejemplos usan el disco privado
local de Laravel, cuya raíz debe ser
storage/app/private, sin URL pública ni enlace simbólico de almacenamiento para este
directorio. Mantén desactivada la salida de depuración en producción y permite que el proceso de la
aplicación acceda únicamente a su propio almacenamiento.
Consulta la configuración del sistema de archivos de Laravel.
(Opcional) Genera la estructura CRUD con QuickAdminPanel
QuickAdminPanel formaba parte del contexto original de este artículo. Si usas recursos de administración generados, integra estas rutas con su inicio de sesión basado en sesiones y sus políticas existentes. Comprueba la versión de Laravel de la aplicación generada antes de usar este ejemplo de Laravel 13; no sobrescribas por completo sus proveedores, middleware ni reglas de autorización.
Implementa una API de subida segura
Este endpoint JSON usa routes/web.php de forma deliberada: comparte la sesión basada
en cookies y la protección CSRF de la página. Una URL que comience con
/api/ no convierte automáticamente una ruta en una ruta sin estado. Las
aplicaciones nuevas de Laravel no incluyen routes/api.php hasta que se instala el
enrutamiento de API. Si necesitas un despliegue separado de SPA/API, sigue
php artisan install:api y la configuración de SPA con estado de Sanctum; añadir únicamente
un encabezado CSRF a una ruta de API que sigue sin estado es insuficiente. Consulta el
enrutamiento de Laravel y la
autenticación de SPA con Sanctum.
Añade una clase de solicitud específica
Crea app/Http/Requests/FileUploadRequest.php. La validación MIME inspecciona el contenido; el atributo
accept del navegador y las comprobaciones MIME del cliente solo ayudan a
seleccionar archivos. Esto todavía no demuestra que sea seguro renderizar o ejecutar un archivo.
<?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'],
];
}
}
Actualiza el controlador para usar la clase de solicitud
Crea app/Http/Controllers/API/FileUploadController.php. Un ID de recepción generado y el ID del usuario autenticado
determinan la ruta de almacenamiento; el nombre del archivo subido nunca determina un directorio
ni un sufijo. Los archivos temporales y completados usan el mismo sistema de archivos local, por lo
que el cambio de nombre finaliza antes de que el servidor indique que la operación tuvo éxito.
Una tarea de revisión posterior solo debe consumir los nombres de los registros de recepción
completados.
<?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 ejemplo depende de la semántica de cambio de nombre del disco local. Un controlador de
almacenamiento de objetos necesita su propio contrato de almacenamiento provisional y publicación.
Programa la eliminación de archivos .part abandonados y establece cuotas
de retención y almacenamiento por usuario antes del despliegue. Los archivos permanecen privados
hasta que se implemente un flujo separado y autorizado de revisión y descarga. Este ejemplo no
incluye una ruta de descarga pública.
Protege la ruta
Añade estas rutas a routes/web.php y conserva las demás rutas de la aplicación:
<?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');
});
Registra el limitador en el AppServiceProvider::boot existente, no en un
RouteServiceProvider sin registrar. El siguiente es un
app/Providers/AppServiceProvider.php mínimo y completo; integra el limitador en un proveedor existente
si este ya tiene otras 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());
});
}
}
Conserva AppServiceProvider en bootstrap/providers.php, como en la estructura
estándar de la aplicación. Mantén activada la protección contra la falsificación de solicitudes del
grupo web y no excluyas este endpoint. La página proporciona el token CSRF de sesión de Laravel,
no una credencial de un servicio de almacenamiento. Consulta la
protección contra la falsificación de solicitudes de Laravel.
Crea el componente Vue
Crea resources/js/app.ts. Montar una función de renderizado evita depender del compilador
de plantillas de Vue en tiempo de ejecución para un elemento personalizado integrado en Blade:
import { createApp } from 'vue'
import FileUpload from './components/FileUpload.vue'
createApp(FileUpload).mount('#app')
Crea resources/js/components/FileUpload.vue. Axios construye el delimitador multipart. El progreso se
mantiene por debajo de 100 hasta recibir la respuesta 201, y una cancelación o el vencimiento del
tiempo de espera no implican que el servidor revierta la operación. No hay reintentos automáticos:
este endpoint asigna un nuevo registro de recepción a cada solicitud.
<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>
Renderiza el componente en Blade
Crea 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/app.ts')
</head>
<body>
<h1>Upload a file for review</h1>
<div id="app"></div>
</body>
</html>
Sigue las buenas prácticas para archivos grandes
Mantén el límite de la aplicación en 10 MiB. Configura upload_max_filesize de PHP en
10M, post_max_size en
12M y el límite del cuerpo de las solicitudes del proxy inverso en
12 MiB para permitir la sobrecarga de multipart. Establece límites para los tiempos de espera de
conexión y del cuerpo de la solicitud, así como para el tiempo de ejecución de PHP en tu despliegue.
Un proxy puede rechazar una solicitud grande antes de que se ejecute Laravel; el componente trata
ese caso como una subida sin confirmar.
Para transferencias más grandes, usa el protocolo tus u otra integración de servidor que permita reanudar las subidas. Aumentar todos los tiempos de espera y aceptar archivos sin límites no es una estrategia de escalabilidad. La revisión de contenido, el análisis de malware y la descarga autorizada requieren trabajo separado que debe completarse antes de que los archivos en cuarentena estén disponibles para los usuarios.
Prueba tu flujo de subida
Crea tests/Feature/FileUploadTest.php en la aplicación con autenticación. Estas pruebas pasan por la
ruta, el validador de solicitudes y el controlador reales, usando el almacenamiento privado simulado
de 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();
$file = UploadedFile::fake()->create('review.pdf', 2, 'application/pdf');
$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);
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();
}
}
Ejecuta php artisan test --filter=FileUploadTest. Laravel normalmente omite la verificación CSRF en las
pruebas funcionales: prueba también la aplicación en ejecución con una sesión válida y, después,
omite el token CSRF y verifica que se rechace la solicitud. Prueba los fallos de almacenamiento, el
cierre de sesión durante una subida, la cancelación y las sesiones vencidas. Comprueba la
autorización de descarga de un segundo usuario cuando añadas una ruta de descarga.
Conclusión
La página ahora permite seleccionar y subir un archivo con un límite de tamaño, muestra el progreso de la transferencia y confirma su recepción privada solo después de que se almacene correctamente. La ruta usa la sesión autenticada de la aplicación, middleware CSRF y un límite de solicitudes por usuario. Añade tu política de retención, revisión y descarga antes de hacer accesibles los archivos recibidos. Para la recepción y el procesamiento gestionados, consulta el Robot 🤖 /upload/handle de Transloadit.
