1. Was Livewire 4 ändert
Livewire 4 ist das größte Release in der Geschichte des Frameworks. Das Komponentenmodell selbst ist gleich geblieben — eine PHP-Klasse plus eine Blade-Vorlage, Zustand auf dem Server, HTML über die Leitung —, doch fast alles drumherum hat sich bewegt: wo Komponenten liegen, wie sie geschrieben werden und wie viel einer Seite ein einzelnes Update berührt.
Die fünf wichtigsten Punkte
| Neuerung | Was sie bringt |
|---|---|
| Single-File-Komponenten | Klasse und Markup liegen in einer .blade.php-Datei. Kein Springen mehr zwischen zwei Verzeichnissen für eine Komponente mit zwanzig Zeilen. |
| Islands | Isolierte Bereiche innerhalb einer Komponente, die sich eigenständig neu rendern. Ein tickender Umsatzzähler führt nicht länger die Abfragen des gesamten Dashboards erneut aus. |
| Slots | Eltern reichen Markup in Kinder hinein — ausgewertet im Kontext des Elternteils, sodass wire:click in einem Slot die Methode des Elternteils aufruft. |
| Optimistisches UI | wire:show, wire:text, wire:bind ändern das DOM sofort auf dem Client, ohne Roundtrip zum Server. |
| Drag and Drop | wire:sort ist eingebaut. Kein SortableJS, kein Klebecode. |
Ist das ein Neuschreiben der Anwendung?
Nein. Livewire 4 bleibt weitgehend abwärtskompatibel: klassenbasierte Komponenten funktionieren weiter. Single-File ist der Standard für neue Komponenten, keine erzwungene Migration. Es gibt eine Reihe von Breaking Changes, die Sie vor dem Upgrade lesen sollten — sie sind im Vergleich Livewire 3 und 4 gesammelt, und der offizielle Upgrade Guide bleibt die maßgebliche Liste.
wire:model-Semantik, Islands und Slots. Der Rest des
Komponentenmodells wird Ihnen vertraut vorkommen.
2. Installation und Voraussetzungen
composer require livewire/livewire:^4.0
php artisan optimize:clear
Wie in Version 3 werden die Assets automatisch eingebunden. @livewireStyles und
@livewireScripts müssen Sie nur bei einem abweichenden Layout oder einer strengen
Content Security Policy von Hand setzen.
/livewire/ wurde /livewire-{hash}/. Firewall-Regeln, WAF-Ausnahmen,
CDN-Bypass-Regeln oder nginx-Locations, die auf den wörtlichen Pfad /livewire/ festgelegt sind,
müssen auf das neue Muster erweitert werden — sonst wirkt die Anwendung in der Produktion kaputt,
während sie auf Ihrem Laptop einwandfrei läuft.
Konfiguration veröffentlichen
php artisan livewire:publish --configEinstellungen, die in Version 4 neu sind oder umbenannt wurden:
| Einstellung | Zweck |
|---|---|
component_locations | Verzeichnisse, die nach Komponenten durchsucht werden. Standard: resources/views/components und resources/views/livewire. |
component_namespaces | Benannte Wurzeln für Komponenten, etwa pages::. |
component_layout | Hieß in v3 layout. Nutzt den Namespace layouts::. |
component_placeholder | Hieß in v3 lazy_placeholder. |
make_command | Legt fest, was make:livewire erzeugt: Single-File oder klassenbasiert. |
smart_wire_keys | Jetzt standardmäßig true. |
csp_safe | Erzeugt einen Build, der strengere Content Security Policies erfüllt. |
Den Standard-Komponentenstil wählen
// Weiterhin klassenbasierte statt Single-File-Komponenten erzeugen
'make_command' => [
'type' => 'class',
],3. Single-File-Komponenten
Die auffälligste Änderung im Arbeitsalltag. Eine Komponente ist jetzt eine Blade-Datei, die mit einem PHP-Block samt anonymer Klasse beginnt, gefolgt vom Markup.
php artisan make:livewire post.create
# resources/views/components/post/⚡create.blade.php<?php
use Livewire\Component;
use App\Models\Post;
use Livewire\Attributes\Validate;
new class extends Component {
#[Validate('required|string|min:5|max:180')]
public string $title = '';
#[Validate('required|string|min:50')]
public string $body = '';
public function save()
{
$this->validate();
Post::create([
'title' => $this->title,
'body' => $this->body,
'user_id' => auth()->id(),
]);
$this->reset();
$this->dispatch('notify', message: 'Post created');
}
};
?>
<div>
<form wire:submit="save">
<input type="text" wire:model="title" placeholder="Title">
@error('title') <span class="error">{{ $message }}</span> @enderror
<textarea wire:model="body" placeholder="Body"></textarea>
@error('body') <span class="error">{{ $message }}</span> @enderror
<button type="submit">Save post</button>
</form>
</div>
Öffentliche Eigenschaften stehen im Markup als gewöhnliche Variablen bereit — {{ $title }}
funktioniert genauso wie zuvor in einer separaten Vorlagendatei.
Zu dem Emoji
Der Standarddateiname trägt das Präfix ⚡. Es ist eine optische Markierung, die Livewire-Komponenten
von gewöhnlichen Blade-Komponenten im selben Verzeichnis abhebt, und sie ist
optional — Sie können sie in config/livewire.php abschalten, wenn Ihre
Toolchain, Ihr Terminal oder Ihr Team schlichte Namen bevorzugt.
Multi-File-Komponenten
Wird eine Komponente für eine einzelne Datei zu groß, erzeugen Sie sie als Multi-File-Komponente und erhalten Klasse, Vorlage und Test wieder getrennt.
php artisan make:livewire post.create --mfcKlassenbasierte Komponenten funktionieren weiter
<?php
namespace App\Livewire;
use Livewire\Component;
class CreatePost extends Component
{
public string $title = '';
public function render()
{
return view('livewire.create-post');
}
}app/Livewire etabliert hat.
4. Ablage, Benennung und Namespaces
Livewire-Komponenten liegen standardmäßig nicht mehr in einem eigenen Silo
resources/views/livewire, sondern direkt neben Ihren anderen Blade-Komponenten.
resources/views/
├── components/
│ ├── ⚡counter.blade.php → <livewire:counter />
│ ├── post/
│ │ ├── ⚡create.blade.php → <livewire:post.create />
│ │ └── ⚡index.blade.php → <livewire:post.index />
│ └── button.blade.php (a plain Blade component)
└── pages/
└── ⚡dashboard.blade.php → <livewire:pages::dashboard />Rendern
{{-- Nach Name --}}
<livewire:counter />
{{-- Verschachtelte Verzeichnisse mit Punkten --}}
<livewire:post.create />
{{-- Mit Namespace --}}
<livewire:pages::post.create />
{{-- Mit Props --}}
<livewire:post.create :title="$initialTitle" :author="$user" /><livewire:some-component> schlicht gar nichts — keine Fehlermeldung, keine Ausgabe. Immer
selbstschließend notieren: <livewire:some-component />. Das ist die häufigste Überraschung
beim Übernehmen von Blade-Dateien aus v3.
Routing auf eine Komponente
Ganzseitige Komponenten erhalten ein eigenes Routen-Makro. Die alte Form
Route::get('/dashboard', Dashboard::class) wird ersetzt durch:
use App\Livewire\Dashboard;
// Nach Klasse
Route::livewire('/dashboard', Dashboard::class);
// Nach Komponentenname — die übliche Wahl für vorlagenbasierte Komponenten
Route::livewire('/dashboard', 'pages::dashboard')
->middleware('auth')
->name('dashboard');Layouts
'component_layout' => 'layouts::app',use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
new class extends Component {
#[Layout('layouts::app')]
#[Title('Dashboard')]
public function render() { /* … */ }
};5. Eigenschaften und Props
Öffentliche Eigenschaften sind weiterhin der Zustand der Komponente, werden weiterhin zwischen
Anfragen in den Snapshot serialisiert und unterliegen denselben Typbeschränkungen: Skalare, Arrays aus
Skalaren, Eloquent-Modelle und -Collections, Carbon, Enums.
new class extends Component {
public string $name = '';
public ?int $age = null;
public array $tags = [];
public bool $isPublic = false;
};Props vom Elternteil: #[Prop]
Werte, die aus einer Eltern-Vorlage übergeben werden, lassen sich explizit deklarieren. So ist die öffentliche Schnittstelle der Komponente auf einen Blick lesbar, und „kommt von außen“ ist sauber von „ist interner Zustand“ getrennt.
<?php
use Livewire\Component;
use Livewire\Attributes\Prop;
new class extends Component {
#[Prop]
public string $type = 'info';
#[Prop]
public bool $dismissible = false;
};
?>
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $slot }}
@if ($dismissible)
<button wire:click="$dispatch('dismiss')">×</button>
@endif
</div><livewire:alert type="warning" dismissible class="mt-4">
The invoice is overdue.
</livewire:alert>mount() läuft weiterhin zuerst
use App\Models\Order;
new class extends Component {
public Order $order;
public string $mode;
public function mount(Order $order, string $mode = 'compact'): void
{
$this->order = $order;
$this->mode = $mode;
}
};#[Locked] ist so wichtig wie eh und je
use Livewire\Attributes\Locked;
new class extends Component {
#[Locked]
public int $invoiceId; // der Client kann keine andere ID unterschieben
public string $note = '';
};6. wire:model in Version 4
wire:model verhalten sich in v4 anders, und beide scheitern lautlos statt mit einer Fehlermeldung.
Änderung 1: Modifikatoren steuern jetzt die clientseitige Synchronisation
In v3 entschieden .blur und .change nur, wann eine Netzwerkanfrage ausgelöst wird.
In v4 entscheiden sie, wann der Wert überhaupt synchronisiert wird — auch auf dem Client. Für das alte
Verhalten ergänzen Sie .live.
{{-- Livewire 3 --}}
<input wire:model.blur="title">
{{-- Das v4-Äquivalent dieses Verhaltens --}}
<input wire:model.live.blur="title">Änderung 2: kein Event-Bubbling von Kindelementen mehr
wire:model auf einem Wrapper fängt keine Events mehr ab, die von verschachtelten Eingabefeldern
hochblubbern. Wenn Sie sich darauf verlassen haben, ergänzen Sie .deep.
{{-- Livewire 3: der Wrapper fing das Event des Inputs ab --}}
<div wire:model="value">
<input type="text">
</div>
{{-- Livewire 4: explizit wieder aktivieren --}}
<div wire:model.deep="value">
<input type="text">
</div>Alles andere bleibt gleich
{{-- Standardmäßig verzögert --}}
<input type="text" wire:model="name">
{{-- Anfrage bei jeder Änderung --}}
<input type="text" wire:model.live="search">
{{-- Live-Suche mit Debounce --}}
<input type="text" wire:model.live.debounce.400ms="search">
{{-- Verschachtelte Daten --}}
<input wire:model="form.address.city">
<input wire:model="post.title">post.title) verlangt weiterhin eine passende Validierungsregel.
Das ist der Schutz vor Mass Assignment, und er ist unverändert in v4 übernommen worden.
7. Aktionen, Async und Renderless
Aktionen werden genau wie zuvor deklariert — als öffentliche Methoden, die aus dem Markup aufgerufen werden.
new class extends Component {
public function save()
{
$this->validate();
// …
}
};<button wire:click="save">Save</button>
<form wire:submit="save">…</form>
<button wire:click="delete({{ $post->id }})"
wire:confirm="Delete this post? This cannot be undone.">Delete</button>Aktionen ohne Neurendern
Ändert eine Aktion nichts, was der Nutzer sieht — einen Aufrufzähler erhöhen, eine Audit-Zeile schreiben —, spart das Überspringen des Neurenderns einen kompletten Template-Durchlauf und einen DOM-Diff.
use Livewire\Attributes\Renderless;
new class extends Component {
#[Renderless]
public function incrementViewCount(): void
{
$this->post->increment('views');
}
};{{-- Oder von der Template-Seite --}}
<button type="button" wire:click.renderless="incrementViewCount">Track</button>Das imperative Gegenstück innerhalb einer Methode ist weiterhin $this->skipRender().
Asynchrone Aktionen
#[Async] (oder der Modifikator .async) führt eine Aktion parallel aus,
außerhalb der normalen Anfrage-Warteschlange. Ideal für Fire-and-forget-Aufgaben: Analytics-Pings, Logging,
Cache-Vorwärmung.
use Livewire\Attributes\Async;
new class extends Component {
#[Async]
public function logInteraction(string $element): void
{
Analytics::record($element, auth()->id());
}
};<button wire:click.async="logInteraction('cta-hero')">Get started</button>Magische Aktionen
<button wire:click="$refresh">Refresh</button>
<button wire:click="$set('tab', 'settings')">Settings</button>
<button wire:click="$toggle('showFilters')">Filters</button>
<button wire:click="$dispatch('open-modal', { name: 'create' })">New</button>
<button wire:click="$parent.closeModal()">Close</button>protected oder private, damit sie nicht vom Client aufgerufen werden können.
8. Lebenszyklus und Hooks
Der Lebenszyklus ist gegenüber Version 3 unverändert. Die Reihenfolge zu kennen, beseitigt nach wie vor die meisten verwirrenden Fehler.
1. Component instance created
2. boot()
3. mount($params)
4. booted()
5. render() 1. POST /livewire-{hash}/update arrives with the state snapshot
2. Snapshot checksum verified
3. Component instance created
4. boot()
5. State restored from the snapshot
6. hydrate() / hydrateFoo()
7. booted()
8. updating($prop, $value) / updatingFoo($value)
9. Properties receive new values
10. updated($prop, $value) / updatedFoo($value)
11. Queued actions run
12. rendering() → render() → rendered($view, $html)
13. dehydrate() — state packed back into the snapshot
14. Response: markup + effectsnew class extends Component {
public float $price = 0;
public function boot(): void {}
public function booted(): void {}
public function hydrate(): void {}
public function dehydrate(): void {}
public function updating(string $property, mixed $value): void {}
public function updated(string $property, mixed $value): void
{
$this->validateOnly($property);
}
// Eigenschaftsspezifisch
public function updatedPrice(mixed $value): void
{
$this->price = round((float) $value, 2);
}
// Verschachtelter Schlüssel form.email
public function updatedFormEmail(mixed $value): void
{
$this->form['email'] = strtolower(trim($value));
}
};9. Validierung und Form-Objekte
Die Validierung wird unverändert aus v3 übernommen, einschließlich Attributen und Form-Objekten.
use Livewire\Attributes\Validate;
new class extends Component {
#[Validate('required|string|min:2|max:80')]
public string $name = '';
#[Validate('required|email:rfc,dns')]
public string $email = '';
#[Validate('accepted', message: 'Please agree to the processing of your data.')]
public bool $consent = false;
public function submit(): void
{
$data = $this->validate();
Contact::create($data);
$this->reset();
}
};Bedingte Regeln
protected function rules(): array
{
return [
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($this->userId),
],
'password' => $this->userId ? 'nullable|min:8|confirmed' : 'required|min:8|confirmed',
];
}Form-Objekte
<?php
namespace App\Livewire\Forms;
use App\Models\Post;
use Livewire\Attributes\Validate;
use Livewire\Form;
class PostForm extends Form
{
public ?Post $post = null;
#[Validate('required|string|min:5|max:180')]
public string $title = '';
#[Validate('required|string|min:50')]
public string $body = '';
public function setPost(Post $post): void
{
$this->post = $post;
$this->title = $post->title;
$this->body = $post->body;
}
public function store(): Post
{
$this->validate();
return Post::create($this->except('post'));
}
public function update(): void
{
$this->validate();
$this->post->update($this->except('post'));
}
}<?php
use Livewire\Component;
use App\Livewire\Forms\PostForm;
new class extends Component {
public PostForm $form;
public function save()
{
$this->form->post ? $this->form->update() : $this->form->store();
$this->redirect(route('posts.index'), navigate: true);
}
};
?>
<div>
<form wire:submit="save">
<input wire:model="form.title">
@error('form.title') <span>{{ $message }}</span> @enderror
<textarea wire:model="form.body"></textarea>
@error('form.body') <span>{{ $message }}</span> @enderror
<button type="submit">Save</button>
</form>
</div>Fehler in JavaScript
Version 4 stellt Validierungsfehler über die magische Eigenschaft $errors auch dem Client bereit.
<div x-show="$errors.has('email')" x-text="$errors.first('email')"></div>10. Islands
Islands sind das Aushängeschild von Livewire 4 und die Neuerung, die das Denken über Komponentengröße verändert. Ein Island ist ein Bereich innerhalb einer Komponente, der sich unabhängig neu rendert: Bei einem Update wird nur dieses Fragment neu berechnet und gepatcht — nicht die ganze Komponente.
@island
<div>Revenue: {{ $this->revenue }}</div>
@endislandWarum das zählt
In v3 war das Standardmittel gegen „dieses Dashboard ist langsam“ die Aufteilung in sechs Kindkomponenten, damit ein Widget-Update nicht die Abfragen hinter den anderen fünf erneut ausführt. Islands liefern dieselbe Isolation ohne Komponentengrenze: eine Komponente, eine Klasse, mehrere unabhängig aktualisierende Bereiche.
<?php
use Livewire\Component;
use Livewire\Attributes\Computed;
new class extends Component {
#[Computed]
public function revenue()
{
return Order::whereMonth('created_at', now()->month)->sum('total');
}
#[Computed]
public function queue()
{
return Job::pending()->count();
}
#[Computed]
public function feed()
{
return Activity::latest()->take(20)->get();
}
};
?>
<div>
@island(name: 'revenue')
<div class="card">
<h3>Revenue this month</h3>
<p class="stat">{{ number_format($this->revenue, 2) }} €</p>
</div>
@endisland
@island(name: 'queue', poll: '5s')
<div class="card">
<h3>Jobs pending</h3>
<p class="stat">{{ $this->queue }}</p>
</div>
@endisland
@island(name: 'feed', lazy: true)
@placeholder
<div class="card skeleton animate-pulse h-64"></div>
@endplaceholder
<div class="card">
@foreach ($this->feed as $activity)
<div wire:key="activity-{{ $activity->id }}">{{ $activity->summary }}</div>
@endforeach
</div>
@endisland
</div>Optionen
| Option | Wirkung |
|---|---|
name | Benennt das Island, damit Aktionen und JavaScript es adressieren können. Mehrere Islands mit gleichem Namen rendern immer als Gruppe. |
lazy | Rendert, sobald das Island in den sichtbaren Bereich scrollt (Intersection Observer). |
defer | Rendert direkt nach dem Laden der Seite, unabhängig von der Sichtbarkeit. |
always | Erzwingt ein Update des Islands bei jedem Neurendern des Elternteils. |
skip | Überspringt das initiale Rendern vollständig. |
Ein Island aus einer Aktion adressieren
@island(name: 'revenue')
Revenue: {{ $this->revenue }}
@endisland
<button wire:click="$refresh" wire:island="revenue">Refresh revenue</button>Append und Prepend — Endlos-Scrolling ohne Verkabelung
@island(name: 'feed')
@foreach ($this->posts as $post)
<article wire:key="post-{{ $post->id }}">{{ $post->title }}</article>
@endforeach
@endisland
<button wire:click="loadMore" wire:island.append="feed">Load more</button>Dasselbe, gesteuert aus Alpine oder reinem JavaScript:
<button x-on:click="$wire.$island('feed', { mode: 'append' }).loadMore()">
Load more
</button>Polling auf ein Island beschränkt
@island(name: 'queue')
<div wire:poll.3s>
Jobs pending: {{ $this->queue }}
</div>
@endisland
Das Polling aktualisiert nur das Island. Zum Vergleich: In v3 führte wire:poll auf einem Dashboard
alle paar Sekunden jede Abfrage der Seite erneut aus.
11. Slots und Attribut-Weitergabe
Livewire-Komponenten nehmen jetzt Slot-Inhalte entgegen, wie Blade-Komponenten es immer konnten — mit einer
wichtigen Besonderheit: Slot-Inhalt wird im Kontext des Elternteils ausgewertet. Ein
wire:click in einem Slot ruft die Methode des Elternteils auf, nicht die des Kindes.
Standard-Slot
<livewire:modal>
<h2>Create a post</h2>
<form wire:submit="save">
<input wire:model="title">
<button type="submit">Save</button>
</form>
</livewire:modal><?php
use Livewire\Component;
new class extends Component {
public bool $isOpen = false;
public function toggle(): void
{
$this->isOpen = ! $this->isOpen;
}
};
?>
<div wire:show="isOpen" class="modal">
<button wire:click="toggle" class="modal__close">×</button>
<div class="modal__body">
{{ $slot }}
</div>
</div>
In diesem Beispiel gehören wire:submit="save" und wire:model="title" zur
Elternkomponente, während wire:click="toggle" zum Modal gehört. Genau diese Aufteilung wollen Sie:
Das Modal ist für Öffnen und Schließen zuständig, das Elternteil für das Formular.
Benannte Slots
<livewire:modal>
<wire:slot name="header">
<h2>Create a post</h2>
</wire:slot>
<form wire:submit="save">
<input wire:model="title">
</form>
<wire:slot name="footer">
<button wire:click="save">Save</button>
<button wire:click="$parent.close()">Cancel</button>
</wire:slot>
</livewire:modal><div class="modal">
@if ($header = $slot('header'))
<div class="modal__header">{{ $header }}</div>
@endif
<div class="modal__body">{{ $slot }}</div>
@if ($footer = $slot('footer'))
<div class="modal__footer">{{ $footer }}</div>
@endif
</div>Attribut-Weitergabe
Wie Blade-Komponenten kann auch eine Livewire-Komponente beliebige HTML-Attribute über
$attributes entgegennehmen und zusammenführen.
<?php
use Livewire\Component;
use Livewire\Attributes\Prop;
new class extends Component {
#[Prop]
public string $type = 'info';
};
?>
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $slot }}
</div><livewire:alert type="danger" class="mt-6" data-testid="overdue-alert">
This invoice is 14 days overdue.
</livewire:alert>12. Verschachtelte Komponenten und wire:key
Verschachtelung funktioniert wie in v3: Jedes Kind ist eine eigenständige Einheit mit eigenem Zustand und eigenem Update-Zyklus.
<div>
@foreach ($orders as $order)
<livewire:order-row :order="$order" :key="'order-'.$order->id" />
@endforeach
</div>Smart Wire Keys
In Version 4 ist smart_wire_keys standardmäßig aktiv, sodass Livewire Keys in vielen
Schleifensituationen selbst ableitet, in denen zuvor ein expliziter nötig war. Das reduziert Boilerplate,
macht Keys aber nicht überflüssig.
wire:key tragen, der an die Datensatz-ID
gebunden ist. Smart Keys sind eine Erleichterung für einfache Fälle, kein Ersatz dafür, dem Framework zu sagen,
was eine Zeile tatsächlich ist.
@foreach ($rows as $row)
<div wire:key="row-{{ $row->id }}">…</div>
@endforeachReaktive Props
use Livewire\Attributes\Reactive;
new class extends Component {
#[Reactive]
public int $quantity;
};Das Elternteil aufrufen
<button wire:click="$parent.refreshList()">Refresh the list</button>Zwei-Wege-Bindung an eine Komponente
use Livewire\Attributes\Modelable;
new class extends Component {
#[Modelable]
public int $value = 0;
};<livewire:rating-input wire:model.live="review.rating" />13. Events
Die Event-API ist gegenüber Version 3 unverändert.
// Auslösen
$this->dispatch('post-created');
$this->dispatch('post-created', postId: $post->id, title: $post->title);
$this->dispatch('refresh')->to(OrderList::class);
$this->dispatch('recalculate')->self();use Livewire\Attributes\On;
new class extends Component {
#[On('post-created')]
public function onPostCreated(int $postId, string $title): void
{
// …
}
};<button wire:click="$dispatch('open-modal', { name: 'create-order' })">New order</button>Events an den Browser
$this->dispatch('notify', type: 'success', message: 'Order saved');<div x-data="{ show: false, message: '' }"
x-on:notify.window="message = $event.detail.message; show = true; setTimeout(() => show = false, 3000)"
x-show="show"
x-transition
class="toast">
<span x-text="message"></span>
</div>14. Berechnete Eigenschaften
#[Computed] funktioniert wie in v3 und harmoniert besonders gut mit Islands: Ein Island,
das eine berechnete Eigenschaft liest, berechnet sie nur neu, wenn dieses Island aktualisiert wird.
use Livewire\Attributes\Computed;
new class extends Component {
public array $items = [];
#[Computed]
public function products()
{
return Product::whereIn('id', array_keys($this->items))->get();
}
#[Computed]
public function subtotal(): float
{
return $this->products->sum(
fn ($product) => $product->price * $this->items[$product->id]
);
}
#[Computed]
public function total(): float
{
return round($this->subtotal * 1.2, 2);
}
};@island(name: 'totals')
<p>Subtotal: {{ number_format($this->subtotal, 2) }} €</p>
<p>Total incl. VAT: {{ number_format($this->total, 2) }} €</p>
@endislandCaching über die Anfrage hinaus
#[Computed(persist: true, seconds: 300)]
public function statistics(): array
{
return [
'orders' => Order::whereMonth('created_at', now()->month)->count(),
'revenue' => Order::whereMonth('created_at', now()->month)->sum('total'),
];
}
#[Computed(cache: true, key: 'global-stats')]
public function globalStats(): array
{
return app(StatsService::class)->build();
}Den Cache leeren
unset($this->products, $this->subtotal, $this->total);$this->products, niemals $products.
15. Zustand in URL und Session
use Livewire\Attributes\Url;
use Livewire\Attributes\Session;
new class extends Component {
#[Url]
public string $search = '';
#[Url(as: 'cat')]
public ?int $categoryId = null;
#[Url(except: '')]
public string $sort = 'popular';
#[Url(keep: true)]
public int $perPage = 24;
#[Url(history: true)]
public string $tab = 'all';
#[Session]
public bool $sidebarCollapsed = false;
#[Session(key: 'admin.table.density')]
public string $density = 'comfortable';
};Ergebnis: /catalog?search=laptop&cat=5&sort=price-asc&tab=sale
Flash-Meldungen
public function save(): void
{
$this->validate();
Setting::updateOrCreate(['key' => 'theme'], ['value' => $this->theme]);
session()->flash('status', 'Settings saved');
}@if (session('status'))
<div class="alert alert-success">{{ session('status') }}</div>
@endif#[Url]-Eigenschaft ruft bei Änderung die History-API auf. Bei Feldern, die sich pro
Tastendruck ändern, kombinieren Sie das mit wire:model.live.debounce.
16. Datei-Uploads
Uploads funktionieren wie in Version 3 über den Trait WithFileUploads.
<?php
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Validate;
new class extends Component {
use WithFileUploads;
#[Validate('required|image|mimes:jpg,jpeg,png,webp|max:4096')]
public $avatar;
public function save(): void
{
$this->validate();
$path = $this->avatar->store('avatars', 'public');
auth()->user()->update(['avatar_path' => $path]);
$this->reset('avatar');
$this->dispatch('notify', message: 'Avatar updated');
}
};
?>
<div>
<form wire:submit="save">
<input type="file" wire:model="avatar" accept="image/*">
<div wire:loading wire:target="avatar">Uploading…</div>
@if ($avatar)
<img src="{{ $avatar->temporaryUrl() }}" alt="Preview" class="preview">
@endif
@error('avatar') <p class="error">{{ $message }}</p> @enderror
<button type="submit" wire:loading.attr="disabled">Save</button>
</form>
</div>Ein präziser Fortschrittsbalken
<div x-data="{ progress: 0, uploading: false }"
x-on:livewire-upload-start="uploading = true"
x-on:livewire-upload-finish="uploading = false; progress = 0"
x-on:livewire-upload-error="uploading = false"
x-on:livewire-upload-progress="progress = $event.detail.progress">
<input type="file" wire:model="avatar">
<div x-show="uploading" class="progress">
<div class="progress__bar" :style="`width: ${progress}%`"></div>
</div>
</div>'temporary_file_upload' => [
'disk' => 's3',
'rules' => ['file', 'max:12288'],
'directory' => 'livewire-tmp',
'middleware' => 'throttle:60,1',
'preview_mimes' => ['png', 'jpeg', 'jpg', 'webp', 'gif', 'mp4', 'pdf'],
'max_upload_time' => 5,
],upload_max_filesize und post_max_size in PHP sowie
client_max_body_size in nginx müssen alle Ihre max:-Regel übersteigen,
sonst schlagen Uploads ohne verständliche Fehlermeldung fehl.
17. Paginierung
use Livewire\WithPagination;
use Livewire\Attributes\Url;
new class extends Component {
use WithPagination;
#[Url]
public string $search = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function updatedSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
public function with(): array
{
return [
'orders' => Order::query()
->with('customer')
->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
->orderBy($this->sortField, $this->sortDirection)
->paginate(25),
];
}
};Islands machen aus Paginierung eine Endlosliste
<div>
<input type="search" wire:model.live.debounce.400ms="search">
@island(name: 'rows')
@foreach ($orders as $order)
<div wire:key="order-{{ $order->id }}">{{ $order->number }}</div>
@endforeach
@endisland
<button wire:click="nextPage" wire:island.append="rows">Load more</button>
</div>paginate() ein zusätzliches COUNT(*). Wenn Sie
keine Gesamtseitenzahl anzeigen — und beim Nachladen per Anhängen tun Sie das meist nicht —, ist
simplePaginate() spürbar günstiger.
18. Ladezustände
Die wire:loading-Familie ist unverändert; v4 ergänzt data-loading-Attribute fürs Styling.
<div wire:loading>Loading…</div>
<div wire:loading.remove>Content</div>
<button wire:click="save">Save</button>
<span wire:loading wire:target="save">Saving…</span>
<span wire:loading wire:target="save,delete,publish">Working…</span>
<div wire:loading wire:target.except="search">Updating…</div>
<button wire:click="save" wire:loading.attr="disabled">Save</button>
<button wire:click="save" wire:loading.class="opacity-50 cursor-wait">Save</button>
{{-- Indikator nur anzeigen, wenn die Anfrage länger als der Schwellenwert dauert --}}
<div wire:loading.delay>Loading…</div>
<div wire:loading.delay.long>Loading…</div>Styling aus CSS
[data-loading] .btn {
opacity: 0.5;
pointer-events: none;
}Ungespeicherte Änderungen und Verbindungsstatus
<input wire:model="title">
<span wire:dirty wire:target="title">You have unsaved changes</span>
<div wire:offline class="banner banner--warn">
No connection to the server. Changes are not being saved.
</div>19. Optimistisches UI auf dem Client
Eine ganze Klasse von Interaktionen brauchte den Server nie: ein Panel aufklappen, einen Zeichenzähler anzeigen, ein Feld rot färben, wenn es zu lang wird. Version 4 gibt ihnen vollwertige Direktiven, die das DOM sofort ändern — ganz ohne Anfrage.
wire:show
{{-- Schaltet die Sichtbarkeit per CSS, sofort, ohne Round Trip --}}
<div wire:show="showModal" class="modal">…</div>
<button wire:click="$toggle('showModal')">Open</button>wire:text
{{-- Der Text folgt der Eigenschaft auf dem Client --}}
<span wire:text="title"></span>
<input wire:model="title">wire:bind
<textarea wire:model="message" maxlength="280"></textarea>
<span wire:text="message.length"></span> / 280
<span wire:bind:class="message.length > 240 && 'text-red-500'">
Getting long
</span>Rein clientseitige Aktionen mit $js
<?php
new class extends Component {
public function save() { /* … */ }
};
?>
<div>
<button wire:click="save">Save</button>
{{-- Läuft ausschließlich auf dem Client --}}
<button x-on:click="$wire.$js.showToast = true">Show toast</button>
</div>wire:show / wire:text für Rückmeldungen, die der Nutzer sofort
spüren soll; eine normale Aktion für alles, was dauerhaft gespeichert werden muss.
20. Drag and Drop
Umsortieren bedeutete früher, SortableJS einzubinden, dessen Callbacks mit einer Livewire-Aktion zu verdrahten und beide Modelle synchron zu halten. In Version 4 ist es eine Direktive.
<ul wire:sort="reorder">
@foreach ($tasks as $task)
<li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
{{ $task->title }}
</li>
@endforeach
</ul>new class extends Component {
public function reorder(array $order): void
{
foreach ($order as $position => $id) {
Task::where('id', $id)->update(['position' => $position]);
}
}
};Anfasser
<ul wire:sort="reorder">
@foreach ($tasks as $task)
<li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
<span wire:sort:handle class="cursor-grab">⠿</span>
<span>{{ $task->title }}</span>
{{-- Interaktive Elemente sollen kein Ziehen auslösen --}}
<button wire:sort:ignore wire:click="delete({{ $task->id }})">Delete</button>
</li>
@endforeach
</ul>Zwischen Listen ziehen
<div class="board">
@foreach ($columns as $column)
<ul wire:sort="moveCard" wire:sort:group="board" wire:key="column-{{ $column->id }}">
@foreach ($column->cards as $card)
<li wire:sort:item="{{ $card->id }}" wire:key="card-{{ $card->id }}">
{{ $card->title }}
</li>
@endforeach
</ul>
@endforeach
</div>21. Scoped CSS und Skripte
Da eine Komponente jetzt eine einzige Datei ist, gehören auch ihre Styles und Skripte in diese Datei. Livewire begrenzt das CSS auf die Komponente, sodass es nicht in den Rest der Seite durchsickern kann.
<?php
use Livewire\Component;
use Livewire\Attributes\Prop;
new class extends Component {
#[Prop]
public string $plan = 'starter';
};
?>
<div class="card">
<h3 class="title">{{ ucfirst($plan) }}</h3>
<p class="price">{{ $this->price }} € / month</p>
</div>
<style>
/* Scoped to this component — no global .title collision */
.card { border: 1px solid #e4e2da; border-radius: 14px; padding: 24px; }
.title { font-size: 20px; font-weight: 700; }
.price { color: #2d6a4f; }
</style>Bewusst globale Styles
<style global>
:root { --brand: #f53004; }
</style>Komponenten-Skripte
@script
<script>
const chart = new Chart(document.getElementById('sales'), {
type: 'line',
data: @json($chartData),
});
$wire.on('data-updated', ({ points }) => {
chart.data.datasets[0].data = points;
chart.update();
});
</script>
@endscript{{-- Eine Drittanbieter-Bibliothek einmal für die ganze Seite laden --}}
@assets
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@endassets23. JavaScript-Integration
Alpine.js wird weiterhin mit Livewire ausgeliefert, und $wire bleibt die Brücke von
JavaScript in den Komponentenzustand.
<div x-data="{ open: false }">
<button x-on:click="open = !open">Details</button>
<div x-show="open">
<p x-text="$wire.title"></p>
<button x-on:click="$wire.title = 'New title'">Rename</button>
<button x-on:click="$wire.save()">Save</button>
<button x-on:click="await $wire.calculate(); open = false">Calculate</button>
</div>
</div>{{-- Zwei-Wege-Bindung in Alpine --}}
<div x-data="{ query: $wire.entangle('search') }">
<input x-model="query">
</div>wire:ref — Elemente und Komponenten benennen
<livewire:modal wire:ref="modal" />
<button x-on:click="$refs.modal.open()">Open the modal</button>#[Json] — Daten direkt an JavaScript zurückgeben
use Livewire\Attributes\Json;
new class extends Component {
#[Json]
public function searchSuggestions(string $term): array
{
return Product::search($term)->take(5)->pluck('name')->all();
}
};<input x-on:input.debounce.300ms="suggestions = await $wire.searchSuggestions($event.target.value)">Interceptors
Version 4 ersetzt die v3-Hooks commit und request durch
interceptMessage() und interceptRequest().
document.addEventListener('livewire:init', () => {
Livewire.interceptRequest(({ options, fail }) => {
options.headers['X-Tenant'] = window.tenantId;
fail(({ status, preventDefault }) => {
if (status === 419) {
preventDefault();
window.location.reload(); // das CSRF-Token ist abgelaufen
}
});
});
Livewire.interceptMessage(({ component, succeed }) => {
succeed(() => {
console.debug('component updated', component.name);
});
});
});// Programmatischer Zugriff
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());Einen Teilbaum ignorieren
<div wire:ignore>
<select id="select2-field">…</select>
</div>
<div wire:ignore.self>…</div>24. Testing
Pest ist der empfohlene Weg, Livewire-4-Komponenten zu testen. View-basierte Komponenten können ihren Test direkt neben der Komponente ablegen statt in einem separaten Verzeichnisbaum.
php artisan make:livewire post.create --test
# resources/views/components/post/create.test.phpEine view-basierte Komponente testen
Referenzieren Sie die Komponente über ihren Namen in Punktnotation statt über eine Klasse:
use Livewire\Livewire;
it('renders', function () {
Livewire::test('post.create')
->assertStatus(200);
});
it('validates required fields', function () {
Livewire::test('post.create')
->set('title', '')
->set('body', 'too short')
->call('save')
->assertHasErrors(['title' => 'required']);
});
it('stores a valid post', function () {
Livewire::actingAs($user)
->test('post.create')
->set('title', 'A perfectly reasonable title')
->set('body', str_repeat('Body copy that is long enough. ', 3))
->call('save')
->assertHasNoErrors()
->assertDispatched('notify');
expect(Post::where('title', 'A perfectly reasonable title')->exists())->toBeTrue();
});Klassenbasierte Komponenten
use App\Livewire\Counter;
it('increments', function () {
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1);
});Die wichtigsten Assertions
| Methode | Prüft |
|---|---|
assertSet() / assertNotSet() | Eigenschaftswerte |
assertSee() / assertDontSee() | Gerenderte Ausgabe |
assertViewHas() | An die View übergebene Daten |
assertHasErrors() / assertHasNoErrors() | Validierung |
assertDispatched() | Events |
assertRedirect() | Weiterleitungen |
assertUnauthorized() / assertForbidden() | Autorisierung |
assertStatus() | HTTP-Status |
// Uploads
use Illuminate\Http\UploadedFile;
Livewire::test('avatar-uploader')
->set('avatar', UploadedFile::fake()->image('avatar.jpg', 400, 400))
->call('save')
->assertHasNoErrors();
// Eine Komponente innerhalb einer Seite
$this->get('/dashboard')
->assertSeeLivewire('pages::dashboard');25. Sicherheit und Performance
Sicherheits-Checkliste
$this->authorize()in jeder Aktion, die Daten verändert.- Alle Identifier mit
#[Locked]markiert. - Hilfsmethoden als
protectedoderprivatedeklariert, damit der Client sie nicht aufrufen kann. - Validierungsregeln ohne interne Felder (
user_id,role,price). - Keine Tokens, Schlüssel oder überflüssigen personenbezogenen Daten in öffentlichen Eigenschaften — der Snapshot geht im Klartext in den Browser.
- Uploads nach MIME-Typ und Größe begrenzt.
- Login- und Kontaktformulare hinter einem Rate Limiter.
{!! !!}nur für vertrauenswürdiges oder bereinigtes Markup.- Optimistisches UI gilt nie als maßgeblich.
public function delete(int $postId): void
{
$post = Post::findOrFail($postId);
$this->authorize('delete', $post);
$post->delete();
}Performance
| Symptom | Lösung in v4 |
|---|---|
| Ein Widget-Update stößt alle Abfragen der Seite an | Bereiche in @island einpacken |
| Riesiges Seiten-Markup | Model-Collections aus öffentlichen Eigenschaften herausnehmen; in with() oder #[Computed] laden |
| Eine Anfrage pro Tastendruck | wire:model.live.debounce.400ms |
| Last durch Polling | Polling im Island, plus .visible |
| Round Trip nur zum Umschalten eines Panels | wire:show statt einer Aktion |
| Zähler-Inkrement rendert die Seite neu | #[Renderless] |
| N+1-Abfragen | Eager Loading über with(); Model::preventLazyLoading() in der Entwicklung |
| Langsamer erster Aufbau bei einem schweren Block | @island(lazy: true) mit einem @placeholder |
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}26. Häufige Fehler und Spickzettel
Die Komponente rendert überhaupt nicht
Der Tag ist nicht selbstschließend. In v4 erzeugt <livewire:my-component> nichts —
und zwar stillschweigend. Schreiben Sie <livewire:my-component />.
In der Produktion alles 404, lokal läuft es
Der Livewire-Endpunkt ist von /livewire/ nach /livewire-{hash}/ gewandert.
Eine Firewall-Regel, WAF-Ausnahme, CDN-Regel oder nginx-Location, die noch auf den alten festen Pfad
zeigt, blockiert die Update-Anfragen.
Ein Feld synchronisiert nach dem Upgrade nicht mehr
wire:model.blur und .change steuern jetzt die clientseitige Synchronisierung,
nicht nur das Netzwerk. Ergänzen Sie .live: wire:model.live.blur="title".
Ein Wrapper greift sein Feld nicht mehr ab
Event-Bubbling ist standardmäßig deaktiviert. Verwenden Sie wire:model.deep.
wire:transition-Modifikatoren tun nichts
.opacity, .scale und .duration.200ms wurden entfernt — v4 nutzt
die native View Transitions API. Beschreiben Sie die Animation stattdessen in CSS.
Ein Island aktualisiert nie
- Das Island hat keinen
name, die Aktion adressiert aber einen. wire:island="…"zeigt auf einen Namen, den es in der gerenderten Ausgabe nicht gibt.- Der Bereich hängt an Elternzustand, der sich gar nicht geändert hat —
always: trueergänzen.
Slot-Inhalt ruft die falsche Methode auf
Das ist so gewollt: Slot-Markup wird im Kontext der Elternkomponente ausgewertet. Um die
Kindkomponente aufzurufen, gehen Sie über ein Event oder eine wire:ref.
Volt-Komponenten funktionieren nicht mehr
Volt ist in den Kern gewandert. Ersetzen Sie Livewire\Volt\Component durch
Livewire\Component, Volt::route() durch Route::livewire(),
Volt::test() durch Livewire::test() und entfernen Sie den Volt-Service-Provider
und das Paket.
Direktiven-Spickzettel
| Direktive | Zweck | Neu in v4 |
|---|---|---|
wire:model | Feld an Eigenschaft binden | Semantik geändert |
wire:model.deep | Events von Kindelementen abfangen | ja |
wire:click / wire:submit | Aktion aufrufen | — |
wire:click.async | Aktion parallel ausführen | ja |
wire:click.renderless | Neurendern überspringen | ja |
wire:island | Ein Island aus einer Aktion adressieren | ja |
wire:island.append | An ein Island anhängen statt ersetzen | ja |
wire:show | Sichtbarkeit auf dem Client schalten | ja |
wire:text | Text clientseitig binden | ja |
wire:bind | Attribut reaktiv binden | ja |
wire:sort | Umsortieren per Drag and Drop | ja |
wire:intersect | Aktion beim Eintritt in den Viewport | ja |
wire:ref | Element oder Komponente für JS benennen | ja |
wire:navigate | Navigation im SPA-Stil | — |
wire:navigate:scroll | Container-Scroll bewahren | umbenannt |
wire:loading / wire:dirty / wire:offline | Anfragestatus | — |
wire:poll | Periodische Aktualisierung | island-begrenzt |
wire:key | Element-Identität in Schleifen | Smart Keys standardmäßig aktiv |
wire:ignore | Teilbaum vom Morphing ausnehmen | — |
Blade-Direktiven
| Direktive | Zweck |
|---|---|
@island … @endisland | Unabhängig rendernder Bereich |
@placeholder … @endplaceholder | Platzhalter für ein Lazy-Island |
<wire:slot name="…"> | Inhalt eines benannten Slots |
@script … @endscript | Komponenten-eigenes JavaScript |
@assets … @endassets | Seiten-Assets, einmalig geladen |
@persist … @endpersist | Element über Navigationen hinweg erhalten |
<style> / <style global> | Scoped / globales Komponenten-CSS |
PHP-Attribute
| Attribut | Zweck |
|---|---|
#[Validate] | Validierungsregel |
#[Locked] | Schreibzugriffe aus dem Frontend sperren |
#[Computed] | Gecachter abgeleiteter Wert |
#[Url] / #[Session] | Zustand erhalten |
#[On] | Event-Listener |
#[Prop] | Eine von der Elternkomponente gelieferte Prop deklarieren |
#[Reactive] / #[Modelable] | Bindung zwischen Eltern- und Kindkomponente |
#[Async] | Aktion parallel ausführen |
#[Renderless] | Neurendern überspringen |
#[Json] | Daten direkt an JavaScript zurückgeben |
#[Lazy] / #[Layout] / #[Title] | Laden und Seitenrahmen |
Artisan-Befehle
php artisan make:livewire post.create # Single-File
php artisan make:livewire post.create --mfc # Multi-File
php artisan make:livewire post.create --test # mit Test
php artisan make:livewire pages::dashboard # mit Namespace
php artisan livewire:publish --config
php artisan optimize:clearOffizielle Ressourcen
- Livewire-Dokumentation: livewire.laravel.com/docs
- Upgrade-Dokumentation: livewire.laravel.com/docs/upgrading
- Laravel-Dokumentation: laravel.com/docs
- Alpine.js: alpinejs.dev
Wenn diese Dokumentation und die offizielle Quelle voneinander abweichen, hat die offizielle Quelle recht. Einen Fehler gefunden? Sagen Sie uns Bescheid.