b2in/dev/file-upload/README.md
2026-04-10 17:18:17 +02:00

458 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<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
```blade
@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
```blade
<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
```blade
<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
```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.