MFormations
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.