Dateiupload mit Laravel, Vue.js & Vite
Ergänzen Sie Ihre authentifizierte Laravel-App um eine Vue.js-Dateiauswahl und zeigen Sie den Upload-Fortschritt an, ohne abgeschlossene Übertragung mit bestätigter Speicherung zu verwechseln. Der Endpunkt nimmt JPEG-, PNG- und PDF-Dateien in private Quarantäne auf und gibt anschließend eine Empfangsbestätigung zurück. Der Empfang einer Datei ist von ihrer Freigabe zum Download getrennt.
Voraussetzungen
- Eine bestehende Laravel-13-Anwendung mit funktionierendem Session-Login, einer Tabelle
usersund einer Blade-Seite - PHP mit den von Laravel benötigten Erweiterungen, Fileinfo und dem PDO-Treiber für Ihre Datenbank
- Composer und Node.js 24.21.0 sowie Yarn 4.12.0, ausgewählt über Corepack
- HTTPS mit demselben Ursprung für die Seite und ihren JSON-Upload-Endpunkt
Für die Reproduktion sind Laravel 13.32.0, PHP 8.4.26, Composer 2.10.3, Node.js 24.21.0, Yarn 4.12.0 und Vue 3.5.42 festgelegt. Dies sind getestete Versionen, keine Mindestanforderungen von Laravel. Nutzen Sie diese Konfiguration, um das Beispiel zu reproduzieren; testen Sie es erneut, wenn Sie seine Abhängigkeiten aktualisieren.
Die Einrichtung der Authentifizierung gehört nicht zu dieser Upload-Integration. Nutzen Sie ein
Laravel-Starter-Kit oder Ihren bestehenden Login; ersetzen Sie auth niemals durch eine
bedingungslos gesetzte Benutzer-ID. Siehe die
Laravel-Starter-Kits.
Das Laravel-Projekt einrichten
Führen Sie diese Befehle im Stammverzeichnis der Anwendung aus, in dem sich artisan,
package.json und ihre eigene Datei yarn.lock befinden. Setzen Sie
"packageManager": "yarn@4.12.0" in package.json, bevor Sie fortfahren.
Installieren Sie die Frontend-Pakete der Reihe nach. Falls die Installation fehlschlägt, beheben Sie
den Fehler und führen Sie den Block erneut aus:
(
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
)
Fügen Sie resources/js/upload.ts zur bestehenden Laravel-Vite-Eingabeliste hinzu und nehmen Sie
vue() in die Plugins auf, falls es fehlt. Behalten Sie die übrigen Einträge, Plugins und
Aliase bei, insbesondere wenn Ihr Login ein Inertia-Starter-Kit nutzt. Für eine minimale
Blade-Anwendung sieht vite.config.ts so aus:
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()],
})
Der Controller setzt voraus, dass der Datenträger local den lokalen Treiber verwendet und
sein Stammverzeichnis auf storage/app/private gesetzt ist. Prüfen Sie config/filesystems.php und jede
zwischengespeicherte Konfiguration: Ein Datenträger namens local kann angepasst worden
sein. Halten Sie dieses Verzeichnis außerhalb des Web-Stammverzeichnisses, ohne öffentliche URL
oder Storage-Symlink. Lassen Sie Debug-Ausgaben in Produktion deaktiviert und den Anwendungsprozess
in seinen eigenen privaten Speicher schreiben.
Siehe Laravel-Dateisystemkonfiguration.
(Optional) CRUD-Grundgerüst mit Quick Admin Panel erstellen
QuickAdminPanel gehörte zum ursprünglichen Kontext dieses Artikels. Wenn Sie generierte Admin-Ressourcen verwenden, integrieren Sie diese Routen in deren bestehenden Session-Login und Richtlinien. Prüfen Sie die Laravel-Version der generierten Anwendung, bevor Sie dieses Laravel-13-Beispiel verwenden. Überschreiben Sie ihre Provider, Middleware oder Autorisierungsregeln nicht pauschal.
Eine sichere Upload-API implementieren
Dieser JSON-Endpunkt nutzt bewusst routes/web.php: Er teilt 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 ist. Wenn Sie stattdessen SPA und API getrennt bereitstellen müssen, folgen Sie
php artisan install:api und Sanctums Einrichtung für zustandsbehaftete SPAs. Einer ansonsten
zustandslosen API-Route lediglich einen CSRF-Header hinzuzufügen, reicht nicht aus. Siehe
Laravel-Routing und
Sanctum-SPA-Authentifizierung.
Eine eigene Anfrageklasse hinzufügen
Erstellen Sie app/Http/Requests/FileUploadRequest.php. Die MIME-Validierung untersucht den Inhalt; das Browserattribut
accept und clientseitige MIME-Prüfungen sind lediglich Auswahlhilfen. Auch das belegt
noch nicht, dass sich eine Datei sicher darstellen oder ausführen lässt.
<?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 Anfrageklasse umstellen
Erstellen Sie app/Http/Controllers/API/FileUploadController.php. Eine generierte Empfangs-ID und die ID des authentifizierten
Benutzers bestimmen den Speicherpfad; der hochgeladene Dateiname bestimmt niemals ein Verzeichnis
oder eine Endung. Die temporären und abgeschlossenen Dateien verwenden dasselbe lokale
Dateisystem, sodass die Umbenennung abgeschlossen ist, bevor der Server Erfolg meldet. Ein späterer
Prüfjob sollte nur Namen abgeschlossener Empfangsbestätigungen 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 stützt sich auf die Umbenennungssemantik des lokalen Datenträgers. Ein Treiber für
Objektspeicher benötigt eigene Regeln für die Zwischenablage und Veröffentlichung. Planen Sie vor
der Bereitstellung die Entfernung zurückgelassener Dateien mit .part und legen Sie pro
Benutzer Aufbewahrungs- und Speicherquoten fest. Dateien bleiben privat, bis ein separater,
autorisierter Prüf- und Download-Ablauf 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 den Limiter im bestehenden AppServiceProvider::boot, nicht in einem unregistrierten
RouteServiceProvider. Im Folgenden sehen Sie einen vollständigen, minimalen
app/Providers/AppServiceProvider.php. Integrieren Sie den Limiter in einen bestehenden Provider, falls dieser bereits
andere Aufgaben hat:
<?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());
});
}
}
Behalten Sie AppServiceProvider in bootstrap/providers.php wie im Standard-Anwendungsgerüst bei.
Lassen Sie den standardmäßigen Schutz vor gefälschten Anfragen in der Web-Gruppe aktiviert und
nehmen Sie diesen Endpunkt nicht davon aus. In Laravel 13 können sichere Nachweise des Browsers
für denselben Ursprung bereits vor der Token-Validierung zur Freigabe führen; bei anderen Anfragen
wird stattdessen das Session-Token geprüft. Für diesen Rückfall sendet die Seite das Token mit.
Siehe Laravels Schutz vor gefälschten Anfragen.
Die Vue-Komponente erstellen
Erstellen Sie resources/js/upload.ts und behalten Sie den bestehenden Einstiegspunkt Ihrer Anwendung
bei. Durch das Einbinden einer Renderfunktion vermeiden Sie die Abhängigkeit von Vues
Laufzeit-Template-Compiler für ein benutzerdefiniertes Element, das in Blade eingebettet ist:
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-Trennmarkierung. Der Fortschritt
bleibt unter 100, bis die Antwort mit Status 201 die Speicherung bestätigt. Das Zeitlimit von
60 Sekunden umfasst die Übertragung und das Warten auf die Antwort. Daher kann selbst bei einer
Datei unter 10 MiB über eine langsame Verbindung das Zeitlimit überschritten werden. Ein Abbruch
oder eine Zeitüberschreitung macht einen Schreibvorgang auf dem Server nicht rückgängig. Es gibt
keinen automatischen Retry: Jede Anfrage legt eine neue Empfangsbestätigung an. Prüfen Sie daher
die Quarantäne des Servers, bevor Sie einen unbestätigten Upload erneut senden.
<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/upload.ts')
</head>
<body>
<h1>Upload a file for review</h1>
<div id="app"></div>
</body>
</html>
Erstellen Sie nach dem Speichern der Dateien den Asset-Build:
corepack yarn vite build
Melden Sie sich an und rufen Sie /uploads über den HTTPS-Ursprung Ihrer Anwendung auf.
Wählen Sie eine JPEG-, PNG- oder PDF-Datei zwischen 1 KiB und 10 MiB und anschließend
Upload file. Die Seite zeigt
Uploading…, bis der Server die Speicherung bestätigt, danach
File received for review. Zu diesem Zeitpunkt erreicht der Fortschritt 100.
Das Feld id der JSON-Antwort nennt storage/app/private/quarantine/{authenticated-user-id}/{id};
vergleichen Sie die Bytes dieser Datei mit Ihrer Eingabe und bestätigen Sie, dass keine passende
Datei mit .part mehr vorhanden ist.
Bewährte Verfahren für große Dateien beachten
Belassen Sie das Limit der Anwendung bei 10 MiB. Setzen Sie in PHP upload_max_filesize auf
10M und post_max_size auf 12M. Setzen Sie außerdem das Limit für den
Anfragekörper im Reverse Proxy auf 12 MiB, um Multipart-Overhead zuzulassen. Begrenzen Sie für Ihre
Bereitstellung die Zeitlimits für Verbindungen und Anfragekörper sowie die PHP-Ausführungszeit.
Ein Proxy kann eine große Anfrage ablehnen, bevor Laravel ausgeführt wird; die Komponente behandelt
dies als unbestätigten Upload.
Für größere Übertragungen verwenden Sie ein wiederaufnehmbares Protokoll wie tus mit einem kompatiblen Empfangsserver. 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 authentifizierten Anwendung. Diese Tests durchlaufen die
echte Route, den Anfragevalidator und den Controller und verwenden dabei Laravels Fake für den
privaten Speicher:
<?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();
}
}
Führen Sie die Feature-Tests aus:
php artisan test --filter=FileUploadTest
Diese Tests mit simuliertem Speicher belegen nicht, dass der Schutz vor gefälschten Anfragen im
laufenden Betrieb funktioniert: Laravel umgeht diese Middleware in Feature-Tests. Prüfen Sie in
der laufenden App eine authentifizierte Anfrage von einem anderen Ursprung ohne Token und erwarten
Sie Status 419. Prüfen Sie außerdem den Rückfall auf die Token-Prüfung, wenn
Sec-Fetch-Site fehlt: Bei einem fehlenden oder falschen Token sollte Status 419 zurückgegeben
werden, während ein gültiges Session-Token zur Validierung führen sollte. Eine sichere
Browseranfrage vom selben Ursprung ohne Token kann unter der Standardrichtlinie von Laravel 13
erfolgreich sein.
Testen Sie Speicherfehler, Anfragedrosselung, Session-Ablauf und Abmeldung bei geöffneter Seite. Wählen Sie während eines Uploads Cancel und prüfen Sie, dass ein unbestätigtes Ergebnis gemeldet wird, ohne anzunehmen, dass der Server eine abgeschlossene Datei entfernt hat. Verzögern oder verwerfen Sie die abschließende Antwort, um dieselbe Grenze zu prüfen: Übertragene Bytes allein dürfen keine Erfolgsmeldung auslösen. Wenn Sie eine Download-Route hinzufügen, testen Sie, dass ein Benutzer keine Quarantänedatei eines anderen Benutzers abrufen kann.
Fazit
Ergänzen Sie Ihre Richtlinien für Aufbewahrung, Prüfung und Download, bevor Sie empfangene Dateien zugänglich machen. Informationen zur verwalteten Dateiannahme und Verarbeitung finden Sie beim 🤖 Robot /upload/handle von Transloadit.
