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());
}