HardТеория8 min

Broadcasting (WebSocket-вещание)

Каналы (public, private, presence), вещание событий, получение трансляций, Pusher/Ably/Soketi, Laravel Echo

Broadcasting в Laravel позволяет транслировать серверные события на клиентскую сторону через WebSocket-соединения. Это основа для создания real-time функциональности: чатов, уведомлений, live-обновлений и collaborative-инструментов.

Конфигурация

Конфигурация broadcasting находится в config/broadcasting.php.

// config/broadcasting.php
return [
    'default' => env('BROADCAST_CONNECTION', 'null'),

    'connections' => [
        'reverb' => [
            'driver' => 'reverb',
            'key' => env('REVERB_APP_KEY'),
            'secret' => env('REVERB_APP_SECRET'),
            'app_id' => env('REVERB_APP_ID'),
            'options' => [
                'host' => env('REVERB_HOST'),
                'port' => env('REVERB_PORT', 443),
                'scheme' => env('REVERB_SCHEME', 'https'),
                'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
            ],
        ],

        'pusher' => [
            'driver' => 'pusher',
            'key' => env('PUSHER_APP_KEY'),
            'secret' => env('PUSHER_APP_SECRET'),
            'app_id' => env('PUSHER_APP_ID'),
            'options' => [
                'cluster' => env('PUSHER_APP_CLUSTER'),
                'host' => env('PUSHER_HOST'),
                'port' => env('PUSHER_PORT', 443),
                'scheme' => env('PUSHER_SCHEME', 'https'),
                'encrypted' => true,
                'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
            ],
        ],

        'ably' => [
            'driver' => 'ably',
            'key' => env('ABLY_KEY'),
        ],

        'null' => [
            'driver' => 'null',
        ],

        'log' => [
            'driver' => 'log',
        ],
    ],
];

Установка Laravel Reverb

Laravel Reverb -- это first-party WebSocket-сервер, включённый в Laravel 11.

# Install Reverb
php artisan install:broadcasting

# This will:
# 1. Install Laravel Reverb
# 2. Install Laravel Echo
# 3. Create channels.php routes file
# 4. Update .env with Reverb credentials

Создание событий для вещания

Событие должно реализовать интерфейс ShouldBroadcast или ShouldBroadcastNow.

<?php

declare(strict_types=1);

namespace App\Events;

use App\Models\Message;
use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

final class MessageSent implements ShouldBroadcast
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

    public function __construct(
        public readonly User $user,
        public readonly Message $message,
    ) {}

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('chat.' . $this->message->chat_id),
        ];
    }

    /**
     * The event's broadcast name.
     */
    public function broadcastAs(): string
    {
        return 'message.sent';
    }

    /**
     * Get the data to broadcast.
     *
     * @return array<string, mixed>
     */
    public function broadcastWith(): array
    {
        return [
            'id' => $this->message->id,
            'text' => $this->message->text,
            'user' => [
                'id' => $this->user->id,
                'name' => $this->user->name,
            ],
            'created_at' => $this->message->created_at->toISOString(),
        ];
    }

    /**
     * Determine if this event should broadcast.
     */
    public function broadcastWhen(): bool
    {
        return ! $this->message->is_draft;
    }
}

ShouldBroadcast vs ShouldBroadcastNow

// ShouldBroadcast - broadcasting is queued (recommended)
final class OrderStatusChanged implements ShouldBroadcast
{
    // Broadcast event is placed on the queue
    // and processed by a queue worker

    /**
     * The queue connection to use for broadcasting.
     */
    public string $connection = 'redis';

    /**
     * The queue name for the broadcast job.
     */
    public string $queue = 'broadcasts';
}

// ShouldBroadcastNow - broadcasting is immediate (synchronous)
final class UrgentAlert implements ShouldBroadcastNow
{
    // Broadcast event is sent immediately
    // without going through the queue
}

Типы каналов

Public Channel (Публичный)

Любой пользователь может подписаться. Не требует авторизации.

use Illuminate\Broadcasting\Channel;

public function broadcastOn(): array
{
    return [
        new Channel('orders'),
    ];
}

Private Channel (Приватный)

Требует авторизации. Только авторизованные пользователи могут подписаться.

