Laravel file upload with Vue.js & Vite

Efficient file uploads are essential for modern web applications, enhancing both usability and functionality. In this tutorial, we build a secure Laravel file-upload API and connect it to a Vue 3 front end powered by Vite—all while tracking real-time progress.
By combining Laravel’s back-end strengths with Vue’s reactive UI, you’ll end up with a seamless
drag-and-drop–ready uploader that works for images, PDFs, and anything else your business logic
allows. This guide focuses on creating a robust laravel file upload
system with a clear
file upload progress bar
.
Prerequisites
Make sure you have the following in place:
- Laravel 11.x
- Vue 3.5.x
- Node.js 20.x LTS or newer
- PHP 8.2 or newer with
xml
,curl
,mbstring
, anddom
extensions installed - Composer and basic familiarity with Laravel + Vue
Set up the laravel project
First, install the required PHP extensions if you haven't already (example for Debian/Ubuntu):
sudo apt-get update && \
sudo apt-get install php-xml php-curl php-mbstring php-dom
Next, scaffold a fresh Laravel project:
composer create-project laravel/laravel:^11.0 file-upload-demo
cd file-upload-demo
Install front-end dependencies, including the Vue plugin for Vite:
npm install @vitejs/plugin-vue @vue/compiler-sfc axios
npm install
Create a vite.config.js
file in your project root:
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
})
Set up your environment file, generate an application key, and link the public storage directory:
cp .env.example .env
# Edit .env with your database credentials, etc.
php artisan key:generate
php artisan storage:link
Finally, compile your front-end assets:
npm run dev
(optional) scaffold crud with quick admin panel
If your project also needs admin CRUD operations, Quick Admin Panel
can generate models, migrations, controllers, and views rapidly. After downloading the generated
ZIP, merge the files into your repository, run composer install && php artisan migrate
, and you’re
ready to integrate the upload logic into those resources.
Implement a secure upload API
We'll create a dedicated controller for handling file uploads via a laravel API
endpoint.
Create the controller:
php artisan make:controller API/FileUploadController
Add a dedicated request class
Using a custom Form Request keeps validation logic separate from the controller and ensures all
rules stay in one place. This is crucial for maintaining a clean laravel file upload
process.
php artisan make:request FileUploadRequest
Define the authorization logic (if needed) and validation rules in
app/Http/Requests/FileUploadRequest.php
:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class FileUploadRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Add authorization logic here if needed (e.g., check user permissions)
// Gate or policy checks could go here
return true; // Allow all requests for this example
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:10240', // 10 MiB limit
'mimes:jpeg,png,pdf', // Allowed file types
],
];
}
}
Update the controller to use the request class
Modify app/Http/Controllers/API/FileUploadController.php
to use the FileUploadRequest
for
validation and handle the file storage:
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\FileUploadRequest; // Import the request class
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\JsonResponse;
class FileUploadController extends Controller
{
/**
* Handle the incoming file upload request.
*/
public function upload(FileUploadRequest $request): JsonResponse // Use type hinting
{
try {
// Retrieve the validated file from the request
$file = $request->validated()['file'];
// Store the file in the 'public' disk under the 'uploads' directory
$path = $file->store('uploads', 'public');
// Return a success response with file details
return response()->json([
'success' => true,
'message' => 'File uploaded successfully.',
'path' => $path,
'url' => Storage::disk('public')->url($path), // Generate accessible URL
], 201); // HTTP 201 Created status
} catch (\Throwable $e) {
// Log any errors that occur during the upload
Log::error('Upload failed: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
// Return a generic error response
return response()->json([
'success' => false,
'message' => 'Upload failed due to a server error.',
], 500); // HTTP 500 Internal Server Error
}
}
}
Secure the route
Define the API route in routes/api.php
and apply rate limiting to prevent abuse:
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\API\FileUploadController;
// Apply rate limiting middleware ('throttle:uploads') to the upload route
Route::middleware(['throttle:uploads'])->post(
'/upload',
[FileUploadController::class, 'upload']
);
// Other API routes...
Add the rate limit definition in app/Providers/RouteServiceProvider.php
within the boot
method:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
// ... other properties and methods
/**
* Define your route model bindings, pattern filters, and other route configuration.
*/
public function boot(): void
{
// Define the 'uploads' rate limiter
RateLimiter::for('uploads', function (Request $request) {
// Limit to 60 requests per minute per user ID or IP address
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
}
Build the Vue component
First, register the FileUpload
component globally in resources/js/app.js
:
import './bootstrap' // Includes Axios setup
import { createApp } from 'vue'
import FileUpload from './components/FileUpload.vue' // Import the component
// Create the Vue app instance
const app = createApp({})
// Register the component globally
app.component('file-upload', FileUpload)
// Mount the app to the DOM element with id="app"
app.mount('#app')
Now, create the Vue component file resources/js/components/FileUpload.vue
using the Composition
API:
<script setup>
import { ref } from 'vue'
import axios from 'axios' // Ensure axios is imported if not globally available via bootstrap
const file = ref(null) // Holds the selected file object
const progress = ref(0) // Tracks upload progress percentage
const message = ref('') // Displays success or error messages from the API
const uploading = ref(false) // Indicates if an upload is in progress
const fileError = ref('') // Displays client-side validation errors
// Handles file selection from the input element
const selectFile = (event) => {
const selected = event.target.files[0]
if (!selected) {
file.value = null
fileError.value = ''
return
}
// Client-side validation constants
const maxSize = 10 * 1024 * 1024 // 10 MiB
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
// Validate file size
if (selected.size > maxSize) {
fileError.value = 'File size exceeds 10 MiB limit.'
file.value = null // Clear invalid file selection
event.target.value = '' // Reset file input visually
return
}
// Validate file type
if (!allowedTypes.includes(selected.type)) {
fileError.value = 'Invalid file type. Only JPEG, PNG, or PDF are allowed.'
file.value = null // Clear invalid file selection
event.target.value = '' // Reset file input visually
return
}
// If validation passes
fileError.value = '' // Clear any previous errors
file.value = selected // Set the valid file
message.value = '' // Clear previous API messages
progress.value = 0 // Reset progress bar
}
// Handles the file upload process
const uploadFile = async () => {
if (!file.value || uploading.value || fileError.value) return // Prevent upload if no file, already uploading, or error
uploading.value = true // Set uploading state
progress.value = 0 // Reset progress
message.value = '' // Clear previous messages
const formData = new FormData()
formData.append('file', file.value) // Append the file to FormData
// Retrieve CSRF token from meta tag (ensure it's in your Blade layout)
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
try {
// Make the POST request using Axios
const response = await axios.post('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'X-CSRF-TOKEN': csrfToken, // Include CSRF token
},
// Axios progress event handler
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
},
})
// Handle successful upload
message.value = response.data.message // Display success message from API
file.value = null // Clear the file state after successful upload
// Reset the file input element visually:
document.querySelector('.file-input').value = ''
} catch (error) {
// Handle errors during upload
if (error.response) {
// Server responded with an error status code
message.value = error.response.data?.message || 'Upload failed. Please try again.'
if (error.response.status === 422) {
// Handle validation errors specifically if needed
console.error('Validation Errors:', error.response.data.errors)
// You could potentially display specific validation errors here
message.value = `Upload failed: ${error.response.data.errors?.file?.[0] || 'Invalid input.'}`
}
} else if (error.request) {
// Request was made but no response received
message.value = 'Upload failed. No response from server.'
} else {
// Something else happened setting up the request
message.value = 'Upload failed. An unexpected error occurred.'
}
console.error('Upload error:', error)
} finally {
// Runs regardless of success or failure
uploading.value = false // Reset uploading state
// Keep progress bar visible briefly after completion/error, then hide
setTimeout(() => {
if (!uploading.value) {
// Only reset if another upload hasn't started
progress.value = 0
}
}, 1500)
}
}
</script>
<template>
<div class="upload-container">
<!-- File Input -->
<input
type="file"
@change="selectFile"
:disabled="uploading"
accept="image/jpeg,image/png,application/pdf"
class="file-input"
ref="fileInput"
/>
<!-- Client-side Error Message -->
<div v-if="fileError" class="error-message">{{ fileError }}</div>
<!-- Upload Button -->
<button @click="uploadFile" :disabled="!file || uploading || !!fileError" class="upload-button">
{{ uploading ? `Uploading (${progress}%)…` : 'Upload File' }}
</button>
<!-- Progress Bar -->
<div v-if="uploading || (progress > 0 && progress < 100)" class="progress-container">
<div class="progress-bar" :style="{ width: `${progress}%` }">
<span v-if="progress > 10">{{ progress }}%</span>
</div>
</div>
<!-- API Response Message -->
<div
v-if="message"
:class="[
'message',
{
error:
message.toLowerCase().includes('failed') || message.toLowerCase().includes('invalid'),
},
]"
class="message-feedback"
>
{{ message }}
</div>
</div>
</template>
<style scoped>
/* Add some basic styling for clarity */
.upload-container {
max-width: 500px;
margin: 2rem auto;
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
font-family: sans-serif;
}
.file-input {
display: block;
margin-bottom: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
.upload-button {
padding: 0.75rem 1.5rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease;
margin-bottom: 1rem; /* Add margin below button */
}
.upload-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.upload-button:not(:disabled):hover {
background-color: #0056b3;
}
.progress-container {
width: 100%;
background-color: #e9ecef;
border-radius: 4px;
margin-bottom: 1rem; /* Add margin below progress bar */
overflow: hidden; /* Ensures inner bar stays within bounds */
height: 25px; /* Give the container a fixed height */
position: relative; /* For positioning the text */
}
.progress-bar {
height: 100%;
background-color: #28a745;
color: white;
text-align: center;
line-height: 25px; /* Vertically center text */
font-size: 0.9rem;
white-space: nowrap;
transition: width 0.3s ease-out; /* Smooth progress animation */
}
.progress-bar span {
position: absolute;
width: 100%;
left: 0;
}
.message-feedback {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 4px;
background-color: #d4edda; /* Default success background */
color: #155724; /* Default success text color */
border: 1px solid #c3e6cb;
}
.message-feedback.error {
background-color: #f8d7da; /* Error background */
color: #721c24; /* Error text color */
border: 1px solid #f5c6cb;
}
.error-message {
color: #dc3545;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
</style>
Render the component in blade
Create or modify a Blade view (e.g., resources/views/welcome.blade.php
) to include the CSRF token
meta tag and mount the Vue app:
<!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>Laravel File Upload with Vue.js</title>@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body><div id="app">
<h1>Laravel File Upload with Vue.js & Progress Bar</h1><file-upload></file-upload>
</div>
</body>
</html>
Make sure this view is returned by a route in routes/web.php
:
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Follow best practices for large files
Handling large file uploads requires extra consideration:
- Server Configuration: Ensure your web server (Nginx, Apache) and PHP settings (
php.ini
) allow for larger uploads. Key directives includeupload_max_filesize
,post_max_size
,max_execution_time
, andclient_max_body_size
(for Nginx). Adjust these based on your expected maximum file size (e.g.,100M
for 100 MiB). - Chunked Uploads: For files significantly larger than 10-20 MiB, implement chunked uploads. This breaks the file into smaller pieces, uploaded sequentially or in parallel. Libraries like Uppy combined with the tus protocol provide robust, resumable chunked uploading capabilities, handling network interruptions gracefully.
- Client-Side Compression: For images, consider compressing them in the browser before
uploading to save bandwidth and reduce upload time, especially on mobile networks. Libraries
like
browser-image-compression
can help achieve this. - Security Scanning: If your application accepts uploads from untrusted users, consider
scanning uploaded files for malware using tools like ClamAV (integrated via a package like
clamav.js
or a server-side wrapper) or integrating with a third-party cloud scanning service after the upload completes.
Test your upload flow
You should test both the back-end API endpoint and the front-end component interaction.
Here's a basic PHPUnit feature test for the controller (tests/Feature/FileUploadTest.php
):
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FileUploadTest extends TestCase
{
// use RefreshDatabase; // Uncomment if your test modifies the database
/**
* Test that a valid file can be uploaded successfully.
*
* @return void
*/
public function test_file_can_be_uploaded_successfully(): void
{
// Use the fake storage disk for testing
Storage::fake('public');
// Create a fake image file within size and type limits
$file = UploadedFile::fake()->image('photo.jpg')->size(1000); // 1MB
// Make a POST request to the upload endpoint
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
// Assert the response status is 201 Created
$response->assertStatus(201);
// Assert the JSON response structure and success status
$response->assertJson([
'success' => true,
'message' => 'File uploaded successfully.',
]);
// Assert the file path exists in the JSON response
$response->assertJsonStructure(['path', 'url']);
// Assert the file was stored correctly on the fake disk
// Note: The path includes the 'uploads/' directory
Storage::disk('public')->assertExists($response->json('path'));
}
/**
* Test that uploading fails without a file.
*
* @return void
*/
public function test_file_upload_fails_without_file(): void
{
Storage::fake('public');
$response = $this->postJson('/api/upload', []); // No file sent
// Assert the response status is 422 Unprocessable Entity (validation failure)
$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}
/**
* Test that uploading fails with a file exceeding the size limit.
*
* @return void
*/
public function test_file_upload_fails_with_oversized_file(): void
{
Storage::fake('public');
// Create a fake file larger than 10 MiB (10240 KiB)
$file = UploadedFile::fake()->create('large_document.pdf', 11000); // Size in KiB
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}
/**
* Test that uploading fails with an invalid file type.
*
* @return void
*/
public function test_file_upload_fails_with_invalid_mime_type(): void
{
Storage::fake('public');
// Create a fake file with an unallowed extension/type
$file = UploadedFile::fake()->create('document.txt', 100);
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}
}
Run tests with php artisan test
.
For the front end, consider using tools like Vitest with Vue Test Utils for component unit/integration tests or Cypress/Playwright for end-to-end browser testing. These tools allow you to simulate file selection, monitor the progress bar visually or programmatically, and verify that success or error messages are displayed correctly to the user.
Wrap-up
You now have a polished, production-ready laravel file upload
pipeline: server-side validation via
a laravel API
, CSRF protection, rate limiting, client-side validation, and a Vue.js
file upload progress bar
for instant user feedback.
Need advanced features like resumable chunked uploads, virus scanning, or complex serverless media processing workflows? Consider offloading the heavy lifting to a dedicated service. Our 🤖 /upload/handle Robot can simplify these tasks, letting you focus on building features your users love.