Téléversement de fichiers Laravel avec Vue.js et Vite
Ajoutez un sélecteur de fichiers Vue.js à votre application Laravel avec authentification et affichez la progression du téléversement sans confondre transfert terminé et stockage confirmé. Le point de terminaison accepte des fichiers JPEG, PNG et PDF dans une quarantaine privée, puis renvoie un reçu. La réception d’un fichier est distincte de son approbation pour le téléchargement.
Prérequis
- Une application Laravel 13 existante avec une connexion par session fonctionnelle, une table
userset une page Blade - PHP avec les extensions requises par Laravel, Fileinfo et le pilote PDO pour votre base de données
- Composer et Node.js 24.21.0, avec Yarn 4.12.0 sélectionné via Corepack
- HTTPS sur la même origine pour la page et son point de terminaison JSON de téléversement
Les versions fixées pour reproduire l’exemple sont Laravel 13.32.0, PHP 8.4.26, Composer 2.10.3, Node.js 24.21.0, Yarn 4.12.0 et Vue 3.5.42. Ce sont des versions testées, et non les versions minimales requises par Laravel. Utilisez cette configuration pour reproduire l’exemple ; testez-le à nouveau lors de la mise à niveau de ses dépendances.
La configuration de l’authentification ne fait pas partie de cette intégration du téléversement.
Utilisez un kit de démarrage Laravel ou votre système de connexion existant ; ne remplacez jamais
auth par un identifiant utilisateur inconditionnel. Consultez les
kits de démarrage Laravel.
Configurer le projet Laravel
Exécutez ces commandes depuis la racine de l’application, où se trouvent
artisan, package.json et son propre
yarn.lock. Définissez "packageManager": "yarn@4.12.0" dans
package.json avant de poursuivre.
Installez les paquets front-end dans l’ordre ; si l’installation échoue, corrigez cette erreur et
exécutez à nouveau le bloc :
(
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
)
Ajoutez resources/js/upload.ts à votre liste existante d’entrées Laravel Vite et incluez
vue() dans ses plugins s’il est absent. Conservez les autres entrées,
plugins et alias, en particulier si votre système de connexion utilise un kit de démarrage Inertia.
Pour une application Blade minimale, vite.config.ts contient :
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()],
})
Le contrôleur exige que le disque local utilise le pilote local avec sa
racine définie sur storage/app/private. Vérifiez config/filesystems.php ainsi
que toute configuration en cache : un disque nommé local peut avoir été
personnalisé. Conservez ce répertoire en dehors de la racine web, sans URL publique ni lien symbolique
vers le stockage. Gardez la sortie de débogage désactivée en production et permettez au processus de
l’application d’écrire dans son propre espace de stockage privé.
Consultez la configuration du système de fichiers Laravel.
(Facultatif) Générer une structure CRUD avec Quick Admin Panel
QuickAdminPanel faisait partie du contexte initial de cet article. Si vous utilisez des ressources d’administration générées, intégrez ces routes à leur système de connexion par session et à leurs politiques existantes. Vérifiez la version de Laravel de l’application générée avant d’utiliser cet exemple Laravel 13 ; ne remplacez pas en bloc ses fournisseurs de services, ses middlewares ou ses règles d’autorisation.
Mettre en place une API de téléversement sécurisée
Ce point de terminaison JSON utilise délibérément routes/web.php : il partage la
session par cookie et la protection CSRF de la page. Une URL commençant par
/api/ ne rend pas automatiquement une route sans état. Les nouvelles
applications Laravel ne fournissent pas routes/api.php tant que le routage API
n’est pas installé. Si vous avez plutôt besoin d’un déploiement SPA/API séparé, suivez
php artisan install:api et la configuration SPA avec état de Sanctum ; ajouter simplement
un en-tête CSRF à une route API par ailleurs sans état ne suffit pas. Consultez le
routage Laravel et
l’authentification SPA de Sanctum.
Ajouter une classe de requête dédiée
Créez app/Http/Requests/FileUploadRequest.php. La validation MIME inspecte le contenu ; l’attribut
accept du navigateur et les vérifications MIME côté client ne sont que
des aides à la sélection. Cela ne permet toujours pas d’établir qu’un fichier peut être rendu ou
exécuté sans danger.
<?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'],
];
}
}
Mettre à jour le contrôleur pour utiliser la classe de requête
Créez app/Http/Controllers/API/FileUploadController.php. Un identifiant de reçu généré et l’identifiant de
l’utilisateur authentifié déterminent le chemin de stockage ; le nom du fichier téléversé ne
détermine jamais un répertoire ni un suffixe. Les fichiers temporaires et finalisés utilisent le
même système de fichiers local, de sorte que le renommage se termine avant que le serveur ne renvoie
une réponse de succès. Seuls les noms de reçus finalisés doivent être utilisés par une tâche
d’examen ultérieure.
<?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);
}
}
Cet exemple repose sur la sémantique de renommage du disque local. Un pilote de stockage objet
nécessite son propre contrat de stockage intermédiaire et de publication. Planifiez la suppression
des fichiers .part abandonnés et imposez des quotas de conservation et de
stockage par utilisateur avant le déploiement. Les fichiers restent privés tant qu’un processus
distinct d’examen et de téléchargement, soumis à autorisation, n’est pas mis en place. Cet exemple
ne comporte aucune route de téléchargement publique.
Sécuriser la route
Ajoutez ces routes à routes/web.php, en conservant les autres routes de
l’application :
<?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');
});
Enregistrez le limiteur dans le AppServiceProvider::boot existant, et non dans un
RouteServiceProvider non enregistré. Voici un app/Providers/AppServiceProvider.php minimal
complet ; intégrez le limiteur à un fournisseur existant s’il a déjà d’autres responsabilités :
<?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());
});
}
}
Conservez AppServiceProvider dans bootstrap/providers.php, comme dans la
structure standard de l’application. Gardez activée la protection par défaut du groupe web contre
la falsification des requêtes et n’exemptez pas ce point de terminaison. Dans Laravel 13, les indices
fournis par le navigateur attestant une requête sécurisée de même origine peuvent permettre de
passer le contrôle avant la validation du jeton ; les autres requêtes passent par la vérification
du jeton de session. La page envoie ce jeton pour cette vérification de repli. Consultez la
protection Laravel contre la falsification des requêtes.
Construire le composant Vue
Créez resources/js/upload.ts, en conservant le point d’entrée existant de votre
application. Le montage d’une fonction de rendu évite de dépendre du compilateur de modèles de Vue
à l’exécution pour un élément personnalisé intégré dans Blade :
import { createApp } from 'vue'
import FileUpload from './components/FileUpload.vue'
createApp(FileUpload).mount('#app')
Créez resources/js/components/FileUpload.vue. Axios construit le délimiteur multipart. La progression
reste inférieure à 100 tant que la réponse 201 n’a pas confirmé le stockage. Le délai d’expiration
de 60 secondes couvre le transfert et l’attente de la réponse ; même un fichier inférieur à 10 MiB
peut donc dépasser ce délai sur une connexion lente. Une annulation ou une expiration du délai
n’annule pas une écriture sur le serveur. Il n’y a aucune nouvelle tentative automatique : chaque
requête crée un nouveau reçu. Vérifiez donc la quarantaine du serveur avant de renvoyer un fichier
dont le téléversement n’a pas été confirmé.
<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>
Effectuer le rendu du composant dans Blade
Créez 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>
Après avoir enregistré les fichiers, compilez les ressources :
corepack yarn vite build
Connectez-vous et accédez à /uploads sur l’origine HTTPS de votre
application. Choisissez un fichier JPEG, PNG ou PDF entre 1 KiB et 10 MiB, puis sélectionnez
Upload file. La page affiche
Uploading… jusqu’à ce que le serveur confirme
le stockage, puis
File received for review. Sa progression atteint alors 100.
Le champ id de la réponse JSON désigne
storage/app/private/quarantine/{authenticated-user-id}/{id} ;
comparez les octets de ce fichier à ceux de votre fichier d’entrée et confirmez qu’aucun fichier
.part correspondant ne subsiste.
Suivre les bonnes pratiques pour les fichiers volumineux
Conservez la limite de l’application à 10 MiB. Configurez upload_max_filesize de PHP
sur 10M, post_max_size sur
12M et la limite de taille du corps des requêtes du proxy inverse sur
12 MiB pour tenir compte du surcoût multipart. Fixez des limites aux délais de connexion et de
réception du corps ainsi qu’au temps d’exécution de PHP pour votre déploiement. Un proxy peut rejeter
une requête volumineuse avant l’exécution de Laravel ; le composant traite ce cas comme un
téléversement non confirmé.
Pour les transferts plus volumineux, utilisez un protocole permettant la reprise, tel que tus, avec un serveur de réception compatible. L’examen du contenu, l’analyse antimalware et le téléchargement soumis à autorisation sont des opérations distinctes qui doivent être achevées avant que les fichiers en quarantaine ne deviennent accessibles aux utilisateurs.
Tester votre processus de téléversement
Créez tests/Feature/FileUploadTest.php dans l’application avec authentification. Ces tests passent
par la véritable route, le validateur de requête et le contrôleur, en utilisant le stockage privé
simulé de Laravel :
<?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();
}
}
Exécutez les tests fonctionnels :
php artisan test --filter=FileUploadTest
Ces tests avec stockage simulé ne permettent pas d’établir l’efficacité de la protection contre la
falsification des requêtes en conditions réelles : Laravel contourne ce middleware dans les tests
fonctionnels. Dans l’application en cours d’exécution, testez une requête authentifiée provenant
d’une autre origine, sans jeton, et attendez-vous à une réponse 419. Testez également la vérification
de repli du jeton en l’absence de Sec-Fetch-Site : un jeton absent ou incorrect
doit entraîner une réponse 419, tandis qu’un jeton de session valide doit permettre d’atteindre la
validation. Une requête sécurisée du navigateur provenant de la même origine, sans jeton, peut
passer le contrôle selon la politique par défaut de Laravel 13.
Testez un échec de stockage, la limitation du débit, l’expiration de session et la déconnexion pendant que la page est ouverte. Sélectionnez Cancel pendant un téléversement et vérifiez que le résultat est signalé comme non confirmé, sans supposer que le serveur a supprimé un fichier dont le stockage était terminé. Retardez ou supprimez la réponse finale pour vérifier la même limite : les octets transférés seuls ne doivent pas produire de message de succès. Lors de l’ajout d’une route de téléchargement, testez qu’un utilisateur ne peut pas récupérer le fichier en quarantaine d’un autre utilisateur.
Conclusion
Ajoutez votre politique de conservation, d’examen et de téléchargement avant de rendre les fichiers reçus accessibles. Pour une ingestion et un traitement gérés, consultez le 🤖 Robot /upload/handle de Transloadit.
