Last updated: May 1, 2025

<span aria-hidden="true" id="laravel-file-upload-with-vuejs--vite"></span>

# Laravel file upload with Vue.js & Vite

![Kevin van Zonneveld](/assets/images/teammates/avatar-kvz-4.jpg?dpl=dpl_3c6w24doFHQ83A1AtAy73NTtMxiB)

#### Kevin van Zonneveld

Co-founder · Amsterdam, The Netherlands · Show bio

[](https://x.com/kvz)[](https://github.com/kvz)

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`.

<span aria-hidden="true" id="prerequisites"></span>

## 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`, and `dom` extensions installed
* Composer and basic familiarity with Laravel + Vue

<span aria-hidden="true" id="set-up-the-laravel-project"></span>

## Set up the laravel project

First, install the required PHP extensions if you haven't already (example for Debian/Ubuntu):

```bash
sudo apt-get update && \
  sudo apt-get install php-xml php-curl php-mbstring php-dom

```

Next, scaffold a fresh Laravel project:

```bash
composer create-project laravel/laravel:^11.0 file-upload-demo
cd file-upload-demo

```

Install front-end dependencies, including the Vue plugin for Vite:

```bash
npm install @vitejs/plugin-vue @vue/compiler-sfc axios
npm install

```

Create a `vite.config.js` file in your project root:

```javascript
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:

```bash
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:

```bash
npm run dev

```

<span aria-hidden="true" id="optional-scaffold-crud-with-quick-admin-panel"></span>

### (optional) scaffold crud with quick admin panel

If your project also needs admin CRUD operations, [Quick Admin Panel⁠](https://quickadminpanel.com)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.

<span aria-hidden="true" id="implement-a-secure-upload-api"></span>

## Implement a secure upload API

We'll create a dedicated controller for handling file uploads via a `laravel API` endpoint.

Create the controller:

```bash
php artisan make:controller API/FileUploadController

```

<span aria-hidden="true" id="add-a-dedicated-request-class"></span>

### 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.

```bash
php artisan make:request FileUploadRequest

```

Define the authorization logic (if needed) and validation rules in`app/Http/Requests/FileUploadRequest.php`:

```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
            ],
        ];
    }
}

```

<span aria-hidden="true" id="update-the-controller-to-use-the-request-class"></span>

### 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
<?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
        }
    }
}

```

<span aria-hidden="true" id="secure-the-route"></span>

### Secure the route

Define the API route in `routes/api.php` and apply rate limiting to prevent abuse:

```php
<?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
<?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'));
        });
    }
}

```

<span aria-hidden="true" id="build-the-vue-component"></span>

## Build the Vue component

First, register the `FileUpload` component globally in `resources/js/app.js`:

```javascript
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:

```vue
<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
        const value = error.response.data.errors?.file?.[0] || 'Invalid input.'
        message.value = `Upload failed: ${value}`
      }
    } 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>

```

<span aria-hidden="true" id="render-the-component-in-blade"></span>

## 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:

```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    {{-- Include CSRF Token for Axios requests --}}
    <meta name="csrf-token" content="{{ csrf_token() }}" />
    <title>Laravel File Upload with Vue.js</title>

    {{-- Include compiled CSS and JS using Vite --}}
    @vite(['resources/css/app.css', 'resources/js/app.js'])
  </head>
  <body>
    {{-- The element where the Vue app will be mounted --}}
    <div id="app">
      <h1>Laravel File Upload with Vue.js & Progress Bar</h1>
      {{-- Use the registered Vue component --}}
      <file-upload></file-upload>
    </div>
  </body>
</html>

```

Make sure this view is returned by a route in `routes/web.php`:

```php
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

```

<span aria-hidden="true" id="follow-best-practices-for-large-files"></span>

## Follow best practices for large files

Handling large file uploads requires extra consideration:

1. **Server Configuration**: Ensure your web server (Nginx, Apache) and PHP settings (`php.ini`) allow for larger uploads. Key directives include `upload_max_filesize`, `post_max_size`,`max_execution_time`, and `client_max_body_size` (for Nginx). Adjust these based on your expected maximum file size (e.g., `100M` for 100 MiB).
2. **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⁠](https://uppy.io/) combined with the [tus protocol⁠](https://tus.io/) provide robust, resumable chunked uploading capabilities, handling network interruptions gracefully.
3. **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.
4. **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.

<span aria-hidden="true" id="test-your-upload-flow"></span>

## 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
<?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.

<span aria-hidden="true" id="wrap-up"></span>

## 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](/docs/robots/upload-handle.md) Robot can simplify these tasks, letting you focus on building features your users love.

\#laravel#vue-js#file-upload#progress-bar#handling-uploads-service

### 👩‍💻 Join 20k+ developers

Sign up for our [monthly newsletter](/newsletters.md) to receive direct links to 3 exclusive tech — and 2 product updates. No less, no more.

Your email:

Get access

## File uploading and encoding. Made simple.

Transloadit streamlines file handling for developers, trusted by brands like Coursera and The New York Times. We’re known for a reliable API, top-notch support, and a strong commitment to open source, with projects like [Uppy⁠](https://uppy.io) and [Tus⁠](https://tus.io) setting standards in file processing.

[Sign up](/c/)[Book a Demo](https://survey.typeform.com/to/kRg47Xi5)

No credit card needed · 5 GB included in the free plan

Cancel anytime
