Laravel file upload with Vue.js & Vite
Add a Vue.js file picker to your authenticated Laravel app and show upload progress without confusing completed transfer with confirmed storage. The endpoint accepts JPEG, PNG, and PDF files into private quarantine, then returns a receipt. Receiving a file is separate from approving it for download.
Prerequisites
- An existing Laravel 13 application with working session login, a
userstable, and a Blade page - PHP with Laravel’s required extensions, Fileinfo, and the PDO driver for your database
- Composer and Node.js 24.21.0, with Yarn 4.12.0 selected through Corepack
- Same-origin HTTPS for the page and its JSON upload endpoint
The reproduction pins are Laravel 13.32.0, PHP 8.4.26, Composer 2.10.3, Node.js 24.21.0, Yarn 4.12.0, and Vue 3.5.42. These are tested versions, rather than Laravel’s minimum requirements. Use this setup to reproduce the example; retest it when upgrading its dependencies.
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
Run these commands from the application root, where artisan, package.json, and its own
yarn.lock live. Set "packageManager": "yarn@4.12.0" in package.json before proceeding.
Install the front-end packages in order; if installation fails, fix that error and rerun the block:
(
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
)
Add resources/js/upload.ts to your existing Laravel Vite input list and include vue() in its
plugins if it is absent. Preserve the other entries, plugins, and aliases, especially when your
login uses an Inertia starter kit. For a minimal Blade application, vite.config.ts is:
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()],
})
The controller requires the local disk to use the local driver with its root set to
storage/app/private. Check config/filesystems.php and any cached configuration: a disk called
local can have been customized. Keep this directory outside the web root, without a public URL or
storage symlink. Keep production debug output disabled and let the application process write to its
own private 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 default request-forgery protection enabled and do not exempt this endpoint. In
Laravel 13, secure same-origin evidence from the browser can pass before token validation; other
requests fall back to checking the session token. The page sends that token for the fallback. See
Laravel request forgery protection.
Build the Vue component
Create resources/js/upload.ts, leaving your application’s existing entrypoint intact. 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 confirms storage. The 60-second timeout covers transfer and
waiting for the response, so even a file below 10 MiB can time out on a slow connection. Cancellation
or timeout does not roll back a server write. There is no automatic retry: each request allocates a
new receipt, so check the server’s quarantine before sending an unconfirmed upload again.
<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/upload.ts')
</head>
<body>
<h1>Upload a file for review</h1>
<div id="app"></div>
</body>
</html>
After saving the files, build the assets:
corepack yarn vite build
Log in and visit /uploads over your application’s HTTPS origin. Choose a JPEG, PNG, or PDF between
1 KiB and 10 MiB, then select Upload file. The page shows
Uploading… until the server acknowledges storage, then
File received for review. Its progress reaches 100 at that point.
The JSON response’s id names storage/app/private/quarantine/{authenticated-user-id}/{id};
compare that file’s bytes with your input and confirm that no matching .part file remains.
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 a resumable protocol such as tus with a compatible receiving server. 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();
$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();
}
}
Run the feature tests:
php artisan test --filter=FileUploadTest
These storage-fake tests do not establish live
request-forgery protection: Laravel bypasses that middleware in feature tests. In the running app,
check an authenticated cross-origin request with no token and expect 419. Also check token fallback
with Sec-Fetch-Site absent: a missing or wrong token should receive 419, while a valid session
token should reach validation. A secure same-origin browser request without a token can pass under
Laravel 13’s default policy.
Exercise storage failure, throttling, session expiry, and logout while the page is open. Select Cancel during an upload and check that it reports an unconfirmed result, without assuming that the server removed a completed file. Delay or drop the final response to check the same boundary: transferred bytes alone must not produce a success message. When adding a download route, test that one user cannot retrieve another user’s quarantined file.
Wrap-up
Add your retention, review, and download policy before exposing received files. For managed ingestion and processing, see Transloadit’s 🤖 /upload/handle Robot.
