# File Upload mit Livewire Volt + Flux UI Vollständige Referenz für den Bild-Upload, wie er in `resources/views/livewire/products/form-teaser.blade.php` implementiert ist. --- ## Überblick Der Upload nutzt: - **Livewire `WithFileUploads`** – verwaltet temporäre Uploads via signierter URL - **Flux UI `flux:file-upload`** – UI-Komponente (Dropzone + Vorschau) - **Laravel `Storage::disk('public')`** – Permanente Speicherung - **Polymorphe `media`-Tabelle** – Zuordnung von Dateien zu beliebigen Models - **Alpine.js** – Drag-&-Drop-Sortierung der vorhandenen Bilder --- ## 1. PHP / Livewire Volt – Komponentenlogik ### Trait einbinden ```php use Livewire\WithFileUploads; new class extends Component { use WithFileUploads; /** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */ public array $mainImages = []; ``` `WithFileUploads` muss zwingend eingebunden sein. Ohne ihn reagiert `wire:model` nicht auf Datei-Inputs. ### Validierung ```php 'mainImages' => 'nullable|array|min:0|max:10', 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', ``` - `mainImages` ist ein **Array** (wegen `multiple`-Upload) - `mainImages.*` validiert jede einzelne Datei - `max:10240` = 10 MB in Kilobyte - Livewire hat intern ein Default-Limit von 12 MB – das greift, bevor die eigene Validierung läuft (Achtung: Wert muss ≤ 12288 KB sein oder `livewire.temporary_file_upload.rules` anpassen) ### Einzelnes Bild entfernen (Vorschauliste) ```php public function removePhoto(int $index): void { if (isset($this->mainImages[$index])) { unset($this->mainImages[$index]); $this->mainImages = array_values($this->mainImages); } } ``` Nach `unset()` unbedingt `array_values()` aufrufen, damit die Array-Indizes wieder bei 0 beginnen – sonst bricht `@foreach` mit `$index` im Template. ### Vorhandenes Bild aus der DB löschen ```php public function removeExistingMedia(int $mediaId): void { $media = $this->product->media()->find($mediaId); if ($media) { Storage::disk('public')->delete($media->file_path); $media->delete(); $this->existingMedia = collect($this->existingMedia) ->reject(fn ($m) => $m['id'] === $mediaId) ->values() ->toArray(); } } ``` Immer erst die **Datei** vom Disk löschen, dann den **DB-Eintrag**. Anschließend `$this->existingMedia` synchronisieren, damit Livewire den State neu rendert. ### Reihenfolge aktualisieren (Drag & Drop) ```php public function updateMediaOrder(array $orderedIds): void { foreach ($orderedIds as $position => $mediaId) { $this->product->media() ->where('id', $mediaId) ->update(['order_column' => $position + 1]); } // Lokalen State synchronisieren $this->existingMedia = collect($orderedIds) ->map(fn ($id, $index) => collect($this->existingMedia)->firstWhere('id', $id) ? array_merge(collect($this->existingMedia)->firstWhere('id', $id), ['order_column' => $index + 1]) : null ) ->filter() ->values() ->toArray(); } ``` ### Bilder permanent speichern (Neu-Anlage) ```php $index = 1; foreach ($this->mainImages as $image) { $path = $image->store('products/' . $product->id, 'public'); $product->media()->create([ 'file_path' => $path, 'type' => 'image', 'alt_text' => $this->name, 'order_column' => $index++, ]); } ``` `$image->store(...)` verschiebt die temporäre Livewire-Datei in den endgültigen Pfad auf dem `public`-Disk. ### Bilder permanent speichern (Bearbeiten – neue Bilder hinzufügen) ```php $maxOrder = $this->product->media()->max('order_column') ?? 0; $index = $maxOrder + 1; foreach ($this->mainImages as $image) { $path = $image->store('products/' . $this->product->id, 'public'); $this->product->media()->create([ 'file_path' => $path, 'type' => 'image', 'alt_text' => $this->name, 'order_column' => $index++, ]); } ``` `order_column` an bestehenden Max-Wert anhängen, nicht von 1 neu beginnen. ### Nach dem Speichern zurücksetzen ```php // Neue Bilder leeren $this->mainImages = []; // Vorhandene Bilder aus DB neu laden (mit sortBy) $this->existingMedia = $this->product->fresh()->media ->sortBy('order_column') ->values() ->map(fn ($m) => [ 'id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text, 'order_column' => $m->order_column, ]) ->toArray(); ``` --- ## 2. Blade / Flux UI – Template ### Upload-Dropzone ```blade ``` - `wire:model="mainImages"` – bindet an das Array-Property - `multiple` – erlaubt Mehrfachauswahl - `accept` – schränkt den Browser-Dateidialog ein (kein serverseitiger Schutz!) - `with-progress` – zeigt Upload-Fortschrittsbalken ### Vorschauliste der neu hinzugefügten Bilder ```blade @if (isset($mainImages) && count($mainImages) > 0)
@foreach ($mainImages as $index => $image) @endforeach
@endif ``` **Wichtig bei `temporaryUrl()`:** Die Methode gibt nur dann eine URL zurück, wenn die Datei auch vorschaubar ist (`isPreviewable()` prüft die MIME-Type-Whitelist in `config/livewire.php`). Immer beide Bedingungen prüfen, sonst Fehler. ### Fehleranzeige ```blade ``` Zeigt Validierungsfehler für das gesamte Array an (z. B. „Maximal 10 Bilder erlaubt."). Für Fehler auf einzelnen Dateien würde `name="mainImages.0"` etc. verwendet. ### Drag-&-Drop-Sortierung vorhandener Bilder ```blade
@foreach ($existingMedia as $mediaIndex => $media)
@if ($mediaIndex === 0)
Standard
@endif {{ $media['alt_text'] ?? '' }}
@endforeach
``` **Wichtig:** `wire:key="existing-media-{{ $media['id'] }}"` ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet. --- ## 3. Datenbank – Media-Tabelle ```php // Migration: database/migrations/xxxx_create_media_table.php Schema::create('media', function (Blueprint $table) { $table->id(); $table->string('model_type'); // z. B. "App\Models\Product" $table->unsignedBigInteger('model_id'); $table->string('file_path'); // relativer Pfad auf dem public-Disk $table->string('type')->default('image'); // 'image', 'video', 'pdf', '3d_model' $table->string('alt_text')->nullable(); $table->integer('order_column')->default(0); $table->timestamps(); $table->index(['model_type', 'model_id']); }); ``` ### Media-Model (`app/Models/Media.php`) ```php class Media extends Model { use HasFactory; protected $fillable = [ 'model_type', 'model_id', 'file_path', 'type', 'alt_text', 'order_column', ]; protected function casts(): array { return ['order_column' => 'integer']; } /** Polymorphe Beziehung zum Eltern-Model */ public function model(): MorphTo { return $this->morphTo(); } } ``` ### Beziehung im Parent-Model (`app/Models/Product.php`) ```php use Illuminate\Database\Eloquent\Relations\MorphMany; public function media(): MorphMany { return $this->morphMany(Media::class, 'model'); } ``` --- ## 4. Filesystem-Konfiguration (`config/filesystems.php`) ```php 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL') . '/storage', 'visibility' => 'public', 'throw' => false, ], ``` ### Symlink anlegen ```bash php artisan storage:link ``` Erstellt `public/storage` → `storage/app/public`. **Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.** --- ## 5. Kritische System-Anpassungen ### 5a. `bootstrap/app.php` – Reverse-Proxy / HTTPS ```php ->withMiddleware(function (Middleware $middleware) { // Traefik/nginx Proxy: X-Forwarded-Proto-Header vertrauen // ZWINGEND für korrekte signierte Upload-URLs hinter einem HTTPS-Proxy $middleware->trustProxies(at: '*'); }) ``` **Warum?** Livewire generiert für temporäre Uploads **signierte URLs**. Wenn die App hinter einem Reverse-Proxy (Traefik, nginx, Load Balancer) läuft und der Proxy HTTPS terminiert, glaubt Laravel intern, es sei HTTP. Die signierte URL wird dann mit `http://` generiert, der Browser sendet aber `https://` – die Signatur stimmt nicht, Upload schlägt fehl mit `403`. ### 5b. `app/Providers/AppServiceProvider.php` – Schema erzwingen ```php public function boot(): void { // X-Forwarded-Proto auswerten und Schema erzwingen // Nötig für Livewire Upload-URLs hinter Traefik $scheme = request()->header('X-Forwarded-Proto') ?? request()->server('HTTP_X_FORWARDED_PROTO') ?? (request()->secure() ? 'https' : 'http'); if ($scheme === 'https') { URL::forceScheme('https'); } } ``` **Warum zusätzlich zum `trustProxies`?** `trustProxies` reicht in manchen Proxy-Setups nicht aus, wenn der Header-Name variiert. `URL::forceScheme('https')` ist die sichere Ergänzung, die sicherstellt, dass alle generierten URLs das korrekte Schema haben. **Ohne diese beiden Maßnahmen** scheitert der Upload mit einer `403 Signature mismatch`-Fehlermeldung in der Browser-Console – besonders frustrierend, weil kein PHP-Fehler erscheint. --- ## 6. Livewire-Konfiguration (`config/livewire.php`) ```php 'temporary_file_upload' => [ 'disk' => null, // null = default-Disk (meist 'local') 'rules' => null, // null = ['required', 'file', 'max:12288'] (12 MB Default) 'directory' => null, // null = 'livewire-tmp' 'middleware' => null, // null = 'throttle:60,1' 'preview_mimes' => [ // Diese MIME-Types erlauben temporaryUrl() 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'pdf', 'mp4', 'mov', 'avi', 'wmv', 'mp3', ... ], 'max_upload_time' => 5, // Minuten bis Upload ungültig wird 'cleanup' => true, // Tmp-Dateien > 24h automatisch löschen ], ``` **Wichtig:** Das interne Default-Limit ist **12 MB** (`max:12288`). Eigene Validierungsregeln wie `max:10240` müssen immer unter diesem Wert liegen. Soll das Limit erhöht werden, muss `rules` hier überschrieben werden. --- ## 7. Checkliste für ein neues Projekt | Schritt | Was | Wo | |---------|-----|----| | ✅ | `use WithFileUploads` im Volt/Livewire-Component | Komponentenklasse | | ✅ | `public array $images = []` Property anlegen | Komponentenklasse | | ✅ | `'images.*' => 'mimes:jpeg,png\|max:10240'` Validierung | `save()`-Methode | | ✅ | `$image->store('pfad', 'public')` beim Speichern | `save()`-Methode | | ✅ | `$this->images = []` nach dem Speichern leeren | `save()`-Methode | | ✅ | `php artisan storage:link` ausführen | Terminal / Deploy | | ✅ | `$middleware->trustProxies(at: '*')` | `bootstrap/app.php` | | ✅ | `URL::forceScheme('https')` bei HTTPS-Proxy | `AppServiceProvider.php` | | ✅ | `wire:key` in Foreach-Schleifen | Blade-Template | | ✅ | `array_values()` nach `unset()` auf dem Array | `removePhoto()` | | ✅ | `isPreviewable()` vor `temporaryUrl()` prüfen | Blade-Template | --- ## 8. Häufige Fallstricke ### Upload schlägt fehl mit 403 (Signature mismatch) → Reverse-Proxy-Problem. Siehe Punkt 5a und 5b. ### Vorschau-Thumbnail zeigt nichts an → `isPreviewable()` gibt `false` zurück, wenn der MIME-Type nicht in `preview_mimes` steht. In der Livewire-Config prüfen. ### Nach `removePhoto()` stimmen die Indizes nicht → `array_values()` vergessen. Livewire sendet den Index als Parameter – ohne Reindizierung kommt es zu Off-by-One-Fehlern. ### Upload-Limit-Fehler vor der Validierung → PHP `upload_max_filesize` und `post_max_size` in `php.ini` überprüfen. Auch Livewires internes `max:12288`-Limit beachten. ### `temporaryUrl()` wirft eine Exception → Bei lokalen Disks ohne `serve: true` in `filesystems.php` funktioniert `temporaryUrl()` nicht. Entweder `serve: true` setzen oder S3 verwenden. Im Template immer mit `isPreviewable()` absichern. ### Bilder nach Deploy nicht sichtbar → `php artisan storage:link` auf dem Produktionssystem ausführen. Im Docker-Container nach jedem `down/up` prüfen, ob der Symlink noch existiert.