← Back to Blog

Adaptive Livewire Components with Wireable & Modelable

livewire laravel php architecture

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.

Todoist View Settings: Inbox vs Today
Todoist View Settings: Inbox vs Today

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() using Enum::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:

  1. Capabilities are interfaces
  2. Shared behavior lives in an abstract base
  3. Pages compose what they need
  4. One Livewire component adapts via instanceof
  5. Wireable + #[Modelable] make the object reactive

One component. Many faces. Zero duplication.

And everything stays type-safe from PHP to Blade.