Dateiupload mit Laravel, Vue.js & Vite
Dieses Tutorial ergänzt eine Laravel-Anwendung mit Authentifizierung um eine Vue-Dateiauswahl und Fortschrittsanzeige. Die Anwendung nimmt JPEG-, PNG- und PDF-Dateien in einem privaten Quarantänebereich entgegen; der Empfang einer Datei ist von ihrer Freigabe zum Download getrennt. Der ursprüngliche Artikel verwendete Laravel 11. Das folgende Beispiel setzt auf Laravel 13.32.0 und Vue 3.5.42 mit expliziter Session- und CSRF-Middleware.
Voraussetzungen
- Eine bestehende Laravel-13-Anwendung mit funktionierender Session-Anmeldung und einer Tabelle
users - PHP 8.3 oder neuer mit den von Laravel benötigten Erweiterungen sowie Fileinfo und dem PDO-Treiber für Ihre Datenbank
- Composer, Node.js 24.15 oder neuer innerhalb der Versionsreihe Node 24 und Yarn 4
- HTTPS mit demselben Ursprung für die Seite und ihren JSON-Upload-Endpunkt
Die Einrichtung der Authentifizierung gehört nicht zu dieser Upload-Integration. Verwenden Sie ein
Laravel-Starterkit oder Ihre bestehende Anmeldung; ersetzen Sie auth
niemals durch eine bedingungslos gesetzte Benutzer-ID. Siehe
Laravel-Starterkits.
Das Laravel-Projekt einrichten
Installieren Sie in dieser Anwendung die hier verwendeten Frontend-Pakete in exakt diesen Versionen:
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
Verwenden Sie vite.config.ts:
import vue from '@vitejs/plugin-vue'
import laravel from 'laravel-vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [laravel({ input: ['resources/js/app.ts'], refresh: true }), vue()],
})
Erstellen Sie den Build mit corepack yarn vite build. Die Beispiele verwenden die private Disk
local von Laravel. Ihr Stammverzeichnis sollte
storage/app/private sein, ohne öffentliche URL oder Storage-Symlink für dieses Verzeichnis.
Lassen Sie Debug-Ausgaben im Produktivbetrieb deaktiviert und gewähren Sie dem Anwendungsprozess
nur Zugriff auf seinen eigenen Speicher. Siehe
Laravel-Dateisystemkonfiguration.
(optional) CRUD-Grundgerüst mit QuickAdminPanel erstellen
QuickAdminPanel gehörte zum ursprünglichen Kontext dieses Artikels. Wenn Sie generierte Verwaltungsressourcen verwenden, integrieren Sie diese Routen in deren bestehende Session-Anmeldung und Berechtigungsrichtlinien. Prüfen Sie die Laravel-Version der generierten Anwendung, bevor Sie dieses Beispiel für Laravel 13 verwenden. Überschreiben Sie ihre Provider, Middleware oder Autorisierungsregeln nicht pauschal.
Eine sichere Upload-API implementieren
Dieser JSON-Endpunkt verwendet bewusst routes/web.php: Er nutzt die Cookie-Session
und den CSRF-Schutz der Seite. Eine URL, die mit /api/ beginnt, macht eine
Route nicht automatisch zustandslos. Neue Laravel-Anwendungen stellen
routes/api.php erst bereit, wenn API-Routing installiert wurde. Wenn Sie stattdessen
SPA und API getrennt bereitstellen müssen, folgen Sie php artisan install:api und der
zustandsbehafteten SPA-Einrichtung von Sanctum. Ein CSRF-Header allein reicht bei einer ansonsten
zustandslosen API-Route nicht aus. Siehe
Laravel-Routing und
SPA-Authentifizierung mit Sanctum.
Eine eigene Request-Klasse hinzufügen
Erstellen Sie app/Http/Requests/FileUploadRequest.php. Die MIME-Validierung untersucht den Inhalt; das
Browser-Attribut accept und clientseitige MIME-Prüfungen sind lediglich
Auswahlhilfen. Auch damit ist noch nicht sichergestellt, dass eine Datei gefahrlos dargestellt oder
ausgeführt werden kann.
<?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'],
];
}
}
Den Controller auf die Request-Klasse umstellen
Erstellen Sie app/Http/Controllers/API/FileUploadController.php. Eine generierte Empfangsbeleg-ID und die ID des
authentifizierten Benutzers bestimmen den Speicherpfad. Der hochgeladene Dateiname bestimmt niemals
ein Verzeichnis oder eine Dateiendung. Temporäre und fertig gespeicherte Dateien nutzen dasselbe
lokale Dateisystem, sodass die Umbenennung abgeschlossen ist, bevor der Server Erfolg meldet.
Ein späterer Prüfjob sollte nur Namen abgeschlossener Empfangsbelege verarbeiten.
<?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);
}
}
Dieses Beispiel beruht auf der Umbenennungssemantik der lokalen Disk. Ein Objektspeicher-Treiber
benötigt eigene Regeln für die Zwischenablage und Veröffentlichung. Planen Sie vor der
Bereitstellung die Entfernung verwaister Dateien mit der Endung .part
und legen Sie pro Benutzer Aufbewahrungs- und Speicherquoten fest. Dateien bleiben privat, bis ein
separater Ablauf für autorisierte Prüfung und Downloads implementiert ist. Dieses Beispiel enthält
keine öffentliche Download-Route.
Die Route absichern
Fügen Sie diese Routen zu routes/web.php hinzu und behalten Sie die übrigen
Anwendungsrouten bei:
<?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');
});
Registrieren Sie die Ratenbegrenzung im bestehenden AppServiceProvider::boot, nicht in einem
unregistrierten RouteServiceProvider. Das folgende Beispiel zeigt eine vollständige
Minimalversion von app/Providers/AppServiceProvider.php. Integrieren Sie die Ratenbegrenzung in einen
bestehenden Provider, wenn dieser bereits weitere Aufgaben erfüllt:
<?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());
});
}
}
Belassen Sie AppServiceProvider wie im Standard-Anwendungsgerüst in
bootstrap/providers.php. Lassen Sie den Schutz vor gefälschten Anfragen in der Web-Gruppe
aktiviert und nehmen Sie diesen Endpunkt nicht davon aus. Die Seite liefert das CSRF-Token der
Laravel-Session, keine Zugangsdaten für einen Speicherdienst. Siehe
Laravels Schutz vor gefälschten Anfragen.
Die Vue-Komponente erstellen
Erstellen Sie resources/js/app.ts. Durch das Einbinden einer Renderfunktion vermeiden Sie
für ein in Blade eingebettetes benutzerdefiniertes Element die Abhängigkeit vom
Template-Compiler der Vue-Laufzeit:
import { createApp } from 'vue'
import FileUpload from './components/FileUpload.vue'
createApp(FileUpload).mount('#app')
Erstellen Sie resources/js/components/FileUpload.vue. Axios erzeugt die Multipart-Begrenzung. Der Fortschritt
bleibt bis zur Antwort mit Status 201 unter 100. Ein Abbruch oder Timeout bedeutet nicht, dass der
Server den Vorgang zurückrollt. Es gibt keinen automatischen Wiederholungsversuch: Dieser Endpunkt
legt für jede Anfrage einen neuen Empfangsbeleg an.
<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>
Die Komponente in Blade rendern
Erstellen Sie 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/app.ts')
</head>
<body>
<h1>Upload a file for review</h1>
<div id="app"></div>
</body>
</html>
Bewährte Verfahren für große Dateien befolgen
Behalten Sie das Anwendungslimit von 10 MiB bei. Setzen Sie in PHP
upload_max_filesize auf 10M und
post_max_size auf 12M. Begrenzen Sie den Anfragerumpf im
Reverse-Proxy auf 12 MiB, um Multipart-Overhead zuzulassen. Begrenzen Sie für Ihre Bereitstellung die
Timeouts für Verbindungen und Anfragerümpfe sowie die PHP-Ausführungszeit. Ein Proxy kann eine große
Anfrage abweisen, bevor Laravel ausgeführt wird; die Komponente behandelt dies als unbestätigten
Upload.
Verwenden Sie für größere Übertragungen tus oder eine andere Serverintegration für fortsetzbare Uploads. Alle Timeouts zu erhöhen und Dateien unbegrenzter Größe anzunehmen, ist keine Skalierungsstrategie. Inhaltsprüfung, Malware-Scan und autorisierter Download sind separate Aufgaben, die abgeschlossen sein müssen, bevor Dateien aus der Quarantäne für Benutzer verfügbar werden.
Ihren Upload-Ablauf testen
Erstellen Sie tests/Feature/FileUploadTest.php in der Anwendung mit Authentifizierung. Diese Tests
durchlaufen die tatsächliche Route, den Request-Validator und den Controller und verwenden dabei
Laravels Testnachbildung des privaten Speichers:
<?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();
$file = UploadedFile::fake()->create('review.pdf', 2, 'application/pdf');
$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);
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();
}
}
Führen Sie php artisan test --filter=FileUploadTest aus. Laravel überspringt die CSRF-Prüfung normalerweise in
Funktionstests: Testen Sie deshalb zusätzlich die laufende Anwendung mit einer gültigen Session,
lassen Sie anschließend das CSRF-Token weg und prüfen Sie, ob die Anfrage abgewiesen wird. Testen
Sie Speicherfehler, das Abmelden während eines Uploads, Abbrüche und abgelaufene Sessions. Testen
Sie beim Hinzufügen einer Download-Route auch die Download-Berechtigung eines zweiten Benutzers.
Fazit
Die Seite ermöglicht jetzt die Auswahl und den Upload einer einzelnen Datei mit begrenzter Größe, zeigt den Übertragungsfortschritt an und bestätigt den privaten Empfang erst nach erfolgreicher Speicherung. Die Route verwendet die authentifizierte Session der Anwendung, CSRF-Middleware und eine Ratenbegrenzung pro Benutzer. Ergänzen Sie Ihre Richtlinien für Aufbewahrung, Prüfung und Downloads, bevor Sie empfangene Dateien zugänglich machen. Für verwaltete Dateiannahme und -verarbeitung bietet Transloadit den 🤖 Robot /upload/handle.
