Frontend

Livewire Standards

Component Structure

Basic Component

<?php

declare(strict_types=1);

namespace App\Livewire;

use App\Models\Ticket;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;

#[Title('Dashboard')]
#[Layout('components.layouts.app')]
final class Dashboard extends Component
{
    public Collection $tickets;

    public function mount(): void
    {
        $this->tickets = Ticket::query()
            ->where('user_id', auth()->id())
            ->with('category')
            ->get();
    }

    public function render(): View
    {
        return view('livewire.dashboard');
    }
}

Attributes

Common Attributes

use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;

// Page title
#[Title('Dashboard')]

// Layout template
#[Layout('components.layouts.app')]
#[Layout('components.layouts.auth')]
#[Layout('components.layouts.guest')]

// URL query string binding
#[Url]
public string $search = '';

#[Url(as: 's')]  // Custom query param name
public string $search = '';

// Prevent property from being modified by client
#[Locked]
public int $userId;

// Listen for events
#[On('ticket-created')]
public function refreshList(): void
{
    $this->tickets = Ticket::all();
}

// Computed properties
#[Computed]
public function activeCount(): int
{
    return $this->tickets->where('is_active', true)->count();
}

Form Objects

Form Class

<?php

declare(strict_types=1);

namespace App\Livewire\Forms;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Form;

final class LoginForm extends Form
{
    #[Validate('required|string|email')]
    public string $email = '';

    #[Validate('required|string')]
    public string $password = '';

    public bool $remember = false;

    public function authenticate(): void
    {
        $this->ensureIsNotRateLimited();

        if (!Auth::attempt($this->only(['email', 'password']), $this->remember)) {
            RateLimiter::hit($this->throttleKey());

            throw ValidationException::withMessages([
                'form.email' => trans('auth.failed'),
            ]);
        }

        RateLimiter::clear($this->throttleKey());
    }

    protected function ensureIsNotRateLimited(): void
    {
        if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
            return;
        }

        throw ValidationException::withMessages([
            'form.email' => trans('auth.throttle', [
                'seconds' => RateLimiter::availableIn($this->throttleKey()),
            ]),
        ]);
    }

    protected function throttleKey(): string
    {
        return Str::transliterate(
            Str::lower($this->email) . '|' . request()->ip()
        );
    }
}

Using Form in Component

<?php

declare(strict_types=1);

namespace App\Livewire\Auth;

use App\Livewire\Forms\LoginForm;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;

#[Title('Login')]
#[Layout('components.layouts.guest')]
final class Login extends Component
{
    public LoginForm $form;

    public function login(): void
    {
        $this->form->authenticate();

        session()->regenerate();

        $this->redirect(route('dashboard'), navigate: true);
    }

    public function render(): View
    {
        return view('livewire.auth.login');
    }
}

Validation

Attribute-Based Validation

use Livewire\Attributes\Validate;

// Simple validation
#[Validate('required|string|max:255')]
public string $title = '';

#[Validate('required|string')]
public string $body = '';

#[Validate('required|email|unique:users,email')]
public string $email = '';

// Nullable fields
#[Validate('nullable|string|max:500')]
public ?string $description = null;

// Array validation
#[Validate(['items' => 'required|array', 'items.*' => 'required|string'])]
public array $items = [];

Custom Validation Messages

#[Validate([
    'email' => 'required|email',
], [
    'email.required' => 'Please enter your email address.',
    'email.email' => 'Please enter a valid email address.',
])]
public string $email = '';

Manual Validation

public function save(): void
{
    $validated = $this->validate([
        'title' => 'required|string|max:255',
        'body' => 'required|string',
    ]);

    Ticket::create($validated);
}

Property Binding

Two-Way Binding

{{-- In Blade template --}}
<input type="text" wire:model="title">

{{-- Live updates (debounced) --}}
<input type="text" wire:model.live="search">

{{-- On blur --}}
<input type="text" wire:model.blur="email">

{{-- Debounce with custom delay --}}
<input type="text" wire:model.live.debounce.500ms="search">