use Illuminate\Broadcasting\PrivateChannel;

public function broadcastOn(): array
{
    return [
        new PrivateChannel('orders.' . $this->order->user_id),
    ];
}

Presence Channel (Канал присутствия)

Расширение приватного канала с информацией о подключённых пользователях. Идеален для функций "кто сейчас онлайн".

use Illuminate\Broadcasting\PresenceChannel;

public function broadcastOn(): array
{
    return [
        new PresenceChannel('chat.' . $this->chatId),
    ];
}

Авторизация каналов

Правила авторизации определяются в файле routes/channels.php.

<?php

// routes/channels.php

use App\Models\Chat;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

// Private channel authorization
Broadcast::channel('orders.{userId}', function (User $user, int $userId) {
    return $user->id === $userId;
});

// Returning data for private channel
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
    $order = Order::find($orderId);

    return $order && $user->id === $order->user_id;
});

// Presence channel authorization
// Return data that will be shared with other subscribers
Broadcast::channel('chat.{chatId}', function (User $user, int $chatId) {
    $chat = Chat::find($chatId);

    if ($chat && $chat->users()->where('user_id', $user->id)->exists()) {
        // Return user data for the presence channel
        return [
            'id' => $user->id,
            'name' => $user->name,
            'avatar' => $user->avatar_url,
        ];
    }

    return false;
});

// Using channel classes for complex authorization
Broadcast::channel('project.{projectId}', \App\Broadcasting\ProjectChannel::class);

Channel-классы для авторизации

<?php

declare(strict_types=1);

namespace App\Broadcasting;

use App\Models\Project;
use App\Models\User;

final class ProjectChannel
{
    /**
     * Authenticate the user's access to the channel.
     *
     * @return array<string, mixed>|bool
     */
    public function join(User $user, Project $project): array|bool
    {
        if (! $project->team->hasUser($user)) {
            return false;
        }

        // For presence channels, return user data
        return [
            'id' => $user->id,
            'name' => $user->name,
            'role' => $project->team->roleFor($user),
        ];
    }
}

Вещание событий

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Events\MessageSent;
use App\Models\Chat;
use App\Models\Message;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class MessageController extends Controller
{
    public function store(Request $request, Chat $chat): JsonResponse
    {
        $message = $chat->messages()->create([
            'user_id' => $request->user()->id,
            'text' => $request->input('text'),
        ]);

        // Method 1: Dispatch the event
        event(new MessageSent($request->user(), $message));

        // Method 2: Using broadcast() helper
        broadcast(new MessageSent($request->user(), $message));

        // Method 3: Broadcast to others (exclude sender)
        broadcast(new MessageSent($request->user(), $message))->toOthers();

        return response()->json($message, 201);
    }
}

Метод toOthers()

Метод toOthers() исключает текущего пользователя из трансляции. Это полезно для интерфейсов, где отправитель сразу видит свои действия (optimistic UI).

// The current user will NOT receive this broadcast
broadcast(new MessageSent($user, $message))->toOthers();

Для работы toOthers() на клиенте нужно передать socket ID:

// JavaScript (Laravel Echo)
axios.defaults.headers.common['X-Socket-Id'] = Echo.socketId();

Laravel Echo (клиентская сторона)

Laravel Echo -- JavaScript-библиотека для работы с WebSocket-каналами.

// resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

Подписка на публичный канал

Echo.channel('orders')
    .listen('OrderShipped', (event) => {
        console.log('Order shipped:', event.order);
    });

Подписка на приватный канал

Echo.private('orders.' + userId)
    .listen('OrderStatusChanged', (event) => {
        console.log('Order status:', event.status);
    })
    // Custom broadcast name (with broadcastAs)
    .listen('.order.status.changed', (event) => {
        console.log('Order status:', event.status);
    });

Подписка на presence-канал

Echo.join('chat.' + chatId)
    // When you successfully join
    .here((users) => {
        console.log('Users in chat:', users);
    })
    // When a new user joins
    .joining((user) => {
        console.log('User joined:', user.name);
    })
    // When a user leaves
    .leaving((user) => {
        console.log('User left:', user.name);
    })
    // Listen for events
    .listen('MessageSent', (event) => {
        console.log('New message:', event);
    })
    // Error handling
    .error((error) => {
        console.error('Channel error:', error);
    });

