If you've ever used Todoist, you know that little "View" popup — the one that lets you adjust sorting, grouping, layout, and filters. It looks the same everywhere, yet every page has its own unique set of options. The Inbox lets you group by date or priority. The "Today" view adds grouping by project. A project page offers manual drag-and-drop sorting.
One popup. Different capabilities. Same consistent experience.


I wanted to build something like that in Livewire. And the question that kept nagging me was:
How do you build one Livewire component that adapts its behavior and UI based on where it's used — without prop explosion, boolean flags everywhere, or duplicating the component?
The answer turned out to be a combination of:
Wireable#[Modelable]- Small capability interfaces
- A typed settings object per page
The Problem
The naive approach to building this in Livewire? Pass a bunch of 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"
/>
This quickly becomes:
- Too many props
- Conditionals everywhere
- Tight coupling between parent and child
- Fragile configuration
The component turns into a configurable junk drawer.
Instead, what if the state object itself described what it supports?
The Core Idea
Instead of passing configuration flags into the component, pass a typed settings object that:
- Knows which capabilities it supports
- Knows which options are available
- Can serialize itself through Livewire
The component simply adapts to the object it receives.
Conceptually:
ViewSettings (abstract)
├── implements Wireable
│
├── InboxSettings
│ └── implements CanGroup, CanToggleCompleted
│
├── TodaySettings
│ └── implements CanGroup, CanFilterByDate
│
└── ProjectSettings
└── implements CanToggleCompleted
No flags. No configuration arrays. Just composition.
Step 1: Define Capabilities as Interfaces
Each optional feature becomes a small 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;
}
These interfaces are not just markers — they define required structure.
If a settings class implements CanGroup, it must provide grouping state and grouping options.
Step 2: The Abstract Base Settings
All pages share some common settings — layout and sorting.
We put those in an abstract base class that also implements Wireable.
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 allows this object to survive Livewire's request cycle.
Without it, Livewire only understands scalars and arrays. With it, your domain object becomes reactive state.
If you're using enums, you may need to rehydrate them inside
fromLivewire()usingEnum::from($value).
Step 3: Concrete Settings per Page
Each page defines its own settings class and composes the capabilities it needs.
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,
];
}
}
Each page defines what it supports by the interfaces it implements.
No configuration flags. No conditional props.
Step 4: The Livewire Component
The component itself is intentionally small.
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] allows the parent to bind this property using wire:model.
Notice that the property is typed as the abstract ViewSettings — not a concrete subclass. This is what makes the whole pattern work. When a parent passes an InboxSettings or TodaySettings through wire:model, Livewire's hydration resolves the actual concrete class from the serialized data. The component never needs to know which subclass it's dealing with. You write it once against the abstraction, and Livewire handles the rest.
Step 5: The Blade View
The view adapts based on implemented capabilities.
<div>
{{-- Layout (always available) --}}
<select wire:model.live="settings.layout">
@foreach(\App\Enums\LayoutOption::cases() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
{{-- Sorting (always available) --}}
<select wire:model.live="settings.sortBy">
<option value="">Default</option>
@foreach($settings->sortOptions() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
{{-- Grouping (only if supported) --}}
@if($settings instanceof \App\ViewSettings\Contracts\CanGroup)
<select wire:model.live="settings.groupBy">
<option value="">None</option>
@foreach($settings->groupOptions() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
@endif
{{-- Date filter (only if supported) --}}
@if($settings instanceof \App\ViewSettings\Contracts\CanFilterByDate)
<select wire:model.live="settings.dateFilter">
<option value="">All</option>
@foreach($settings->dateFilterOptions() as $option)
<option value="{{ $option->value }}">
{{ $option->label() }}
</option>
@endforeach
</select>
@endif
{{-- Completed toggle (only if supported) --}}
@if($settings instanceof \App\ViewSettings\Contracts\CanToggleCompleted)
<label>
<input type="checkbox" wire:model.live="settings.showCompleted">
Show completed
</label>
@endif
</div>
The view does not ask:
@if($showGrouping)
It asks:
@if($settings instanceof CanGroup)
The object's type is the configuration.
Step 6: Using It in a Page
Inbox Page
class Inbox extends Component
{
public InboxSettings $settings;
public function mount(): void
{
$this->settings = new InboxSettings();
}
public function render()
{
$tasks = Task::query()
# Shared settings like sorting work on every page
->when(
$this->settings->sortBy,
fn ($q) => $q->orderBy($this->settings->sortBy->value)
)
# Capability-specific logic — only runs if the settings object supports it
->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 Page
class Today extends Component
{
public TodaySettings $settings;
public function mount(): void
{
$this->settings = new TodaySettings();
}
}
Same component. Different behavior. No changes required.
Why This Works
Make state describe capabilities — not configuration.
- The parent decides what is possible.
- The settings object expresses those capabilities through interfaces.
- The component adapts automatically.
- The UI remains consistent.
- Everything stays type-safe.
You get:
- Strong typing with enums
- No prop explosion
- No configuration arrays
- Clear separation of concerns
- One reusable component
Final Thoughts
The pattern in hindsight is simple:
- Capabilities are interfaces
- Shared behavior lives in an abstract base
- Pages compose what they need
- One Livewire component adapts via
instanceof Wireable+#[Modelable]make the object reactive
One component. Many faces. Zero duplication.
And everything stays type-safe from PHP to Blade.