Nested Form Binding

{{-- With form object --}}
<input type="email" wire:model="form.email">
<input type="password" wire:model="form.password">
<input type="checkbox" wire:model="form.remember">

Actions

Basic Actions

public function save(): void
{
    $this->validate();

    Ticket::create([
        'title' => $this->title,
        'body' => $this->body,
        'user_id' => auth()->id(),
    ]);

    $this->reset(['title', 'body']);

    $this->dispatch('ticket-created');
}

public function delete(Ticket $ticket): void
{
    $ticket->delete();

    $this->tickets = $this->tickets->reject(
        fn ($t) => $t->id === $ticket->id
    );
}

With Confirmation

<button wire:click="delete({{ $ticket->id }})"
        wire:confirm="Are you sure you want to delete this ticket?">
    Delete
</button>

Events

Dispatching Events

// From component
$this->dispatch('ticket-created');

// With data
$this->dispatch('ticket-updated', ticketId: $ticket->id);

// To specific component
$this->dispatch('refresh')->to(TicketList::class);

// To parent
$this->dispatch('saved')->up();

Listening for Events

#[On('ticket-created')]
public function handleTicketCreated(): void
{
    $this->tickets = Ticket::all();
}

#[On('ticket-updated')]
public function handleTicketUpdated(int $ticketId): void
{
    // Refresh specific ticket
}

Navigation

Redirects

// Standard redirect
return redirect()->route('dashboard');

// With SPA navigation
$this->redirect(route('dashboard'), navigate: true);

// With flash message
session()->flash('message', 'Ticket created successfully.');
$this->redirect(route('tickets.index'), navigate: true);

SPA Links in Blade

{{-- Use wire:navigate for SPA-style navigation --}}
<a href="{{ route('dashboard') }}" wire:navigate>Dashboard</a>

{{-- Prefetch on hover --}}
<a href="{{ route('tickets.show', $ticket) }}" wire:navigate.hover>
    {{ $ticket->title }}
</a>

Loading States

{{-- Loading indicator --}}
<button wire:click="save">
    <span wire:loading.remove wire:target="save">Save</span>
    <span wire:loading wire:target="save">Saving...</span>
</button>

{{-- Disable during loading --}}
<button wire:click="save" wire:loading.attr="disabled">
    Save
</button>

{{-- Loading class --}}
<div wire:loading.class="opacity-50" wire:target="save">
    Content
</div>

File Structure

app/Livewire/
├── Auth/
│   ├── Login.php
│   ├── Register.php
│   └── ForgotPassword.php
├── Forms/
│   ├── LoginForm.php
│   ├── RegisterForm.php
│   └── TicketForm.php
├── Dashboard.php
├── TicketList.php
└── TicketEdit.php

resources/views/livewire/
├── auth/
│   ├── login.blade.php
│   ├── register.blade.php
│   └── forgot-password.blade.php
├── dashboard.blade.php
├── ticket-list.blade.php
└── ticket-edit.blade.php

Best Practices

Keep Components Focused

// Good - Single responsibility
final class TicketList extends Component
{
    public function render(): View
    {
        return view('livewire.ticket-list', [
            'tickets' => Ticket::paginate(10),
        ]);
    }
}

// Bad - Too many responsibilities
final class TicketManager extends Component
{
    // Handles list, create, edit, delete, search, filter...
}

Use Computed Properties for Derived Data

#[Computed]
public function filteredTickets(): Collection
{
    return $this->tickets
        ->when($this->search, fn ($tickets) =>
            $tickets->filter(fn ($t) =>
                str_contains($t->title, $this->search)
            )
        );
}

#[Computed]
public function totalCount(): int
{
    return $this->tickets->count();
}

Authorize Actions

public function delete(Ticket $ticket): void
{
    $this->authorize('delete', $ticket);

    $ticket->delete();
}

public function update(Ticket $ticket): void
{
    $this->authorize('update', $ticket);

    $ticket->update($this->form->all());
}