Laravel file upload with Vue.js & Vite
This tutorial adds a Vue file picker and progress display to an authenticated Laravel application. It receives JPEG, PNG and PDF files into private quarantine; receiving a file is separate from approving it for download. The original article used Laravel 11. The example below targets Laravel 13.32.0 and Vue 3.5.42, with explicit session and CSRF middleware.
Prerequisites
- An existing Laravel 13 application with working session login and a
userstable - PHP 8.3 or newer with Laravel’s required extensions, plus Fileinfo and the PDO driver for your database
- Composer, Node.js 24.15 or newer in the Node 24 line, and Yarn 4
- Same-origin HTTPS for the page and its JSON upload endpoint
Authentication setup is outside this upload integration. Use a Laravel starter kit or your existing
login; never replace auth with an unconditional user ID. See the
Laravel starter kits.
Set up the laravel project
Within that application, install the exact front-end packages used here:
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
Use 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()],
})
Build with corepack yarn vite build. The examples use Laravel’s private local disk, whose root
should be storage/app/private, with no public URL or storage symlink for this directory. Keep
production debug output disabled and give the application process access only to its own storage.
See Laravel filesystem configuration.
(optional) scaffold crud with quick admin panel
QuickAdminPanel was part of this article’s original context. If you use generated admin resources, integrate these routes into their existing session login and policies. Check the generated application’s Laravel version before using this Laravel 13 example; do not overwrite its providers, middleware or authorization rules wholesale.
Implement a secure upload API
This JSON endpoint uses routes/web.php deliberately: it shares the page’s cookie session and CSRF
protection. A URL beginning with /api/ does not automatically make a route stateless. New Laravel
applications do not provide routes/api.php until API routing is installed. If you instead need a
separate SPA/API deployment, follow php artisan install:api and Sanctum’s stateful SPA setup;
simply adding a CSRF header to an otherwise stateless API route is insufficient. See
Laravel routing and
Sanctum SPA authentication.
Add a dedicated request class
Create app/Http/Requests/FileUploadRequest.php. MIME validation inspects content; the browser’s
accept attribute and client MIME checks are only selection aids. This still does not establish
that a file is safe to render or execute.
<?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'],
];
}
}
Update the controller to use the request class
Create app/Http/Controllers/API/FileUploadController.php. A generated receipt ID and authenticated
user ID determine the storage path; the uploaded filename never determines a directory or suffix.
The temporary and completed files use the same local filesystem, so the rename completes before the
server returns success. Only completed receipt names should be consumed by a later review job.
<?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);
}
}
This example relies on the local disk’s rename semantics. An object-storage driver needs its own
staging and publication contract. Schedule removal of abandoned .part files and impose per-user
retention/storage quotas before deployment. Files remain private until a separate, authorized review
and download flow is implemented. There is no public download route in this example.
Secure the route
Add these routes to routes/web.php, retaining other application routes:
<?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');
});
Register the limiter in the existing AppServiceProvider::boot, not an unregistered
RouteServiceProvider. The following is a complete minimal app/Providers/AppServiceProvider.php;
merge the limiter into an existing provider if it already has other responsibilities:
<?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());
});
}
}
Keep AppServiceProvider in bootstrap/providers.php as in the standard application scaffold. Keep
the web group’s request-forgery protection enabled and do not exempt this endpoint. The page
supplies Laravel’s session CSRF token, not a storage-service credential. See
Laravel request forgery protection.
Build the Vue component
Create resources/js/app.ts. Mounting a render function avoids depending on Vue’s runtime template
compiler for a custom element embedded in Blade:
import { createApp } from 'vue'
import FileUpload from './components/FileUpload.vue'
createApp(FileUpload).mount('#app')
Create resources/js/components/FileUpload.vue. Axios constructs the multipart boundary. Progress
stays below 100 until the 201 response, and cancellation or a timeout does not imply server
rollback. There is no automatic retry: this endpoint allocates a new receipt for each request.
<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>
Render the component in blade
Create 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>
Follow best practices for large files
Keep the application limit at 10 MiB. Configure PHP’s upload_max_filesize to 10M,
post_max_size to 12M, and the reverse proxy request-body cap to 12 MiB to allow multipart
overhead. Bound connection/body timeouts and PHP execution time for your deployment. A proxy may
reject a large request before Laravel runs; the component handles that as an unconfirmed upload.
For larger transfers, use tus or another resumable server integration. Raising every timeout and accepting unlimited files is not a scaling strategy. Content review, malware scanning and authorized download are separate work that must complete before quarantine files become available to users.
Test your upload flow
Create tests/Feature/FileUploadTest.php in the authenticated application. These tests run through
the real route, request validator and controller, using Laravel’s private storage fake:
<?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();
}
}
Run php artisan test --filter=FileUploadTest. Laravel normally bypasses CSRF verification in
feature tests: additionally exercise the running application with a valid session, then omit the
CSRF token and verify rejection. Test storage failures, logout during an upload, cancellation and
expired sessions. Test a second user’s download authorization when adding a download route.
Wrap-up
The page now selects and uploads one bounded file, shows transfer progress, and confirms private receipt only after storage succeeds. The route uses the application’s authenticated session, CSRF middleware and per-user rate limit. Add your retention, review and download policy before exposing received files. For managed ingestion and processing, see Transloadit’s 🤖 /upload/handle Robot.