Whisper (Client Events)

Whisper позволяет отправлять события между клиентами без участия сервера.

// Send typing indicator (client-to-client, no server)
Echo.private('chat.' + chatId)
    .whisper('typing', {
        user: userName,
    });

// Listen for typing
Echo.private('chat.' + chatId)
    .listenForWhisper('typing', (event) => {
        console.log(event.user + ' is typing...');
    });

Модельное вещание (Model Broadcasting)

Laravel позволяет автоматически транслировать события моделей Eloquent.

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Model;

final class Post extends Model
{
    use BroadcastsEvents;

    protected $fillable = ['title', 'content', 'user_id'];

    /**
     * Get the channels that model events should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(string $event): array
    {
        return match ($event) {
            'created' => [
                new PrivateChannel('users.' . $this->user_id),
            ],
            default => [
                new PrivateChannel('posts.' . $this->id),
            ],
        };
    }

    /**
     * Customize the broadcast event name.
     */
    public function broadcastAs(string $event): string
    {
        return "post.{$event}";
    }

    /**
     * Get the data to broadcast for the model event.
     *
     * @return array<string, mixed>
     */
    public function broadcastWith(string $event): array
    {
        return match ($event) {
            'created', 'updated' => [
                'id' => $this->id,
                'title' => $this->title,
                'updated_at' => $this->updated_at->toISOString(),
            ],
            default => ['id' => $this->id],
        };
    }
}

Подписка на модельные события через Echo:

Echo.private('posts.' + postId)
    .listen('.post.updated', (event) => {
        console.log('Post updated:', event);
    })
    .listen('.post.deleted', (event) => {
        console.log('Post deleted:', event.id);
    });

Notification Broadcasting

Уведомления можно транслировать через broadcasting.

<?php

declare(strict_types=1);

namespace App\Notifications;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;

final class InvoicePaid extends Notification implements ShouldBroadcast
{
    public function __construct(
        public readonly float $amount,
    ) {}

    /**
     * @return array<int, string>
     */
    public function via(object $notifiable): array
    {
        return ['broadcast', 'database'];
    }

    public function toBroadcast(object $notifiable): BroadcastMessage
    {
        return new BroadcastMessage([
            'invoice_id' => $this->id,
            'amount' => $this->amount,
            'message' => "Invoice paid: {$this->amount}",
        ]);
    }

    /**
     * The channels the notification should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('user.' . $this->notifiable->id),
        ];
    }

    public function broadcastType(): string
    {
        return 'invoice.paid';
    }
}

Подписка на уведомления через Echo:

Echo.private('App.Models.User.' + userId)
    .notification((notification) => {
        console.log('Notification:', notification.type);
        console.log('Data:', notification);
    });

Тестирование Broadcasting

<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Events\MessageSent;
use App\Models\Chat;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

final class BroadcastingTest extends TestCase
{
    public function test_message_event_is_broadcast(): void
    {
        Event::fake([MessageSent::class]);

        $user = User::factory()->create();
        $chat = Chat::factory()->create();
        $message = Message::factory()->create([
            'chat_id' => $chat->id,
            'user_id' => $user->id,
        ]);

        event(new MessageSent($user, $message));

        Event::assertDispatched(MessageSent::class, function ($event) use ($chat) {
            $channels = $event->broadcastOn();

            return $channels[0]->name === 'private-chat.' . $chat->id;
        });
    }

    public function test_broadcast_event_contains_correct_data(): void
    {
        $user = User::factory()->create();
        $message = Message::factory()->create(['user_id' => $user->id]);

        $event = new MessageSent($user, $message);
        $data = $event->broadcastWith();

        $this->assertArrayHasKey('id', $data);
        $this->assertArrayHasKey('text', $data);
        $this->assertArrayHasKey('user', $data);
        $this->assertEquals($user->id, $data['user']['id']);
    }
}

Проверь себя

Какая разница между ShouldBroadcast и ShouldBroadcastNow?

Что делает метод broadcastAs() в классе события?

Как whisper отличается от обычного события broadcasting?

Что делает broadcast(new Event)->toOthers()?

Чем PrivateChannel отличается от PresenceChannel?