# Real-time Laravel

Broadcasting met Reverb en Echo: events van de server die zonder verversen in elke open browser landen.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, realtime, queues

Slides: <https://slides.mctantwerp.be/nl/the-agency/19-realtime>

<!-- slide 2 -->

## Agenda

1. Waarom realtime
2. Polling, SSE en WebSockets
3. Broadcasting
4. Kanalen
5. Echo in de frontend
6. Reverb in productie

> **Toelichting:** Het eerste deel gaat over kiezen: niet elke live update heeft WebSockets nodig. Daarna bouw je broadcasting op, van de server tot in je component, en je eindigt met wat er nodig is om het in productie te laten draaien.

<!-- slide 3 -->

## Na deze les kan je

- Kiezen tussen polling, server-sent events en WebSockets voor een feature, en uitleggen waarom
- Broadcasting met Reverb installeren en een event naar een private channel sturen
- In routes/channels.php bepalen wie op een kanaal mag luisteren
- Met useEcho in React of Vue live updates tonen, en met een presence channel wie er online is
- Uitleggen waarom een broadcast een queue worker nodig heeft, en zoeken waarom er niets aankomt
- Reverb in productie zetten achter nginx, met de juiste host en poort

> **Toelichting:** De opdracht aan het einde vraagt al deze vaardigheden samen: live comments met een lijst van wie online is. Wie dat kan, kan ook notificaties, dashboards en voortgangsbalken bouwen, want dat is telkens hetzelfde patroon.

<!-- slide 4 -->

## 1. Waarom realtime

> **Toelichting:** Klanten vragen vaak om iets dat live werkt, zonder te weten wat het kost. Daarom begint deze les bij de vraag of het nodig is. Een pagina die zichzelf bijwerkt, betekent een extra proces op de server, iets extra dat kan uitvallen en iets extra om te testen.

<!-- slide 5 -->

### Waar je het tegenkomt

- **Notificaties**: Een nieuwe opdracht, een reactie op je comment. De bel krijgt een 1, zonder dat je ververst.
- **Dashboards**: Orders die binnenlopen op het scherm in de winkel van een bakker.
- **Samen werken**: Wie heeft dit project open, wie typt er. Twee mensen die elkaars werk niet overschrijven.
- **Lange jobs**: De export draait twee minuten op de queue. De knop Download verschijnt als hij klaar is.

> **Toelichting:** Wat deze vier gemeen hebben: de server weet iets eerder dan de browser, en de gebruiker wil het weten zonder zelf te verversen. Een chat is het bekendste voorbeeld, maar in agency-projecten komt die minder vaak voor dan notificaties en dashboards.

<!-- slide 6 -->

### Wanneer niet

- De meeste pagina’s hebben het niet nodig. Na een save is een redirect naar de lijst prima.
- Data die eens per uur verandert, haal je gewoon op als de pagina laadt
- Een WebSocket-server is een extra proces, met zijn eigen deploy en monitoring
- Vraag de klant hoe snel het moet. Binnen de minuut is polling. Binnen de seconde, voor veel mensen tegelijk, is broadcasting.

> **Toelichting:** Een tussenvorm is verversen bij terugkeer: komt de gebruiker terug naar het tabblad, dan haal je de data opnieuw op. De browser geeft je daarvoor het visibilitychange-event. Voor veel dashboards is dat genoeg.

<!-- slide 7 -->

## 2. Polling, SSE en WebSockets

> **Toelichting:** Er zijn drie manieren om data van de server naar de browser te krijgen zonder dat de gebruiker ververst. Ze verschillen in hoe lang een verbinding open blijft, en in welke richting er data gaat. Die twee vragen bepalen wat je server moet kunnen.

<!-- slide 8 -->

### Drie manieren

#### Polling

Al iets nieuws?

De browser vraagt het om de paar seconden. Gewone HTTP, met veel lege antwoorden.

#### Server-sent events

Ik stuur wel.

Eén request die open blijft. De server schrijft erin, alleen in die richting.

#### WebSockets

We praten.

Een verbinding die begint als HTTP en daarna open blijft, in twee richtingen. Een aparte server houdt ze vast.

> **Toelichting:** Er bestaat ook long polling: de server houdt een gewone request open tot er iets nieuws is, en de browser vraagt daarna meteen opnieuw. Zo deed men het voor WebSockets bestonden, en sommige libraries vallen er nog op terug als een WebSocket niet lukt.

<!-- slide 9 -->

### Polling

```js
setInterval(async () => {
    const response = await fetch('/orders/open')

    renderOrders(await response.json())
}, 5000)
```

Elke vijf seconden een gewone GET. Honderd open tabbladen is twintig requests per seconde, ook om drie uur 's nachts als er niets verandert.

> [!TIP]
> Mag het binnen de minuut en zijn het een paar gebruikers, zoals bij een intern dashboard? Dan is polling gewoon de juiste keuze.

> **Toelichting:** Met Inertia herlaad je alleen een deel van de props met router.reload({ only: ['orders'] }). Stop met pollen als het tabblad verborgen is, met het visibilitychange-event: dan vallen de meeste nutteloze requests weg.

<!-- slide 10 -->

### Server-sent events

```php
Route::get('/exports/{export}/progress', function (Export $export) {
    return response()->eventStream(function () use ($export) {
        while ($export->refresh()->isRunning()) {
            yield new StreamedEvent(event: 'update', data: $export->progress);

            sleep(1);
        }
    });
});
```

