Wer Todoist kennt, kennt auch dieses kleine "Anzeige"-Popup — das, mit dem man Sortierung, Gruppierung, Layout und Filter einstellt. Es sieht überall gleich aus, aber jede Seite hat ihre eigenen Optionen. Der Posteingang erlaubt Gruppierung nach Datum oder Priorität. Die "Heute"-Ansicht bietet zusätzlich Gruppierung nach Projekt. Eine Projektseite ermöglicht manuelles Sortieren per Drag-and-Drop.
Ein Popup. Unterschiedliche Fähigkeiten. Dasselbe konsistente Erlebnis.


Ich wollte genau so etwas in Livewire bauen. Und die Frage, die mich nicht losgelassen hat, war:
Wie baut man eine Livewire-Komponente, die ihr Verhalten und ihre UI je nach Einsatzort anpasst — ohne Prop-Explosion, ohne Boolean-Flags überall, und ohne die Komponente zu duplizieren?
Die Antwort war eine Kombination aus:
Wireable#[Modelable]- Kleinen Capability-Interfaces
- Einem typisierten Settings-Objekt pro Seite
Das Problem
Der naive Ansatz, das in Livewire umzusetzen? Einfach ein Haufen Props:
<livewire:task-view-settings
:show-grouping="true"
:grouping-options="['date', 'priority']"
:show-date-filter="false"
:show-completed-toggle="true"
:sorting-options="['name', 'date', 'priority']"
wire:model.live="settings"
/>
Das wird schnell zu:
- Zu vielen Props
- Conditionals überall
- Enger Kopplung zwischen Parent und Child
- Fragiler Konfiguration
Die Komponente wird zur konfigurierbaren Rumpelkammer.
Was wäre, wenn stattdessen das State-Objekt selbst beschreibt, was es unterstützt?
Die Kernidee
Statt Konfigurations-Flags in die Komponente zu übergeben, übergeben wir ein typisiertes Settings-Objekt, das:
- Weiß, welche Fähigkeiten es unterstützt
- Weiß, welche Optionen verfügbar sind
- Sich selbst durch Livewire serialisieren kann
Die Komponente passt sich einfach an das empfangene Objekt an.
Konzeptionell:
ViewSettings (abstract)
├── implements Wireable
│
├── InboxSettings
│ └── implements CanGroup, CanToggleCompleted
│
├── TodaySettings
│ └── implements CanGroup, CanFilterByDate
│
└── ProjectSettings
└── implements CanToggleCompleted
Keine Flags. Keine Konfigurations-Arrays. Nur Komposition.
Schritt 1: Fähigkeiten als Interfaces definieren
Jedes optionale Feature wird zu einem kleinen Interface.
namespace App\ViewSettings\Contracts;
use App\Enums\GroupOption;
interface CanGroup
{
public ?GroupOption $groupBy { get; set; }
/** @return GroupOption[] */
public function groupOptions(): array;
}
namespace App\ViewSettings\Contracts;
interface CanToggleCompleted
{
public bool $showCompleted { get; set; }
}
namespace App\ViewSettings\Contracts;
use App\Enums\DateFilterOption;
interface CanFilterByDate
{
public ?DateFilterOption $dateFilter { get; set; }
/** @return DateFilterOption[] */
public function dateFilterOptions(): array;
}
Diese Interfaces sind nicht nur Marker — sie definieren eine verbindliche Struktur.
Wenn eine Settings-Klasse CanGroup implementiert, muss sie sowohl den Gruppierungs-State als auch die verfügbaren Optionen bereitstellen.
Schritt 2: Die abstrakte Basis-Klasse
Alle Seiten teilen sich einige gemeinsame Settings — Layout und Sortierung.
Diese packen wir in eine abstrakte Basisklasse, die gleichzeitig Wireable implementiert.
namespace App\ViewSettings;
use App\Enums\LayoutOption;
use App\Enums\SortOption;
use Livewire\Wireable;
abstract class ViewSettings implements Wireable
{
public LayoutOption $layout = LayoutOption::List;
public ?SortOption $sortBy = null;
/** @return SortOption[] */
abstract public function sortOptions(): array;
public function toLivewire(): array
{
return get_object_vars($this);
}
public static function fromLivewire($value): static
{
$instance = new static;
foreach ($value as $key => $val) {
if (! property_exists($instance, $key)) {
continue;
}
$instance->$key = $val;
}
return $instance;
}
}
Wireable ermöglicht es dem Objekt, Livewires Request-Zyklus zu überleben.
Ohne Wireable versteht Livewire nur Skalare und Arrays.
Mit Wireable wird das Domain-Objekt zu reaktivem State.
Wer Enums verwendet, muss diese möglicherweise in
fromLivewire()überEnum::from($value)rehydrieren.
Schritt 3: Konkrete Settings pro Seite
Jede Seite definiert ihre eigene Settings-Klasse und komponiert die Fähigkeiten, die sie braucht.
Inbox
namespace App\ViewSettings;
use App\Enums\GroupOption;
use App\Enums\SortOption;
use App\ViewSettings\Contracts\CanGroup;
use App\ViewSettings\Contracts\CanToggleCompleted;
class InboxSettings extends ViewSettings implements CanGroup, CanToggleCompleted
{
public ?GroupOption $groupBy = null;
public bool $showCompleted = false;
public function groupOptions(): array
{
return [
GroupOption::DueDate,
GroupOption::Priority,
];
}
public function sortOptions(): array
{
return [
SortOption::CreatedAt,
SortOption::Priority,
SortOption::DueDate,
];
}
}
Today
namespace App\ViewSettings;
use App\Enums\GroupOption;
use App\Enums\SortOption;
use App\Enums\DateFilterOption;
use App\ViewSettings\Contracts\CanGroup;
use App\ViewSettings\Contracts\CanFilterByDate;
class TodaySettings extends ViewSettings implements CanGroup, CanFilterByDate
{
public ?GroupOption $groupBy = null;
public ?DateFilterOption $dateFilter = null;
public function groupOptions(): array
{
return [
GroupOption::Project,
GroupOption::Priority,
GroupOption::DueDate,
];
}
public function dateFilterOptions(): array
{
return DateFilterOption::cases();
}
public function sortOptions(): array
{
return [
SortOption::Priority,
SortOption::DueDate,
];
}
}
Jede Seite definiert über die implementierten Interfaces, was sie unterstützt.
Keine Konfigurations-Flags. Keine bedingten Props.
Schritt 4: Die Livewire-Komponente
Die Komponente selbst ist bewusst klein gehalten.
namespace App\Livewire;
use App\ViewSettings\ViewSettings;
use Livewire\Attributes\Modelable;
use Livewire\Component;
class TaskViewSettings extends Component
{
#[Modelable]
public ViewSettings $settings;
public function render()
{
return view('livewire.task-view-settings');
}
}
#[Modelable] erlaubt es dem Parent, diese Property über wire:model zu binden.
Wichtig: Die Property ist als abstrakte ViewSettings-Klasse typisiert — nicht als konkrete Subklasse. Genau das macht das gesamte Pattern möglich. Wenn ein Parent ein InboxSettings oder TodaySettings über wire:model übergibt, löst Livewires Hydration die tatsächliche konkrete Klasse aus den serialisierten Daten auf. Die Komponente muss nie wissen, mit welcher Subklasse sie es zu tun hat. Man schreibt sie einmal gegen die Abstraktion, und Livewire erledigt den Rest.
Schritt 5: Die Blade-View
Die View passt sich anhand der implementierten Fähigkeiten an.
<div>
{{-- Layout (immer verfügbar) --}}
<select wire:model.live="settings.layout">
@foreach(\App\Enums\LayoutOption::cases() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
{{-- Sortierung (immer verfügbar) --}}
<select wire:model.live="settings.sortBy">
<option value="">Standard</option>
@foreach($settings->sortOptions() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
{{-- Gruppierung (nur wenn unterstützt) --}}
@if($settings instanceof \App\ViewSettings\Contracts\CanGroup)
<select wire:model.live="settings.groupBy">
<option value="">Keine</option>
@foreach($settings->groupOptions() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
@endif
{{-- Datumsfilter (nur wenn unterstützt) --}}
@if($settings instanceof \App\ViewSettings\Contracts\CanFilterByDate)
<select wire:model.live="settings.dateFilter">
<option value="">Alle</option>
@foreach($settings->dateFilterOptions() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
@endif
{{-- Erledigte anzeigen (nur wenn unterstützt) --}}
@if($settings instanceof \App\ViewSettings\Contracts\CanToggleCompleted)
<label>
<input type="checkbox" wire:model.live="settings.showCompleted">
Erledigte anzeigen
</label>
@endif
</div>
Die View fragt nicht:
@if($showGrouping)
Sie fragt:
@if($settings instanceof CanGroup)
Der Typ des Objekts ist die Konfiguration.
Schritt 6: Einsatz auf einer Seite
Inbox-Seite
class Inbox extends Component
{
public InboxSettings $settings;
public function mount(): void
{
$this->settings = new InboxSettings();
}
public function render()
{
$tasks = Task::query()
# Gemeinsame Settings wie Sortierung funktionieren auf jeder Seite
->when(
$this->settings->sortBy,
fn ($q) => $q->orderBy($this->settings->sortBy->value)
)
# Capability-spezifische Logik — läuft nur, wenn das Settings-Objekt es unterstützt
->when(
$this->settings instanceof HasShowCompletedToggle
&& $this->settings->showCompleted,
fn ($q) => $q->withCompleted()
)
->get();
return view('livewire.pages.inbox', compact('tasks'));
}
}
<livewire:task-view-settings wire:model.live="settings" />
Today-Seite
class Today extends Component
{
public TodaySettings $settings;
public function mount(): void
{
$this->settings = new TodaySettings();
}
}
Dieselbe Komponente. Anderes Verhalten. Keine Anpassungen nötig.
Warum das funktioniert
State beschreibt Fähigkeiten — nicht Konfiguration.
- Der Parent entscheidet, was möglich ist.
- Das Settings-Objekt drückt diese Fähigkeiten über Interfaces aus.
- Die Komponente passt sich automatisch an.
- Die UI bleibt konsistent.
- Alles bleibt typsicher.
Das Ergebnis:
- Starke Typisierung mit Enums
- Keine Prop-Explosion
- Keine Konfigurations-Arrays
- Klare Trennung der Zuständigkeiten
- Eine wiederverwendbare Komponente
Fazit
Das Pattern ist im Nachhinein simpel:
- Fähigkeiten sind Interfaces
- Gemeinsames Verhalten lebt in einer abstrakten Basis
- Seiten komponieren, was sie brauchen
- Eine Livewire-Komponente passt sich über
instanceofan Wireable+#[Modelable]machen das Objekt reaktiv
Eine Komponente. Viele Gesichter. Null Duplikation.
Und alles bleibt typsicher — von PHP bis Blade.