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, and dom 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:

  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 combined with the tus protocol 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.

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.