1. `eventStream()` geeft een response met type `text/event-stream` die open blijft tot de functie klaar is.
2. Elke yield is een bericht. Zolang de export loopt, stuurt de route elke seconde de voortgang.

> **Toelichting:** Een event stream is gewone HTTP, dus hij gaat door elke proxy en firewall. Achter nginx moet buffering uit staan voor zo'n route, anders houdt nginx de berichten vast tot de stream klaar is. Laravel stuurt voor streamed responses zelf een header mee die dat regelt.

<!-- slide 11 -->

### In de browser

```js
const source = new EventSource(`/exports/${exportId}/progress`)

source.addEventListener('update', (event) => showProgress(event.data))
```

`EventSource` zit in elke browser. Als de stream klaar is, stuurt Laravel nog een laatste bericht `</stream>`. Voor tekst die binnendruppelt, zoals een AI-antwoord, is er `useEventStream` in `@laravel/stream-react` en `@laravel/stream-vue`.

> [!WARNING]
> Elke open stream houdt een PHP-worker bezet zolang hij loopt. Zie de [docs over event streams](https://laravel.com/docs/13.x/responses#event-streams).

> **Toelichting:** EventSource verbindt vanzelf opnieuw als de verbinding wegvalt, na een paar seconden. Bij een live feed is dat handig, maar bij een stream die klaar is wil je dat net niet. Sluit hem daarom met source.close() als het laatste bericht binnen is.

<!-- slide 12 -->

### Waarom een aparte server

- PHP-FPM geeft elke request een eigen worker, tot het antwoord weg is
- Een open verbinding per bezoeker houdt dus een worker bezet, en een server heeft er meestal een paar tientallen
- Server-sent events voor een handvol gebruikers gaat. Voor honderd open tabbladen niet.
- Reverb is een apart PHP-proces met een event loop (ReactPHP). Het start niet per request, maar blijft lopen en houdt duizenden sockets tegelijk vast.

> **Toelichting:** ReactPHP is een library voor asynchrone PHP: één proces wacht op veel verbindingen tegelijk en reageert op wat er binnenkomt. Node.js werkt op dezelfde manier. Daarom kan Reverb duizenden sockets aan, waar PHP-FPM er een paar tientallen heeft.

<!-- slide 13 -->

### Een AI-antwoord verschijnt woord per woord op het scherm van de gebruiker die de vraag stelde. Wat past het best?

- A. Polling elke seconde
- B. Server-sent events
- C. WebSockets met Reverb
- D. Een mail als het klaar is

**Antwoord:** B. Server-sent events

Eén gebruiker, één richting, en het stopt als het antwoord klaar is: precies een event stream. WebSockets lijken het veiligste antwoord omdat ze alles kunnen, maar dan zet je een aparte server en een kanaal op voor iets dat maar één browser moet zien.

> **Toelichting:** De meeste AI-API's streamen hun antwoord op exact deze manier. Moeten meerdere mensen hetzelfde antwoord live zien binnenkomen, zoals in een gedeelde chat, dan kom je wel weer bij broadcasting uit.

<!-- slide 14 -->

## 3. Broadcasting

> **Toelichting:** Broadcasting is de Laravel-manier om een event op de server naar browsers te sturen. Het voordeel: je schrijft gewone events, zoals je ze al kent, en Laravel doet de rest. De prijs: twee extra processen naast je webserver.

<!-- slide 15 -->

### Van event tot browser

Drie mensen hebben hetzelfde project open en Jan schrijft een comment. Kijk bij elke stap welk van de drie processen aan het werk is.
1. Elke browser opent één WebSocket naar Reverb en abonneert zich op het kanaal van het project.
2. Jan post een comment. Dat is een gewone HTTP-request naar Laravel.
3. Laravel bewaart de comment en dispatcht CommentPosted. Omdat het ShouldBroadcast is, wordt het een job op de queue.
4. De queue worker pikt de job op en geeft het event door aan Reverb.
5. Reverb pusht het naar elke socket op het kanaal. Niemand hoeft te verversen.
6. Met toOthers() slaat Reverb de socket van Jan over. Die heeft de comment al uit de response.

> **Toelichting:** Het schema toont Reverb, maar Pusher en Ably werken op dezelfde manier: Laravel stuurt het event naar een WebSocket-dienst, en die pusht het naar de browsers. Reverb spreekt hetzelfde protocol als Pusher. Daarom gebruikt Echo ook voor Reverb de library pusher-js.

<!-- slide 16 -->

### Drie processen

1. **Laravel**: De webserver die de request van Jan afhandelt, de comment opslaat en het event dispatcht
2. **De queue worker**: Neemt de broadcast van de queue en geeft hem aan Reverb, zodat de request van Jan niet hoeft te wachten
3. **Reverb**: Houdt de WebSockets van alle browsers open en pusht het event naar wie op het kanaal zit

> [!WARNING]
> Valt er één van de drie weg, dan komt er niets aan. Zonder foutmelding, want voor Laravel is het event gewoon verstuurd.

> **Toelichting:** Lokaal start composer run dev ze alle drie. In productie beheert Supervisor, Forge of Laravel Cloud elk proces apart, en elk kan apart crashen. Monitoring die alleen je website checkt, merkt een gestopte queue worker of Reverb niet op.

<!-- slide 17 -->

### Installeren

```console
$ php artisan install:broadcasting --reverb
$ composer run dev
```

`install:broadcasting` maakt `config/broadcasting.php` en `routes/channels.php`, installeert Reverb en zet de keys in `.env`. Daarna start `composer run dev` ook Reverb, naast de server, de queue listener en Vite.

Start je de processen apart, dan zijn het `php artisan reverb:start` en `php artisan queue:work`, elk in een eigen terminal. Reverb luistert lokaal op poort 8080. Zie de [Reverb-docs](https://laravel.com/docs/13.x/reverb).

> **Toelichting:** Zonder --reverb vraagt het commando welke driver je wil, met ook Pusher, Ably en Mercure als keuze. De keys in .env zijn willekeurig gegenereerd. Lokaal maakt de waarde niet uit, zolang de VITE-variabelen naar dezelfde verwijzen.

<!-- slide 18 -->

### In .env

- ```ini
  BROADCAST_CONNECTION=reverb

  REVERB_APP_ID=482913
  REVERB_APP_KEY=hq2kx8vqjz1lmd0e
  REVERB_APP_SECRET=q1w2e3r4t5y6u7i8
  REVERB_HOST=localhost
  REVERB_PORT=8080
  REVERB_SCHEME=http

  VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
  VITE_REVERB_HOST="${REVERB_HOST}"
  ```
- De app key is geen geheim. Echo heeft hem nodig om te verbinden, dus hij zit in je JavaScript.

  De secret blijft op de server. Daarmee ondertekent Laravel de toegang tot private kanalen, en praat het met Reverb.

  `VITE_`-variabelen gaan mee in de build. Pas je er een aan, draai dan opnieuw `npm run build`.

> **Toelichting:** REVERB_APP_ID, KEY en SECRET bestaan omdat één Reverb-server meerdere apps kan bedienen, zoals een Pusher-account. Voor één project zijn het drie willekeurige waarden. Genereer voor productie nieuwe, zodat een key van je laptop nooit op de server werkt.

<!-- slide 19 -->

### Het event

*app/Events/CommentPosted.php*

```php
class CommentPosted implements ShouldBroadcast
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

    public function __construct(public Comment $comment) {}
}
```

Een gewoon event met één interface erbij: `ShouldBroadcast`. De comment komt binnen via de constructor. `InteractsWithSockets` heb je nodig voor `toOthers()`, een paar slides verder.

> **Toelichting:** Maak het event met php artisan make:event CommentPosted en voeg de interface zelf toe. SerializesModels zet de comment als id op de queue, en haalt hem bij het uitvoeren opnieuw uit de database. Is hij intussen verwijderd, dan faalt de job.

<!-- slide 20 -->

### Het kanaal

```php
public function broadcastOn(): array
{
    return [new PrivateChannel("projects.{$this->comment->project_id}")];
}
```

`broadcastOn()` zegt op welk kanaal het event terechtkomt. Hier een `PrivateChannel` met het id van het project in de naam: elk project krijgt zijn eigen kanaal, en `routes/channels.php` beslist wie erop mag.

Zie [Defining broadcast events](https://laravel.com/docs/13.x/broadcasting#defining-broadcast-events).

> **Toelichting:** broadcastOn() kan meerdere kanalen teruggeven, bijvoorbeeld het project en de persoonlijke notificaties van de eigenaar. De naam van een kanaal kies je zelf. Hou dezelfde stijl aan als in je routes: meervoud, een punt, en het id.

<!-- slide 21 -->

### Versturen

```php
$comment = $project->comments()->create($request->validated());

CommentPosted::dispatch($comment);

broadcast(new CommentPosted($comment))->toOthers();
```

1. De comment wordt opgeslagen, zoals altijd.
2. Een broadcast-event dispatchen is genoeg: het gaat op de queue en daarna naar iedereen op het kanaal.
3. Het alternatief: met `broadcast()` en `toOthers()` krijgt iedereen het, behalve de browser die de request deed. Je kiest één van de twee.

> **Toelichting:** Voor een snelle broadcast zonder eigen class is er Broadcast::on('projects.4')->as('CommentPosted')->with($data)->send(). Een eigen event-class is wel makkelijker te testen: met Event::fake() check je in Pest of het event verstuurd werd, zie de les over fakes.

<!-- slide 22 -->

### toOthers

Zonder `toOthers()` krijgt Jan zijn comment twee keer: één keer uit de response van zijn POST, en één keer uit de broadcast. Dan staat hij dubbel in zijn lijst.

Echo zet op elke request van een globale Axios een header `X-Socket-ID` met het id van jouw WebSocket. `toOthers()` leest die header en vraagt Reverb om die ene socket over te slaan.

> [!NOTE]
> Gebruik je `fetch` in plaats van Axios, dan zet je de header zelf. `useSocketId()` uit `@laravel/echo-react` geeft je het id.

> **Toelichting:** Een andere oplossing is optimistisch: de browser toont de comment meteen, nog voor het antwoord er is, en negeert een broadcast met een id dat al in de lijst staat. Dat is robuuster, want dan maakt de volgorde van response en broadcast niet meer uit.

<!-- slide 23 -->

### Wat er meegaat

```php
public function broadcastWith(): array
{
    return [
        'id' => $this->comment->id,
        'body' => $this->comment->body,
        'author' => $this->comment->user->name,
    ];
}
```

Zonder `broadcastWith()` gaan alle public properties mee: hier het hele Comment-model, met elke kolom die niet in hidden staat. Met deze methode kies je zelf wat de pagina krijgt.

> [!WARNING]
> Elke browser op het kanaal ziet de volledige payload in de Network-tab. Wat er niet in hoort, stuur je niet mee.

> **Toelichting:** broadcastAs() geeft het event een andere naam dan de class. In de frontend luister je dan met een punt ervoor, zoals '.comment.posted', omdat Echo anders App.Events voor de naam zet. Handig als je de naam los van je PHP-class wil houden.

<!-- slide 24 -->

### De queue ertussen

- `ShouldBroadcast` maakt van de broadcast een job, zodat de request niet op Reverb hoeft te wachten
- Draait er geen worker, dan komt er niets aan. Het event staat dan in de jobs-tabel te wachten. Workers en supervisor staan in de les over [queues](https://laravel.com/docs/13.x/queues).
- `ShouldBroadcastNow` slaat de queue over en broadcast tijdens de request. Handig in een demo, niet onder load.
- Dispatch je in een database-transactie? Zet er `ShouldDispatchAfterCommit` bij, anders komt het event aan voor de rij in de database staat.

> **Toelichting:** Welke queue een broadcast gebruikt, zet je met de attributen Connection en Queue op de event-class, of met een methode broadcastQueue(). Handig als je broadcasts niet achter een trage export wil laten wachten.

<!-- slide 25 -->

### Reverb draait, Echo is verbonden, de controller dispatcht het event zonder fout. Toch komt er niets aan. Wat kijk je eerst na?

- A. Of de browser WebSockets ondersteunt
- B. Of er een queue worker draait
- C. Of APP_DEBUG aan staat
- D. Of het event een broadcastWith heeft

**Antwoord:** B. Of er een queue worker draait

Een ShouldBroadcast-event is een job. Zonder worker blijft het op de queue en ziet Reverb het nooit. Een ontbrekende broadcastWith lijkt verdacht, maar dan gaan gewoon alle public properties mee: het event komt wel aan.

> **Toelichting:** In de terminal van composer run dev toont de queue listener elke job. Staat je event daar niet, dan kwam het nooit op de queue: kijk dan of de class ShouldBroadcast implementeert. Staat hij er als FAIL, dan vind je de fout in storage/logs.

<!-- slide 26 -->

## 4. Kanalen

> **Toelichting:** Een kanaal is de plek waar een event terechtkomt, en wie op dat kanaal zit, krijgt het. Daarom is dit hoofdstuk ook security: een kanaal zonder controle is een lek, en de controle schrijf je zelf.

<!-- slide 27 -->

### Drie soorten kanalen

|   | In PHP | Wie mag luisteren | Voor |
| --- | --- | --- | --- |
| **Public** | `Channel` | Iedereen | Een publieke teller, een live scorebord |
| **Private** | `PrivateChannel` | Wie `true` krijgt in channels.php | Het project van je team, je notificaties |
| **Presence** | `PresenceChannel` | Idem, en iedereen ziet wie er is | Wie heeft dit project open |

Reverb zet er een prefix voor: `private-projects.4`, `presence-projects.4.viewers`. Die prefix schrijf je nooit zelf, Echo en de channel-classes doen dat.

> **Toelichting:** Een kanaal zonder prefix is publiek, ook als de naam moeilijk te raden is. Pusher kent daarnaast encrypted private channels, waar de payload versleuteld is zodat de dienst zelf hem niet kan lezen. Met je eigen Reverb-server heb je die extra laag zelden nodig.

<!-- slide 28 -->

### Wie mag luisteren

*routes/channels.php*

```php
Broadcast::channel('projects.{project}', function (User $user, Project $project) {
    return $user->can('view', $project);
});
```

De callback krijgt de ingelogde user en, met model binding zoals in je routes, het project. `true` betekent: mag luisteren. Door de policy te hergebruiken staat de regel wie een project mag zien op één plek.

> [!WARNING]
> Een private channel is toegangscontrole. Vergeet je de check, dan heb je een IDOR over een WebSocket. Zie [Authorizing channels](https://laravel.com/docs/13.x/broadcasting#authorizing-channels).

> **Toelichting:** Voor grotere apps maak je een channel-class met php artisan make:channel ProjectChannel, met een methode join(). Die registreer je met Broadcast::channel('projects.{project}', ProjectChannel::class). De callback krijgt standaard de user van de web-guard; met de optie guards kies je een andere.

<!-- slide 29 -->

### Een private channel openen

1. Browser → Reverb, **WebSocket openen**: Echo verbindt één keer met Reverb en krijgt een socket id.
2. Browser → Laravel, **POST /broadcasting/auth**: Met de socket id, de naam `private-projects.4` en je sessie-cookie. Laravel weet dus wie je bent.
3. Laravel, **routes/channels.php**: Je callback draait. `false` geeft een 403, `true` een handtekening, gemaakt met de `REVERB_APP_SECRET`.
4. Laravel → Browser, **Handtekening**: De browser krijgt de handtekening terug als antwoord op de POST.
5. Browser → Reverb, **Subscribe**: Reverb controleert de handtekening met dezelfde secret. Klopt ze, dan zit je op het kanaal.

> **Toelichting:** De route /broadcasting/auth registreert Laravel via channels in withRouting() in bootstrap/app.php. Heb je een aparte frontend op een ander domein, met Sanctum-tokens, dan moet Echo zich anders aanmelden. Dat staat in de broadcasting-docs onder customizing the authorization.

<!-- slide 30 -->

### Wie is er online

*routes/channels.php*

```php
Broadcast::channel('projects.{project}.viewers', function (User $user, Project $project) {
    if ($user->cannot('view', $project)) {
        return false;
    }

    return ['id' => $user->id, 'name' => $user->name];
});
```

1. Eerst dezelfde check als bij een private channel. Mag de user het project niet zien, dan komt hij er niet op.
2. Bij een presence channel geef je geen `true` terug, maar wat de anderen over jou te zien krijgen. Alleen wat je op het scherm toont: id en naam, geen e-mail.

> **Toelichting:** Een presence channel werkt alleen voor ingelogde users, want je moet iemand kunnen tonen. Wil je gasten tellen, zoals twaalf mensen bekijken dit product, dan heb je iets anders nodig, bijvoorbeeld een teller in Redis.

<!-- slide 31 -->

### Here, joining, leaving

Jan, Fatima en Lotte openen na elkaar hetzelfde project. Kijk in elke browser welke callback draait en hoe de lijst verandert.
1. Jan komt binnen. channels.php geeft zijn id en naam, en here() geeft de lijst: alleen hijzelf.
2. Fatima komt erbij. Zij krijgt here() met Jan en zichzelf, bij Jan draait joining().
3. Lotte komt erbij, en de andere twee krijgen joining().
4. Fatima sluit haar tab. Reverb merkt dat de socket weg is, en de anderen krijgen leaving().

> **Toelichting:** Dezelfde persoon in twee tabbladen staat maar één keer in de lijst: Reverb groepeert op het id dat je in channels.php teruggeeft. Pas als beide tabs dicht zijn, krijgen de anderen leaving(). Daarom moet dat id uniek zijn per user.

<!-- slide 32 -->

### Wat de drie callbacks doen

- `here(users)` draait één keer, als je binnenkomt, met iedereen die er al is. Jezelf inbegrepen.
- `joining(user)` draait bij iedereen die er al is, telkens iemand binnenkomt
- `leaving(user)` draait als iemand vertrekt of zijn tab sluit. Reverb merkt dat de socket weg is.
- Elke browser houdt zijn eigen lijst bij met die drie. De server bewaart geen lijst die je kan opvragen.

> **Toelichting:** Voor een melding als 'Jan typt...' bestaat whisper: een bericht van browser naar browser over het kanaal, zonder langs Laravel te gaan. Zie Client events in de broadcasting-docs.

<!-- slide 33 -->

### Kanalen zijn toegangscontrole

- Een public channel kan iedereen beluisteren die je app key heeft, en die staat in je JavaScript
- `channels.php` is een policy. Test hem met een tweede account, zoals elke andere route.
- Stuur ids en wat de pagina nodig heeft, geen volledige modellen
- Wie meer nodig heeft, haalt het op via een gewone route met een policy. Zie ook de les over security.

> **Toelichting:** In de Network-tab van je browser zie je onder WS de WebSocket-verbinding, met elk bericht dat binnenkomt. Kijk er in je eigen project eens naar: elke payload op elk kanaal waar je op zit, staat er leesbaar in. Precies wat een nieuwsgierige gebruiker ook ziet.

<!-- slide 34 -->

### Je broadcast nieuwe facturen op new Channel('invoices'). Wie kan ze lezen?

- A. Alleen ingelogde users
- B. Alleen admins, via channels.php
- C. Iedereen die de app key uit je JavaScript haalt
- D. Niemand, Reverb versleutelt alles

**Antwoord:** C. Iedereen die de app key uit je JavaScript haalt

Een Channel is publiek: er is geen autorisatie, en de app key staat in je bundle. 'Alleen ingelogde users' klinkt logisch, maar Reverb weet niet wie ingelogd is. Dat vraagt het pas aan Laravel bij een private of presence channel.

> **Toelichting:** Ook een moeilijk te raden naam, zoals invoices-8f3a2c, maakt een kanaal niet privé. Iemand die de naam één keer in de devtools ziet, kan zich blijven abonneren. Een geheime naam is geen autorisatie.

<!-- slide 35 -->

## 5. Echo in de frontend

> **Toelichting:** Echo is de brug tussen Reverb en je componenten. Met de hooks voor React en Vue hoef je niet zelf te verbinden, kanalen te verlaten of listeners op te ruimen als een component verdwijnt. Dat opruimen vergeten is de klassieke bug: dan komen berichten dubbel binnen.

<!-- slide 36 -->

### configureEcho

```console
$ npm install --save-dev @laravel/echo-react
```

*resources/js/app.tsx*

```ts
import { configureEcho } from '@laravel/echo-react'

configureEcho({
    broadcaster: 'reverb',
})
```

Eén keer, bovenaan je app, voor de eerste component die `useEcho` gebruikt. Zonder extra opties leest hij de `VITE_REVERB`-variabelen. In Vue is het exact hetzelfde, uit `@laravel/echo-vue`. Zie [Client side installation](https://laravel.com/docs/13.x/broadcasting#client-side-installation).

> **Toelichting:** Werk je met Blade en Alpine in plaats van React of Vue, dan gebruik je het package laravel-echo met new Echo({...}) op window.Echo, zoals in de broadcasting-docs. De hooks gebruiken achter de schermen precies die library.

<!-- slide 37 -->

### Luisteren in React

```tsx
import { useEcho } from '@laravel/echo-react'

export function Comments({ project, initial }: CommentsProps) {
    const [comments, setComments] = useState(initial)

    useEcho<CommentPayload>(`projects.${project.id}`, 'CommentPosted', (comment) => {
        setComments((current) => [...current, comment])
    })

    return <CommentList comments={comments} />
}
```

`useEcho` opent een private channel, luistert naar `CommentPosted` en ruimt alles op als de component verdwijnt. De comment in de callback is wat `broadcastWith` teruggeeft.

> **Toelichting:** useEcho luistert standaard op een private channel. Voor een public channel gebruik je useEchoPublic, voor presence useEchoPresence. Met useConnectionStatus() toon je een melding als de verbinding wegvalt.

<!-- slide 38 -->

### Luisteren in Vue

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { useEcho } from '@laravel/echo-vue'

const props = defineProps<{ project: Project; initial: CommentPayload[] }>()
const comments = ref(props.initial)

useEcho<CommentPayload>(`projects.${props.project.id}`, 'CommentPosted', (comment) => {
    comments.value.push(comment)
})
</script>
```

Dezelfde hook met dezelfde argumenten. Aan de Laravel-kant verandert er niets, of je team nu React of Vue gebruikt.

> **Toelichting:** In Vue ruimt useEcho op als de component unmount, net als in React. Zonder Inertia, in een aparte SPA, werkt het ook, maar dan moet de aanmelding bij /broadcasting/auth met Sanctum gebeuren.

<!-- slide 39 -->

### Presence in React

```tsx
const [online, setOnline] = useState<Viewer[]>([])
const { channel } = useEchoPresence(`projects.${project.id}.viewers`)

useEffect(() => {
    channel()
        ?.here((users: Viewer[]) => setOnline(users))
        .joining((user: Viewer) => setOnline((current) => [...current, user]))
        .leaving((user: Viewer) => setOnline((current) => current.filter(({ id }) => id !== user.id)))
}, [channel])
```

`useEchoPresence` zonder event geeft je het kanaal. `channel()` kan `null` zijn voor de verbinding er is, daarom het vraagteken. `here`, `joining` en `leaving` houden de lijst bij, zoals in het schema van daarnet.

> **Toelichting:** useEchoPresence kan ook meteen naar events luisteren: geef dan een eventnaam en een callback mee, zoals bij useEcho. Het type Viewer schrijf je zelf, met wat channels.php teruggeeft: hier id en name.

<!-- slide 40 -->

### Model broadcasting

- *app/Models/Project.php*

  ```php
  class Project extends Model
  {
      use BroadcastsEvents;

      public function broadcastOn(string $event): array
      {
          return [$this];
      }
  }
  ```
- *React*

  ```tsx
  useEchoModel<Project, 'App.Models.Project'>(
      'App.Models.Project',
      project.id,
      ['ProjectUpdated'],
      (event) => setProject(event.model),
  )
  ```

De trait stuurt created, updated en deleted zelf door, als `ProjectCreated`, `ProjectUpdated` en zo verder, op het private channel `App.Models.Project.4`. In channels.php zet je daar een regel voor. Handig voor een status die verandert. Een nieuwe comment verdient een eigen event.

> **Toelichting:** useEchoModel controleert de eventnamen met TypeScript: voor App.Models.Project mag je alleen Project-events gebruiken, zoals ProjectUpdated. Een event van een ander model op dat kanaal beluister je met useEcho en een punt voor de naam, zoals '.CommentCreated'.

<!-- slide 41 -->

### Als er niets aankomt

- Draait de queue worker? Zonder worker blijft elke broadcast op de queue staan.
- Draait Reverb? Start hem met `--debug` en je ziet elk bericht dat passeert.
- Open de Network-tab en zoek `/broadcasting/auth`. Een 403 betekent dat channels.php nee zei.
- Gebruik je `broadcastAs()`, zet dan een punt voor de naam in de frontend: `'.comment.posted'`.
- Een `VITE_`-variabele aangepast? Dan moet Vite herstarten of de build opnieuw.

> **Toelichting:** Kijk ook in de console van je browser: Echo meldt daar als de verbinding met Reverb mislukt, bijvoorbeeld door een verkeerde poort of host. Draai Reverb met --debug en open de pagina: bij elke subscribe hoort er een regel te verschijnen.

<!-- slide 42 -->

### Jan ziet zijn eigen comment twee keer in de lijst. Wat is de fix?

- A. broadcast(...)->toOthers() in plaats van gewoon dispatchen
- B. ShouldBroadcastNow in plaats van ShouldBroadcast
- C. Een PresenceChannel in plaats van een PrivateChannel
- D. De comment niet meer uit de response halen

**Antwoord:** A. broadcast(...)->toOthers() in plaats van gewoon dispatchen

Jan krijgt de comment uit zijn eigen response én uit de broadcast. toOthers() slaat zijn socket over. De comment uit de response weglaten lijkt ook te werken, maar dan ziet Jan zijn comment pas als de queue en Reverb klaar zijn, en helemaal niet als een van de twee plat ligt.

> **Toelichting:** De volgorde van response en broadcast ligt niet vast. Meestal is de response eerst, maar met een snelle queue kan de broadcast eerst aankomen. Code die op de volgorde rekent, faalt dan af en toe, en zo'n bug is lastig te vinden.

<!-- slide 43 -->

## 6. Reverb in productie

> **Toelichting:** Lokaal start composer run dev alles. In productie moet elk proces apart ingesteld, bewaakt en na een deploy herstart worden. Een Reverb-server die stilletjes gestopt is, valt niemand op tot een klant meldt dat er niets meer live gebeurt.

<!-- slide 44 -->

### Een proces dat blijft draaien

- `reverb:start` draait tot je hem stopt, net als een queue worker
- Supervisor, Forge of Laravel Cloud houdt hem in leven en start hem opnieuw als hij stopt
- Na een deploy draai je `php artisan reverb:restart`. Die sluit alle verbindingen netjes, Supervisor start Reverb opnieuw en Echo verbindt vanzelf terug.
- Lokaal zie je met `--debug` elk bericht dat passeert

```console
$ php artisan reverb:start --debug
$ php artisan reverb:restart
```

> **Toelichting:** Voor Supervisor volstaat een blok met command=php artisan reverb:start, autorestart=true en de juiste user. Zet minfds hoog genoeg, zie de slide over veel verbindingen. Op Forge zet je Reverb aan bij de site, of je maakt er een daemon voor.

<!-- slide 45 -->

### Twee keer host en poort

- *waar Reverb luistert*

  ```ini
  REVERB_SERVER_HOST=0.0.0.0
  REVERB_SERVER_PORT=8080
  ```
- *waar de wereld hem vindt*

  ```ini
  REVERB_HOST=ws.klant.be
  REVERB_PORT=443
  REVERB_SCHEME=https
  ```

nginx neemt https op poort 443 aan en stuurt het door naar Reverb op 8080. Reverb zelf doet geen TLS. De `SERVER`-variabelen zijn voor Reverb, de andere voor Laravel en de browser.

> [!WARNING]
> Stel: je laat REVERB_PORT op 8080 staan in productie. Dan probeert de browser `wss://ws.klant.be:8080`, en daar luistert niemand.

> **Toelichting:** Je kan Reverb op een eigen subdomein zetten, zoals ws.klant.be, of op hetzelfde domein onder een pad. Een eigen subdomein is eenvoudiger in nginx, maar vraagt een extra certificaat.

<!-- slide 46 -->

### nginx als proxy

```text
location / {
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_pass http://0.0.0.0:8080;
}
```

Een WebSocket begint als HTTP-request met een `Upgrade`-header. nginx moet die doorgeven, anders blijft het een gewone request. Het volledige blok staat in de [Reverb-docs](https://laravel.com/docs/13.x/reverb#web-server). Op Forge staat het goed zodra je Reverb aanzet.

> **Toelichting:** Reverb luistert op /app voor WebSockets en op /apps voor de API waarmee Laravel berichten aflevert. Beide paden moeten door de proxy. De volledige config in de docs zet ook headers als X-Forwarded-For, zodat Reverb het echte IP van de bezoeker ziet.

<!-- slide 47 -->

### Waar het draait

|   | Wat je zelf doet |
| --- | --- |
| **Laravel Cloud** | Niets. Beheerde Reverb-clusters, die meeschalen. |
| **Forge** | Reverb aanzetten op de site. nginx en de daemon zijn dan goed ingesteld. |
| **Eigen VPS** | nginx als reverse proxy, Supervisor, en de limieten van het OS |
| **Pusher of Ably** | Geen eigen server. Je betaalt per bericht en per verbinding, met `BROADCAST_CONNECTION=pusher`. |

Met Pusher of Ably blijven je events en je Echo-code hetzelfde. Een eigen VPS is leerrijk, maar dan ben jij de beheerder als het om middernacht plat ligt.

> **Toelichting:** Pusher en Ably hebben een gratis plan met een limiet op verbindingen en berichten per dag. Voor een klein intern tool kan dat volstaan. Reken bij een publieke site wel uit wat het kost met veel bezoekers.

<!-- slide 48 -->

### Veel verbindingen

- Elke verbinding is een open bestand. Check `ulimit -n` op de server en `minfds` in Supervisor.
- De standaard event loop stopt rond 1024 open bestanden. Installeer `ext-uv` en Reverb schakelt zelf over.
- Meer dan één Reverb-server: `REVERB_SCALING_ENABLED=true` en een gedeelde Redis, achter een load balancer
- Pulse telt verbindingen en berichten, met de recorders van Reverb
- Voor een intern tool van een klant kom je hier nooit aan. Voor een publieke site met een live teller wel.

> **Toelichting:** Een open verbinding kost weinig geheugen, maar niet niets. Met de recorders van Reverb in Pulse zie je hoeveel verbindingen er echt zijn, en dat zijn er meestal minder dan je vreest. Meet eerst, schaal daarna.

<!-- slide 49 -->

### In productie draait Reverb op poort 8080, achter nginx op https://ws.klant.be. Wat zet je in REVERB_PORT?

- A. 8080
- B. 443
- C. 80
- D. Niets, Echo vindt het zelf

**Antwoord:** B. 443

REVERB_PORT is waar Laravel en de browser Reverb vinden, en dat is nginx op 443. 8080 is verleidelijk omdat Reverb daar echt draait, maar die poort hoort in REVERB_SERVER_PORT en is van buitenaf niet bereikbaar.

> **Toelichting:** Op een Forge-server staan beide paren vaak op dezelfde machine, en dan is de verleiding groot om ze gelijk te zetten. Onthoud het zo: met SERVER is de binnenkant, zonder SERVER de buitenkant.

<!-- slide 50 -->

### Opdracht: Live comments

In je Agency-project of een nieuw Laravel-project met een starter kit, met Reverb lokaal. Vast? Kijk bij 'Als er niets aankomt' (slide 41) en in de broadcasting-docs onder het boek-icoon.

1. install:broadcasting met Reverb, en een CommentPosted-event op een private channel per project (slide 17 tot 21)
2. Een check in channels.php die de policy van het project hergebruikt (slide 28)
3. Een lijst met comments die aanvult via useEcho, en een lijst van wie het project open heeft (slide 37 en 39)
4. Klaar als twee accounts in twee browsers elkaars comments zien zonder verversen, een account van een ander team een 403 krijgt op /broadcasting/auth, en de lijst verandert als je een tab sluit

> **Toelichting:** Begin met één browser en php artisan tinker: dispatch het event met de hand en kijk of de comment verschijnt. Werkt dat, dan zit een latere fout niet in Reverb of Echo, maar in je controller. Voor het tweede account open je een privévenster.

## Begrippen

- **Polling**: De browser vraagt om de paar seconden aan de server of er iets nieuws is. Gewone HTTP, werkt overal, maar de meeste antwoorden zijn leeg. (slide 8)
- **Server-sent events**: Eén HTTP-verbinding die open blijft, waar de server berichten in schrijft. Alleen van server naar browser. In Laravel met response()->eventStream(), in de browser met EventSource. (slide 10)
- **WebSocket**: Een verbinding die open blijft en in twee richtingen werkt. Een gewone PHP-request kan hem niet vasthouden, daarvoor draait er een aparte server zoals Reverb. (slide 8)
- **Broadcasting**: Een Laravel-event doorsturen naar de browsers die op een kanaal zitten. Laravel geeft het aan een WebSocket-server, die het naar elke open socket pusht. (slide 15)
- **Laravel Reverb**: De WebSocket-server van Laravel, een apart PHP-proces met een event loop. Je start hem met php artisan reverb:start, en hij spreekt het protocol van Pusher. (slide 17)
- **Laravel Echo**: De JavaScript-library die verbindt met Reverb, kanalen opent en naar events luistert. Voor React en Vue zijn er hooks in @laravel/echo-react en @laravel/echo-vue. (slide 36)
- **ShouldBroadcast**: De interface die van een event een broadcast maakt. Het event gaat als job op de queue, dus zonder worker komt er niets aan. (slide 19)
- **ShouldBroadcastNow**: Zoals ShouldBroadcast, maar zonder queue: het event gaat meteen naar Reverb, tijdens de request. Handig in een demo, niet onder load. (slide 24)
- **Public channel**: Een kanaal zonder controle. Iedereen met de app key kan luisteren, en die key staat in je JavaScript. Alleen voor data die publiek mag zijn. (slide 27)
- **Private channel**: Een kanaal waar Laravel eerst beslist wie mag luisteren, met een callback in routes/channels.php. Reverb geeft het de prefix private-. (slide 27)
- **Presence channel**: Een private channel dat bijhoudt wie er op zit. Echo geeft je de lijst met here() en roept joining() en leaving() op als iemand komt of gaat. (slide 30)
- **routes/channels.php**: Het bestand met de autorisatie van je kanalen. Per kanaal een callback die true of false teruggeeft, of bij een presence channel de gegevens van de user. (slide 28)
- **/broadcasting/auth**: De route die Echo aanroept voor het een private of presence channel opent. Laravel draait de callback uit channels.php en geeft een handtekening terug die Reverb controleert. (slide 29)
- **Socket id**: Het id dat Reverb aan elke verbinding geeft. Echo stuurt het mee als X-Socket-ID-header, zodat toOthers() weet welke socket het moet overslaan. (slide 22)
- **toOthers**: broadcast($event)->toOthers() stuurt het event naar iedereen op het kanaal behalve de browser die de request deed. Die heeft het resultaat al uit de response. (slide 22)
- **broadcastWith**: De methode op je event die bepaalt wat er in de payload zit. Zonder gaan alle public properties mee, en dus vaak een volledig model. (slide 23)
- **Model broadcasting**: De BroadcastsEvents-trait op een model stuurt created, updated en deleted zelf door, als ProjectUpdated en zo verder. In React luister je met useEchoModel. (slide 40)
- **useEcho**: De hook uit @laravel/echo-react en @laravel/echo-vue die een private channel opent, naar een event luistert en alles weer opruimt als de component verdwijnt. (slide 37)
- **REVERB_SERVER_HOST**: Waar Reverb zelf luistert op de server, meestal 0.0.0.0:8080. REVERB_HOST en REVERB_PORT zijn het publieke adres waarop Laravel en de browser hem vinden, zoals ws.klant.be:443. (slide 45)

## Links en bronnen

### In de presentatie

- [Event streams (SSE)](https://laravel.com/docs/13.x/responses#event-streams) (slide 11): response()->eventStream en StreamedEvent
- [Laravel Reverb](https://laravel.com/docs/13.x/reverb) (slide 17): Installatie, REVERB_HOST tegenover REVERB_SERVER_HOST, productie en scaling
- [Defining broadcast events](https://laravel.com/docs/13.x/broadcasting#defining-broadcast-events) (slide 20)
- [Queues](https://laravel.com/docs/13.x/queues) (slide 24): De worker achter elke broadcast
- [Authorizing channels](https://laravel.com/docs/13.x/broadcasting#authorizing-channels) (slide 28)
- [Client side installation](https://laravel.com/docs/13.x/broadcasting#client-side-installation) (slide 36)
- [Reverb-docs](https://laravel.com/docs/13.x/reverb#web-server) (slide 46)

### Bronnen

- [Broadcasting](https://laravel.com/docs/13.x/broadcasting): Events, kanalen, channels.php, toOthers, model broadcasting en de Echo-hooks
- [laravel/echo](https://github.com/laravel/echo): Broncode van @laravel/echo-react en @laravel/echo-vue, useEchoPresence en here()
- [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource): Server-sent events in de browser
- [The WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API): Wat een WebSocket is
