Subida de archivos en Laravel con Vue.js y Vite
Añade un selector de archivos Vue.js a tu aplicación Laravel con autenticación y muestra el progreso sin confundir una transferencia completada con el almacenamiento confirmado. El endpoint recibe archivos JPEG, PNG y PDF en cuarentena privada y luego devuelve un comprobante de recepción. Recibir un archivo es distinto de aprobarlo para su descarga.
Requisitos previos
- Una aplicación Laravel 13 existente con inicio de sesión funcional basado en sesiones, una tabla
usersy una página Blade - PHP con las extensiones que requiere Laravel, Fileinfo y el controlador PDO para tu base de datos
- Composer y Node.js 24.21.0, con Yarn 4.12.0 seleccionado mediante Corepack
- HTTPS del mismo origen para la página y su endpoint JSON de subida
Las versiones fijadas para reproducir el ejemplo son Laravel 13.32.0, PHP 8.4.26, Composer 2.10.3, Node.js 24.21.0, Yarn 4.12.0 y Vue 3.5.42. Son versiones probadas, no los requisitos mínimos de Laravel. Usa esta configuración para reproducir el ejemplo; vuelve a probarlo cuando actualices sus dependencias.
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 reemplaces auth
por un ID de usuario incondicional. Consulta los
kits de inicio de Laravel.
Configura el proyecto Laravel
Ejecuta estos comandos desde la raíz de la aplicación, donde se encuentran
artisan, package.json y su propio
yarn.lock. Configura "packageManager": "yarn@4.12.0" en
package.json antes de continuar.
Instala los paquetes del frontend en orden; si la instalación falla, corrige ese error y vuelve a
ejecutar el bloque:
(
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
)
Añade resources/js/upload.ts a la lista de entradas existente de Laravel Vite e incluye
vue() entre sus plugins si no está presente. Conserva las demás entradas,
plugins y alias, especialmente si tu inicio de sesión usa un kit de inicio de Inertia. Para una
aplicación Blade mínima, vite.config.ts es:
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()],
})
El controlador requiere que el disco local use el controlador local con
su raíz establecida en storage/app/private. Revisa config/filesystems.php y
cualquier configuración en caché: un disco llamado local puede haberse
personalizado. Mantén este directorio fuera de la raíz web, sin una URL pública ni un enlace
simbólico de almacenamiento. Mantén deshabilitada la salida de depuración en producción y permite
que el proceso de la aplicación escriba en su propio almacenamiento privado.
Consulta la configuración del sistema de archivos de Laravel.
(Opcional) Genera la estructura CRUD con Quick Admin Panel
QuickAdminPanel formaba parte del contexto original de este artículo. Si usas recursos de administración generados, integra estas rutas en su inicio de sesión basado en sesiones y en sus políticas existentes. Revisa 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 deliberadamente: comparte la sesión basada
en cookies y la protección CSRF de la página. Una URL que empieza con
/api/ no convierte automáticamente una ruta en una ruta sin estado. Las
aplicaciones Laravel nuevas 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; simplemente añadir
un encabezado CSRF a una ruta de API que sigue siendo 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 lado del cliente solo
ayudan a seleccionar archivos. Esto tampoco demuestra que sea seguro mostrar 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 generado para el comprobante de recepción 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 se completa antes de que el servidor devuelva una
respuesta de éxito. Solo los nombres de los comprobantes de recepción completados deben utilizarse
en una tarea de revisión 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 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 de revisión y descarga con autorización. Este ejemplo no
incluye una ruta pública de descarga.
Protege la ruta
Añade estas rutas a routes/web.php, conservando 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 bloque 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());
});
}
}
Mantén AppServiceProvider en bootstrap/providers.php, como en la estructura
estándar de la aplicación. Mantén habilitada la protección predeterminada contra la falsificación
de solicitudes del grupo web y no excluyas este endpoint. En Laravel 13, la evidencia de mismo
origen en un contexto seguro que aporta el navegador puede permitir que la solicitud pase antes de
validar el token; para otras solicitudes, se recurre a comprobar el token de sesión. La página
envía ese token para esta comprobación alternativa. Consulta la
protección contra la falsificación de solicitudes de Laravel.
Crea el componente Vue
Crea resources/js/upload.ts, conservando el punto de entrada existente de tu aplicación.
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
permanece por debajo de 100 hasta que la respuesta 201 confirma el almacenamiento. El tiempo de
espera de 60 segundos abarca la transferencia y la espera de la respuesta, por lo que incluso un
archivo de menos de 10 MiB puede agotar ese tiempo en una conexión lenta. La cancelación o el
agotamiento del tiempo de espera no revierte una escritura en el servidor. No hay reintentos
automáticos: cada solicitud asigna un nuevo comprobante de recepción, así que revisa la cuarentena
del servidor antes de volver a enviar una subida no confirmada.
<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>
Muestra 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/upload.ts')
</head>
<body>
<h1>Upload a file for review</h1>
<div id="app"></div>
</body>
</html>
Después de guardar los archivos, compila los activos:
corepack yarn vite build
Inicia sesión y visita /uploads en el origen HTTPS de tu aplicación. Elige
un archivo JPEG, PNG o PDF de entre 1 KiB y 10 MiB y luego selecciona
Upload file. La página muestra
Uploading… hasta que el servidor confirma el
almacenamiento y luego muestra File received for review.
El progreso llega a 100 en ese momento. El campo id de la respuesta
JSON indica storage/app/private/quarantine/{authenticated-user-id}/{id}; compara los bytes de ese archivo con los de tu archivo
de entrada y confirma que no quede ningún archivo .part correspondiente.
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 dar cabida a los datos adicionales 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 Laravel se ejecute; el
componente la trata como una subida no confirmada.
Para transferencias más grandes, usa un protocolo reanudable como el protocolo tus con un servidor receptor compatible. La revisión del contenido, el análisis de malware y la descarga con autorización son tareas separadas que deben 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 real, el validador de solicitudes y el controlador, 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();
$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();
}
}
Ejecuta las pruebas funcionales:
php artisan test --filter=FileUploadTest
Estas pruebas con almacenamiento simulado no demuestran que la protección contra la falsificación
de solicitudes funcione en la aplicación en ejecución: Laravel omite ese middleware en las pruebas
funcionales. En la aplicación en ejecución, comprueba una solicitud autenticada de otro origen sin
token y espera una respuesta 419. Comprueba también la validación alternativa mediante token cuando
Sec-Fetch-Site esté ausente: un token ausente o incorrecto debe recibir una
respuesta 419, mientras que un token de sesión válido debe llegar a la validación. Una solicitud
del navegador de mismo origen en un contexto seguro y sin token puede pasar con la política
predeterminada de Laravel 13.
Prueba un fallo de almacenamiento, la limitación de solicitudes, la caducidad de la sesión y el cierre de sesión mientras la página está abierta. Selecciona Cancel durante una subida y comprueba que se informe de un resultado no confirmado, sin asumir que el servidor eliminó un archivo completado. Retrasa o interrumpe la respuesta final para comprobar el mismo límite: los bytes transferidos por sí solos no deben producir un mensaje de éxito. Cuando añadas una ruta de descarga, prueba que un usuario no pueda recuperar el archivo en cuarentena de otro usuario.
Conclusión
Añade tu política de retención, revisión y descarga antes de exponer los archivos recibidos. Para la recepción y el procesamiento gestionados, consulta el 🤖 Robot /upload/handle de Transloadit.
