Modern PHP Engineering
Chapitre 15
15 — Livewire & Alpine.js
15 — Livewire & Alpine.js
Course : Livewire & Alpine.js
1. Introduction au Full-Stack PHP avec Livewire
1.1 Philosophie Livewire
Livewire (Caleb Porzio) permet de construire des interfaces dynamiques en PHP pur, sans écrire de JavaScript. Il utilise AJAX en arrière-plan pour mettre à jour le DOM :
┌──────────┐ Requête AJAX ┌──────────┐
│ Browser │ ──────────────────▶│ PHP │
│ (Alpine) │ ◀─────────────────│ Livewire │
└──────────┘ Diff DOM └──────────┘
1.2 Installation
composer require livewire/livewire
<!-- layout.blade.php -->
<html>
<head>
@livewireStyles
@vite(['resources/css/app.css'])
</head>
<body>
{{ $slot }}
@livewireScripts
@vite(['resources/js/app.js'])
</body>
</html>
1.3 Composant minimal
php artisan make:livewire Counter
<?php
// app/Livewire/Counter.php
namespace App\Livewire;
use Livewire\Component;
class Counter extends Component
{
public int $count = 0;
public function increment(): void
{
$this->count++;
}
public function render(): \Illuminate\View\View
{
return view('livewire.counter');
}
}
{{-- resources/views/livewire/counter.blade.php --}}
<div>
<h1>Compteur : {{ $count }}</h1>
<button wire:click="increment" class="btn btn-primary">
+
</button>
</div>
2. Livewire 3 — Concepts Avancés
2.1 Full-Page Components
// Route
Route::get('/posts', App\Livewire\Posts\Index::class);
Route::get('/posts/{post}', App\Livewire\Posts\Show::class);
Route::get('/posts/create', App\Livewire\Posts\Create::class);
class Index extends Component
{
use WithPagination;
public string $search = '';
public function render(): View
{
return view('livewire.posts.index', [
'posts' => Post::where('title', 'like', "%{$this->search}%")
->paginate(15),
])->layout('layouts.app')->title('Liste des articles');
}
}
2.2 Component Parameters
class ShowPost extends Component
{
public Post $post;
public bool $showComments = false;
public function mount(Post $post): void
{
$this->post = $post;
}
public function toggleComments(): void
{
$this->showComments = !$this->showComments;
}
public function render(): View
{
return view('livewire.posts.show', [
'comments' => $this->showComments
? $this->post->comments()->latest()->get()
: collect(),
]);
}
}
2.3 Nested Components
{{-- Parent --}}
<div>
<h1>{{ $post->title }}</h1>
@foreach($post->comments as $comment)
<livewire:comment-card
:comment="$comment"
:key="'comment-' . $comment->id"
wire:key="'comment-' . $comment->id"
/>
@endforeach
<livewire:comment-form :post="$post" />
</div>
class CommentCard extends Component
{
public Comment $comment;
public bool $isEditing = false;
public function toggleEdit(): void
{
$this->isEditing = !$this->isEditing;
}
// Écouter les événements
protected $listeners = [
'comment-{{ $comment->id }}-updated' => '$refresh',
];
}
2.4 Lazy Loading
{{-- Le composant se charge uniquement quand il devient visible --}}
<div wire:init="loadStats">
@if($readyToLoad)
<livewire:stats-panel />
@else
<div wire:loading>
Chargement des statistiques...
</div>
@endif
</div>
class StatsPanel extends Component
{
public bool $readyToLoad = false;
public function loadStats(): void
{
usleep(500000); // Simulation
$this->readyToLoad = true;
}
public function placeholder(): View
{
return view('livewire.placeholders.stats');
}
}
2.5 File Uploads
use Livewire\WithFileUploads;
class UploadPhoto extends Component
{
use WithFileUploads;
public $photo;
public string $status = '';
public function rules(): array
{
return [
'photo' => 'required|image|max:4096', // 4MB
];
}
public function save(): void
{
$this->validate();
$path = $this->photo->store('photos', 's3');
// Traitement
auth()->user()->update(['avatar' => $path]);
$this->status = 'Photo uploadée avec succès !';
$this->reset('photo');
}
public function render(): View
{
return view('livewire.upload-photo');
}
}
<form wire:submit="save">
<input type="file" wire:model="photo">
{{-- Preview --}}
@if ($photo)
<img src="{{ $photo->temporaryUrl() }}" class="w-32 h-32 object-cover">
@endif
{{-- Progress --}}
<div wire:loading wire:target="photo">
Téléchargement en cours...
</div>
@error('photo') <span class="error">{{ $message }}</span> @enderror
<button type="submit" wire:loading.attr="disabled">
Enregistrer
</button>
</form>
2.6 Validation temps réel
class CreatePost extends Component
{
public string $title = '';
public string $content = '';
public function rules(): array
{
return [
'title' => 'required|min:5|max:255',
'content' => 'required|min:20',
];
}
// Validation en temps réel
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
public function save(): void
{
$this->validate();
Post::create([
'title' => $this->title,
'content' => $this->content,
'user_id' => auth()->id(),
]);
session()->flash('message', 'Article créé !');
$this->redirect('/posts');
}
}
2.7 Events et Communication
// Émet un événement
class CommentForm extends Component
{
public function addComment(): void
{
$comment = Comment::create([...]);
// Événement système
$this->dispatch('comment-added', commentId: $comment->id);
// Événement vers un composant nommé
$this->dispatch('refresh-comments')->to(PostComments::class);
// Événement self (seulement ce composant)
$this->dispatch('$refresh');
// Événement à un parent
$this->dispatch('comment-count-updated')->up();
}
}
// Écoute
class PostShow extends Component
{
protected function getListeners(): array
{
return [
'comment-added' => 'onCommentAdded',
'echo:orders,OrderPlaced' => 'notifyNewOrder', // Reverb
];
}
public function onCommentAdded(array $params): void
{
$this->comments = $this->post->comments()->latest()->get();
}
}
2.8 Polling et Offline
{{-- Polling automatique toutes les 5 secondes --}}
<div wire:poll.5s="refreshNotifications">
@foreach($notifications as $notification)
<div>{{ $notification->message }}</div>
@endforeach
</div>
{{-- Visibilité conditionnelle --}}
<div wire:poll.visible="checkStatus">
{{-- Ne poll que si visible --}}
</div>
{{-- Mode offline --}}
<div wire:offline.class="opacity-50">
Contenu qui devient transparent hors-ligne
<div wire:offline>
Vous êtes hors-ligne. Les modifications seront synchronisées automatiquement.
</div>
<div wire:online>
Connecté
</div>
</div>
2.9 Computed Properties
use Livewire\Attributes\Computed;
class Cart extends Component
{
public array $items = [];
#[Computed]
public function total(): float
{
return array_reduce(
$this->items,
fn($carry, $item) => $carry + ($item['price'] * $item['quantity']),
0
);
}
#[Computed(persist: true)]
public function formattedTotal(): string
{
return number_format($this->total, 2) . ' €';
}
public function render(): View
{
return view('livewire.cart');
}
}
2.10 URL Binding
class SearchPosts extends Component
{
#[Url(as: 'q', history: true)]
public string $search = '';
#[Url]
public string $sort = 'latest';
public function render(): View
{
return view('livewire.search-posts', [
'posts' => Post::query()
->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->sort === 'latest', fn($q) => $q->latest())
->when($this->sort === 'popular', fn($q) => $q->orderBy('views', 'desc'))
->paginate(15),
]);
}
}
3. Alpine.js
3.1 Introduction
Alpine.js est un framework JavaScript minimaliste pour ajouter des comportements interactifs au HTML :
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
3.2 x-data et x-init
<div x-data="{ open: false, message: 'Hello Alpine!' }">
<button @click="open = !open">
<span x-text="open ? 'Fermer' : 'Ouvrir'"></span>
</button>
<div x-show="open" x-transition>
<p x-text="message"></p>
</div>
</div>
<!-- x-init pour initialisation -->
<div x-data="{ count: 0 }"
x-init="setInterval(() => count++, 1000)">
<span x-text="count"></span> secondes
</div>
3.3 x-on (Events)
<div x-data="{ count: 0, name: '' }">
<button x-on:click="count++">
Compteur : <span x-text="count"></span>
</button>
<!-- Syntaxe courte @ -->
<button @click="count = 0">Reset</button>
<!-- Modificateurs -->
<input @keydown.enter="submit()">
<input @keydown.escape="close()">
<div @click.away="open = false">Menu</div>
<div @click.once="loadData()">Click unique</div>
<button @click.prevent="submitForm()">Submit sans reload</button>
<button @click.stop="handleClick()">Arrête la propagation</button>
</div>
3.4 x-model (Two-Way Binding)
<div x-data="{ email: '', message: '' }">
<input type="email" x-model="email" placeholder="Email">
<span x-show="!email.includes('@') && email.length > 0" class="error">
Email invalide
</span>
<textarea x-model="message" placeholder="Votre message"></textarea>
<p x-text="`${email} : ${message}`"></p>
<!-- Modificateurs -->
<input x-model.lazy="name"> <!-- sur change, pas keyup -->
<input x-model.number="age"> <!-- convertit en nombre -->
<input x-model.debounce.500ms="search"> <!-- debounce 500ms -->
</div>
3.5 x-for (Loops)
<div x-data="{ items: ['Pomme', 'Banane', 'Cerise'] }">
<template x-for="(item, index) in items" :key="index">
<div>
<span x-text="`${index + 1}. ${item}`"></span>
<button @click="items.splice(index, 1)" class="text-red-500">✕</button>
</div>
</template>
<input type="text" x-model="newItem" @keydown.enter="items.push(newItem); newItem = ''">
</div>
3.6 x-transition (Animations)
<div x-data="{ show: false }">
<button @click="show = !show">Toggle</button>
<!-- Transitions de base -->
<div x-show="show"
x-transition.duration.500ms>
Apparaît/disparaît en 500ms
</div>
<!-- Transitions personnalisées -->
<div x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-90"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-90">
Animé avec Tailwind
</div>
</div>
3.7 x-teleport
<!-- Déplace le contenu dans un autre élément du DOM -->
<div x-data="{ open: false }">
<button @click="open = true">Ouvrir Modal</button>
<template x-teleport="body">
<div x-show="open"
class="fixed inset-0 bg-black/50 flex items-center justify-center"
@click.away="open = false">
<div class="bg-white p-8 rounded-lg">
<h2>Modal</h2>
<button @click="open = false">Fermer</button>
</div>
</div>
</template>
</div>
3.8 Composants Alpine réutilisables
<!-- Définition du composant -->
<div x-data="dropdown()" class="relative">
<button @click="toggle()" x-text="open ? 'Fermer' : 'Ouvrir'"></button>
<div x-show="open" @click.away="close()" x-transition>
<ul>
<li><a href="#">Option 1</a></li>
<li><a href="#">Option 2</a></li>
</ul>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() { this.open = !this.open },
close() { this.open = false },
}))
})
</script>
4. Volt & Folio
4.1 Volt — Composants Livewire en fichier unique
composer require livewire/volt
php artisan volt:install
<?php
// resources/views/pages/counter.blade.php
?>
<?php
use function Livewire\Volt\{state};
state(count: 0);
$increment = function () {
$this->count++;
};
?>
<div>
<h1>Compteur : {{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
4.2 Folio — File-based routing
composer require laravel/folio
php artisan folio:install
{{-- resources/views/pages/posts/index.blade.php --}}
<?php
use function Livewire\Volt\{state};
state(['search' => '']);
?>
<x-layout>
<x-slot:title>Articles</x-slot>
<input wire:model.live="search" placeholder="Rechercher..." />
@foreach(\App\Models\Post::where('title', 'like', "%{$search}%")->get() as $post)
<article>
<h2>{{ $post->title }}</h2>
</article>
@endforeach
</x-layout>
5. Reverb (WebSockets)
5.1 Installation
composer require laravel/reverb
php artisan reverb:install
5.2 Configuration
// config/reverb.php
'apps' => [
[
'app_id' => env('REVERB_APP_ID'),
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'host' => env('REVERB_HOST', 'localhost'),
'port' => env('REVERB_PORT', 8080),
'scheme' => env('REVERB_SCHEME', 'http'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
],
5.3 Broadcasting avec Livewire
// App\Models\Order
class Order extends Model
{
public function notifyStatusChange(): void
{
broadcast(new OrderStatusChanged($this));
}
}
// Event
class OrderStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('orders.' . $this->order->user_id),
];
}
}
// Livewire component écoute Reverb
class OrderStatus extends Component
{
public Order $order;
protected function getListeners(): array
{
return [
"echo-private:orders.{$this->order->user_id},OrderStatusChanged" => 'refreshOrder',
];
}
public function refreshOrder(): void
{
$this->order->refresh();
}
}
6. Résumé
L'écosystème Livewire + Alpine.js permet de construire des applications modernes sans framework JS lourd :
- Livewire 3 : composants full-stack, lazy loading, uploads, validation, events, polling
- Alpine.js : interactivité côté client légère (x-data, x-on, x-model, x-transition, x-teleport)
- Volt : composants Livewire en fichier unique (SFC)
- Folio : routing basé sur les fichiers (page-centric)
- Reverb : WebSockets temps réel natifs Laravel
L'architecture MPA (Multi-Page Application) avec Livewire offre la simplicité du serveur avec la réactivité du client.