Module 6 – Livewire et Eloquent
Niveau 5.1 – Laravel Livewire
Listes paginées, recherche et tri avec WithPagination, when() et sortBy(). Pièges courants (N+1, oubli de resetPage(), pagination hors de render()). En fin de module : requêtes optimisées, index BDD, listes très longues.
Objectif
À la fin de ce module, vous saurez :
- Afficher des listes issues d’Eloquent dans un composant Livewire et les paginer avec le trait WithPagination et la méthode paginate().
- Ajouter une recherche et des filtres (statut, catégorie, etc.) en reliant des propriétés au formulaire (wire:model) et en utilisant when() dans la requête Eloquent.
- Implémenter un tri (colonnes cliquables, ordre asc/desc) avec des propriétés $sortField et $sortDirection et une méthode sortBy().
- Optimiser les requêtes : éviter le N+1 avec with(), limiter les colonnes avec select(), et garder la logique dans render() ou une méthode dédiée.
Nous prenons l’exemple d’une liste d’articles (ou d’utilisateurs) avec recherche, tri et pagination.
Listes et pagination avec WithPagination
Livewire fournit le trait WithPagination qui s’intègre avec le système de pagination Laravel. Au lieu de gérer vous-même $page et les offsets, vous appelez paginate($perPage) dans render() ; Livewire conserve la page courante entre les requêtes et réinitialise la page à 1 quand les filtres changent (si vous appelez $this->resetPage() dans *updated()**).
Exemple de base :
<?php
namespace App\Livewire;
use App\Models\Post;
use Livewire\Component;
use Livewire\WithPagination;
class PostList extends Component
{
use WithPagination;
public string $search = '';
public function render()
{
$posts = Post::query()
->when($this->search, fn ($q) => $q->where('title', 'like', '%' . $this->search . '%'))
->orderBy('created_at', 'desc')
->paginate(10);
return view('livewire.post-list', ['posts' => $posts]);
}
}
- WithPagination : fournit la logique de page courante et la réinitialisation. La requête doit utiliser paginate() (pas get()).
- when($this->search, ...) : n’applique le filtre de recherche que si $search n’est pas vide.
- Dans la vue, vous affichez $posts->items() (ou parcourez $posts directement, car LengthAwarePaginator est itérable) et les liens de pagination avec $posts->links().
Vue Blade :
<div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Rechercher...">
<ul>
@foreach ($posts as $post)
<li>{{ $post->title }} — {{ $post->created_at->format('d/m/Y') }}</li>
@endforeach
</ul>
{{ $posts->links() }}
</div>
$posts->links() génère les liens « Précédent », « 1 », « 2 », « Suivant » au format Bootstrap ou Tailwind (selon votre config Laravel). Les clics sur ces liens déclenchent une requête Livewire avec la page demandée ; WithPagination met à jour la page et render() est rappelé avec la bonne page.
Tri : sortField et sortDirection
Pour un tableau triable (ex. tri par titre, date, statut), gardez deux propriétés : $sortField (nom du champ) et $sortDirection ('asc' ou 'desc'). Dans render(), utilisez orderBy($this->sortField, $this->sortDirection). Exposez une méthode sortBy($field) qui alterne la direction si on reclique sur la même colonne, sinon fixe la nouvelle colonne en asc.
Exemple :
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function render()
{
$posts = Post::query()
->when($this->search, fn ($q) => $q->where('title', 'like', '%' . $this->search . '%'))
->orderBy($this->sortField, $this->sortDirection)
->paginate(10);
return view('livewire.post-list', ['posts' => $posts]);
}
Vue : en-têtes de colonnes cliquables :
<th wire:click="sortBy('title')">Titre</th>
<th wire:click="sortBy('created_at')">Date</th>
Vous pouvez afficher une icône (flèche) selon $sortField et $sortDirection pour indiquer la colonne triée et le sens.
Réinitialiser la page quand les filtres changent
Quand l’utilisateur modifie la recherche ou le tri, il est logique de revenir à la page 1 (sinon on peut rester sur une page vide ou incohérente). Avec WithPagination, appelez $this->resetPage() dans updatedSearch(), updatedStatus(), ou sortBy() :
public function updatedSearch(): void
{
$this->resetPage();
}
resetPage() remet la page courante à 1 ; au prochain render(), la pagination repart de la première page.
Optimisation : éviter le N+1 et limiter les colonnes
Si votre liste affiche des relations (ex. auteur de l’article, catégorie), chargez-les en une fois avec with() pour éviter le problème N+1 (une requête par ligne pour charger la relation).
$posts = Post::query()
->with('user', 'category')
->when($this->search, fn ($q) => $q->where('title', 'like', '%' . $this->search . '%'))
->orderBy($this->sortField, $this->sortDirection)
->paginate(10);
Si la liste est lourde, vous pouvez aussi sélectionner uniquement les colonnes nécessaires avec select() :
->select('id', 'title', 'slug', 'status', 'created_at', 'user_id')
->with('user:id,name')
Cela réduit la quantité de données chargées et sérialisées.
Exemple complet : liste d’articles avec recherche, tri et pagination
Classe app/Livewire/ArticleList.php :
<?php
namespace App\Livewire;
use App\Models\Article;
use Livewire\Component;
use Livewire\WithPagination;
class ArticleList extends Component
{
use WithPagination;
public string $search = '';
public string $status = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
public function render()
{
$articles = Article::query()
->with('user:id,name')
->when($this->search, fn ($q) => $q->where('title', 'like', '%' . $this->search . '%'))
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->orderBy($this->sortField, $this->sortDirection)
->paginate(10);
return view('livewire.article-list', ['articles' => $articles]);
}
}
Vue (extrait) : champs de filtre, en-têtes triables, boucle et liens de pagination.
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Recherche">
<select wire:model.live="status">...</select>
<table>
<thead>
<tr>
<th wire:click="sortBy('title')">Titre</th>
<th wire:click="sortBy('created_at')">Date</th>
</tr>
</thead>
<tbody>
@foreach ($articles as $article)
<li>{{ $article->title }} — {{ $article->user->name }}</li>
@endforeach
</tbody>
</table>
{{ $articles->links() }}
Pièges à éviter
- N+1 : si dans la vue vous affichez $article->user->name pour chaque article sans avoir chargé la relation, Laravel fera une requête par ligne. Toujours ->with('user') (ou user:id,name) dans la requête du render().
- Oublier resetPage() : quand l’utilisateur change la recherche ou un filtre, la propriété $page (gérée par WithPagination) peut rester sur 5 alors que les nouveaux résultats ne font que 2 pages. La page affichée serait vide ou incohérente. Dans updatedSearch(), updatedStatus(), sortBy(), appelez $this->resetPage().
- Tri sur une colonne non indexée : orderBy($this->sortField) sur une grosse table sans index peut ralentir. En production, indexez les colonnes triées fréquemment.
- paginate() dans une méthode autre que render() : la pagination Livewire s’attend à ce que render() soit appelé et retourne la vue avec le résultat de paginate(). Ne pas paginer dans mount() et stocker le résultat dans une propriété (vous perdriez le lien avec WithPagination). Toujours paginer dans render().
Checklist liste paginée + filtres
- Trait WithPagination dans la classe ; paginate($perPage) dans render() (pas dans mount()).
- $articles->links() dans la vue pour les liens de pagination.
- Filtres (search, status) liés avec wire:model (debounce sur la recherche) ; when($this->search, ...) dans la requête.
- $this->resetPage() dans updatedSearch(), updatedStatus(), sortBy() pour revenir à la page 1 quand les critères changent.
- with('user') (ou les relations affichées) pour éviter le N+1 ;
wire:key="item-{{ $item->id }}"sur chaque ligne si la liste est dynamique (module 10).
Approfondissement
- Requêtes optimisées : pour des listes de 10–50 lignes, select('id', 'title', 'created_at', 'user_id') et with('user:id,name') réduisent la quantité de données et accélèrent render(). Pour des centaines de milliers de lignes, la pagination est obligatoire ; envisagez un cursor pagination (Laravel) si vous avez des problèmes de performance sur les grandes pages.
- Index en base : les where et orderBy sur des colonnes non indexées peuvent ralentir. Indexez search (ou la colonne de recherche), status, et les colonnes de tri pour des listes rapides.
- Scopes Eloquent : au lieu de répéter when($this->search, ...) dans plusieurs composants, définissez un scope sur le modèle :
Article::scopeFilter($query, $search, $status)et appelez Article::filter($this->search, $this->status)->orderBy(...)->paginate(...) dans render(). Réutilisable et lisible. - Export / impression : pour un bouton « Exporter en CSV » ou « Imprimer », vous pouvez soit ouvrir une nouvelle URL (route Laravel qui génère le fichier), soit appeler une méthode Livewire qui génère le fichier et renvoie un stream ou une redirect vers une route de téléchargement. Évitez de charger des milliers de lignes dans le composant ; privilégiez une requête dédiée côté serveur.
À retenir
- WithPagination + paginate($perPage) dans render() ; $posts->links() dans la vue ; resetPage() dans updated()* et sortBy() quand les filtres ou le tri changent.
- when($property, fn ($q) => ...) pour recherche et filtres conditionnels ; sortBy($field) + orderBy($this->sortField, $this->sortDirection) pour le tri.
- Optimisation : with('relation') contre le N+1 ; select() pour limiter les colonnes. Paginer uniquement dans render(), pas dans mount(). Éviter les colonnes non indexées pour le tri (index BDD).
Dans le prochain module, nous détaillons le cycle de vie et les hooks : mount(), updated(), updatedPropertyName(), hydrate() et dehydrate().