b2in/packages/flux-cms/README-FILE-UPLOAD.md
2026-04-10 17:18:17 +02:00

15 KiB
Raw Blame History

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

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

'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)

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

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)

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)

$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)

$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

// 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

<flux:file-upload wire:model="mainImages" label="Upload files" multiple
    accept="image/jpeg,image/png,.jpg,.jpeg,.png">
    <flux:file-upload.dropzone
        heading="Bilder hochladen"
        text="Nur JPEG oder PNG  max. 10 MB"
        with-progress />
</flux:file-upload>
  • 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

@if (isset($mainImages) && count($mainImages) > 0)
    <div class="mt-4 flex flex-wrap items-center gap-3">
        @foreach ($mainImages as $index => $image)
            <flux:file-item
                :heading="$image->getClientOriginalName()"
                :image="(str_starts_with($image->getMimeType() ?? '', 'image/') && $image->isPreviewable())
                    ? $image->temporaryUrl()
                    : null"
                :size="$image->getSize()">
                <x-slot name="actions">
                    <flux:file-item.remove
                        wire:click="removePhoto({{ $index }})"
                        aria-label="{{ 'Remove file: ' . $image->getClientOriginalName() }}" />
                </x-slot>
            </flux:file-item>
        @endforeach
    </div>
@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

<flux:error name="mainImages" />

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

<div x-data="{
    dragging: null,
    dragOver: null,
    items: @js(collect($existingMedia)->pluck('id')->toArray()),
    onDragStart(e, id) {
        this.dragging = id;
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/plain', id);
    },
    onDragOver(e, id) {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        this.dragOver = id;
    },
    onDrop(e, targetId) {
        e.preventDefault();
        if (this.dragging === targetId) { this.dragOver = null; return; }
        const fromIdx = this.items.indexOf(this.dragging);
        const toIdx = this.items.indexOf(targetId);
        this.items.splice(fromIdx, 1);
        this.items.splice(toIdx, 0, this.dragging);
        this.dragging = null;
        this.dragOver = null;
        $wire.updateMediaOrder(this.items);
    },
    onDragEnd() {
        this.dragging = null;
        this.dragOver = null;
    }
}" class="flex flex-wrap items-start gap-3">

    @foreach ($existingMedia as $mediaIndex => $media)
        <div wire:key="existing-media-{{ $media['id'] }}"
             draggable="true"
             x-on:dragstart="onDragStart($event, {{ $media['id'] }})"
             x-on:dragover="onDragOver($event, {{ $media['id'] }})"
             x-on:drop="onDrop($event, {{ $media['id'] }})"
             x-on:dragend="onDragEnd()"
             :class="{
                 'opacity-50 scale-95': dragging === {{ $media['id'] }},
                 'ring-2 ring-blue-400 ring-offset-2': dragOver === {{ $media['id'] }} && dragging !== {{ $media['id'] }}
             }"
             class="group relative cursor-grab active:cursor-grabbing transition-all duration-150">

            @if ($mediaIndex === 0)
                <div class="absolute -top-1 -left-1 z-10 bg-blue-600 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-md shadow">
                    Standard
                </div>
            @endif

            <img src="{{ Storage::url($media['file_path']) }}"
                 alt="{{ $media['alt_text'] ?? '' }}"
                 class="h-24 w-24 rounded-lg object-cover border border-zinc-200 dark:border-zinc-700
                        {{ $mediaIndex === 0 ? 'ring-2 ring-blue-500' : '' }}" />

            <flux:button
                wire:click="removeExistingMedia({{ $media['id'] }})"
                wire:confirm="Bild wirklich löschen?"
                variant="filled" size="xs" icon="trash"
                class="absolute -top-2 -right-2 !bg-red-500 !text-white hover:!bg-red-600" />
        </div>
    @endforeach
</div>

Wichtig: wire:key="existing-media-{{ $media['id'] }}" ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet.


3. Datenbank Media-Tabelle

// 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)

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)

use Illuminate\Database\Eloquent\Relations\MorphMany;

public function media(): MorphMany
{
    return $this->morphMany(Media::class, 'model');
}

4. Filesystem-Konfiguration (config/filesystems.php)

'public' => [
    'driver'     => 'local',
    'root'       => storage_path('app/public'),
    'url'        => env('APP_URL') . '/storage',
    'visibility' => 'public',
    'throw'      => false,
],
php artisan storage:link

Erstellt public/storagestorage/app/public. Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.


5. Kritische System-Anpassungen

5a. bootstrap/app.php Reverse-Proxy / HTTPS

->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

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)

'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.