O Laravel Livewire permite criar interfaces dinâmicas e reativas usando PHP em vez de JavaScript. Oferece a reatividade do Vue ou do React, mantendo o seu código em templates Blade familiares. Este guia aborda a construção de UIs modernas e interativas com Livewire, na perspetiva de um programador sénior.
Porquê Livewire
O Livewire oferece vantagens convincentes:
- Apenas PHP: Crie UIs reativas sem escrever JavaScript
- Integração com Blade: Use a templating do Laravel que já conhece
- Lógica no Lado do Servidor: Mantenha a lógica de negócio no servidor
- Atualizações em Tempo Real: Diffing e atualizações automáticas do DOM
- Ecossistema Laravel: Funciona de forma integrada com aplicações Laravel existentes
Instalação
Instale o Livewire via Composer:
composer require livewire/livewire
Os assets do Livewire são injetados automaticamente. Para controlo manual, adicione ao seu layout:
<html>
<head>
@livewireStyles
</head>
<body>
{{ $slot }}
@livewireScripts
</body>
</html>
Criação de Componentes
Gerar um Componente
php artisan make:livewire DataTable
Isto cria dois ficheiros:
app/Http/Livewire/DataTable.php - Classe do componenteresources/views/livewire/data-table.blade.php - Template
Estrutura do Componente
Classe do componente app/Http/Livewire/DataTable.php:
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\User;
class DataTable extends Component
{
use WithPagination;
// As propriedades públicas são automaticamente reativas.
public $search = '';
public $status = 'all';
public $sortField = 'created_at';
public $sortDirection = 'desc';
// Repor a paginação quando os filtros mudam.
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => 'all'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatus()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function render()
{
$users = User::query()
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
});
})
->when($this->status !== 'all', function ($query) {
$query->where('status', $this->status);
})
->orderBy($this->sortField, $this->sortDirection)
->paginate(10);
return view('livewire.data-table', [
'users' => $users,
]);
}
}
Template resources/views/livewire/data-table.blade.php:
<div>
<div class="filters mb-4 flex gap-4">
<input
type="text"
wire:model.debounce.300ms="search"
placeholder="Search users..."
class="form-control"
>
<select wire:model="status" class="form-control">
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<table class="table">
<thead>
<tr>
<th wire:click="sortBy('name')" class="cursor-pointer">
Name
@if($sortField === 'name')
@if($sortDirection === 'asc') ↑ @else ↓ @endif
@endif
</th>
<th wire:click="sortBy('email')" class="cursor-pointer">
Email
</th>
<th wire:click="sortBy('created_at')" class="cursor-pointer">
Created
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr>
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>{{ $user->created_at->format('M d, Y') }}</td>
<td>
<button wire:click="edit({{ $user->id }})" class="btn btn-sm btn-primary">
Edit
</button>
<button
wire:click="delete({{ $user->id }})"
wire:confirm="Are you sure you want to delete this user?"
class="btn btn-sm btn-danger"
>
Delete
</button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center">No users found.</td>
</tr>
@endforelse
</tbody>
</table>
{{ $users->links() }}
</div>
Utilizar Componentes
Em qualquer view Blade:
<livewire:data-table />
{{-- Or with parameters --}}
<livewire:data-table :status="'active'" />
Tratamento de Formulários
Criar Componente de Formulário
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use App\Models\Post;
class PostForm extends Component
{
public Post $post;
protected $rules = [
'post.title' => 'required|min:3|max:255',
'post.body' => 'required|min:10',
'post.published' => 'boolean',
];
protected $messages = [
'post.title.required' => 'The post title is required.',
'post.title.min' => 'The title must be at least 3 characters.',
];
public function mount(Post $post = null)
{
$this->post = $post ?? new Post();
}
public function updated($propertyName)
{
$this->validateOnly($propertyName);
}
public function save()
{
$this->validate();
$isNew = !$this->post->exists;
$this->post->user_id = auth()->id();
$this->post->save();
session()->flash('message', $isNew
? 'Post created successfully!'
: 'Post updated successfully!'
);
return redirect()->route('posts.index');
}
public function render()
{
return view('livewire.post-form');
}
}
Template:
<form wire:submit.prevent="save">
@if (session()->has('message'))
<div class="alert alert-success">
{{ session('message') }}
</div>
@endif
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
wire:model="post.title"
class="form-control @error('post.title') is-invalid @enderror"
>
@error('post.title')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="body">Content</label>
<textarea
id="body"
wire:model.lazy="post.body"
rows="10"
class="form-control @error('post.body') is-invalid @enderror"
></textarea>
@error('post.body')
<span class="invalid-feedback">{{ $message }}</span>
@enderror
</div>
<div class="form-check">
<input
type="checkbox"
id="published"
wire:model="post.published"
class="form-check-input"
>
<label for="published" class="form-check-label">Published</label>
</div>
<button type="submit" class="btn btn-primary" wire:loading.attr="disabled">
<span wire:loading.remove>Save Post</span>
<span wire:loading>Saving...</span>
</button>
</form>
Comunicação por Eventos
Emitir Eventos
Os componentes podem comunicar através de eventos:
// No componente filho.
public function selectItem($id)
{
$this->emit('itemSelected', $id);
}
// Emitir apenas para o componente pai.
$this->emitUp('itemSelected', $id);
// Emitir para um componente específico.
$this->emitTo('shopping-cart', 'itemAdded', $productId);
Escutar Eventos
// No componente pai.
protected $listeners = [
'itemSelected' => 'handleItemSelected',
'refreshData' => '$refresh', // Especial: re-renderizar o componente.
];
public function handleItemSelected($id)
{
$this->selectedItem = Item::find($id);
}
JavaScript para Livewire
Emita eventos a partir de JavaScript:
// Emitir para todos os componentes Livewire.
Livewire.emit('filterUpdated', filterValue);
// Emitir para um componente específico.
Livewire.emitTo('data-table', 'filterUpdated', filterValue);
Escute no componente:
protected $listeners = [
'filterUpdated' => 'setFilter',
];
public function setFilter($value)
{
$this->filter = $value;
}
Estados de Carregamento
Diretiva wire:loading
{{-- Show/hide elements --}}
<span wire:loading>Processing...</span>
<span wire:loading.remove>Ready</span>
{{-- Target specific actions --}}
<span wire:loading wire:target="save">Saving post...</span>
<span wire:loading wire:target="delete">Deleting...</span>
{{-- Target multiple actions --}}
<div wire:loading wire:target="save, update, delete">
Working...
</div>
{{-- CSS classes --}}
<button wire:loading.class="opacity-50" wire:target="save">
Save
</button>
{{-- Disable elements --}}
<button wire:loading.attr="disabled">Submit</button>
Componente de Indicador de Carregamento
Crie um overlay de carregamento reutilizável:
{{-- resources/views/livewire/partials/loading.blade.php --}}
<div
wire:loading.flex
class="fixed inset-0 bg-black bg-opacity-25 items-center justify-center z-50"
>
<div class="bg-white p-4 rounded-lg shadow-lg">
<svg class="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
</svg>
</div>
</div>
Uploads de Ficheiros
Ativar Uploads de Ficheiros
<?php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
class ImageUpload extends Component
{
use WithFileUploads;
public $photo;
public $photos = [];
protected $rules = [
'photo' => 'image|max:1024', // Máximo de 1 MB.
'photos.*' => 'image|max:1024',
];
public function updatedPhoto()
{
$this->validate([
'photo' => 'image|max:1024',
]);
}
public function save()
{
$this->validate();
// Guardar um único ficheiro.
$path = $this->photo->store('photos', 'public');
// Guardar vários ficheiros.
foreach ($this->photos as $photo) {
$photo->store('photos', 'public');
}
session()->flash('message', 'Photos uploaded!');
$this->reset(['photo', 'photos']);
}
public function render()
{
return view('livewire.image-upload');
}
}
Template:
<form wire:submit.prevent="save">
{{-- Single file --}}
<div class="form-group">
<label>Single Photo</label>
<input type="file" wire:model="photo">
@error('photo')
<span class="text-red-500">{{ $message }}</span>
@enderror
{{-- Preview --}}
@if ($photo)
<img src="{{ $photo->temporaryUrl() }}" class="mt-2 max-w-xs">
@endif
<div wire:loading wire:target="photo">Uploading...</div>
</div>
{{-- Multiple files --}}
<div class="form-group">
<label>Multiple Photos</label>
<input type="file" wire:model="photos" multiple>
@if ($photos)
<div class="flex gap-2 mt-2">
@foreach($photos as $photo)
<img src="{{ $photo->temporaryUrl() }}" class="w-20 h-20 object-cover">
@endforeach
</div>
@endif
</div>
<button type="submit" wire:loading.attr="disabled">
Upload Photos
</button>
</form>
Validação em Tempo Real
Feedback Imediato
public function updated($propertyName)
{
$this->validateOnly($propertyName);
}
Input com Debounce
{{-- Validate 500ms after user stops typing --}}
<input type="text" wire:model.debounce.500ms="email">
{{-- Validate only on blur --}}
<input type="text" wire:model.lazy="email">
{{-- Immediate validation (default) --}}
<input type="text" wire:model="email">
Polling e Atualização Automática
Polling Automático
{{-- Refresh every 2 seconds --}}
<div wire:poll.2s>
Current time: {{ now() }}
</div>
{{-- Poll only when tab is visible --}}
<div wire:poll.visible.5s>
{{ $notifications->count() }} notifications
</div>
{{-- Poll a specific method --}}
<div wire:poll.10s="refreshData">
{{ $data }}
</div>
Integração com Alpine.js
O Livewire combina de forma excelente com Alpine.js para interatividade no lado do cliente:
<div
x-data="{ open: false }"
@item-selected.window="open = false"
>
<button @click="open = !open">Toggle Menu</button>
<div x-show="open" x-cloak>
<ul>
@foreach($items as $item)
<li>
<button wire:click="selectItem({{ $item->id }})">
{{ $item->name }}
</button>
</li>
@endforeach
</ul>
</div>
</div>
Bibliotecas Úteis
Melhore o Livewire com estes packages:
- Filament: Framework completo de painel de administração construído sobre Livewire
- WireUI: Biblioteca de componentes de UI para Livewire
- Volt: Componentes Livewire de ficheiro único
# Instalar o Filament.
composer require filament/filament
# Instalar o WireUI.
composer require wireui/wireui
Principais Conclusões
- As propriedades públicas são reativas: Qualquer alteração a uma propriedade pública desencadeia um re-render
- Use wire:model com critério: Escolha entre atualizações imediatas, com debounce, ou lazy
- Tire partido de eventos: Desacople componentes com comunicação orientada a eventos
- Mostre estados de carregamento: Indique sempre quando as operações estão em curso
- Valide em tempo real: Use
validateOnly() para feedback imediato - Combine com Alpine.js: Use Alpine para interações puramente no lado do cliente
O Livewire permite criar interfaces sofisticadas e reativas, mantendo toda a lógica em PHP, o que o torna perfeito para programadores Laravel que querem UIs modernas sem uma framework JavaScript.