15 KiB
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',
mainImagesist ein Array (wegenmultiple-Upload)mainImages.*validiert jede einzelne Dateimax: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.rulesanpassen)
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-Propertymultiple– erlaubt Mehrfachauswahlaccept– 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,
],
Symlink anlegen
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
->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.