Skip to content

Broadcasting Tenancy

Broadcasting (websockets) is one of the few things the package doesn’t automatically solve out of the box. This is due to multi-tenant broadcasting being highly setup-specific.

That said, Tenancy comes with two approaches for making broadcasting tenant-aware:

The former requires fewer code changes, but your tenants will need to bring their own keys (at least in case of commercial services like Pusher). When using self-hosted broadcasting solutions, like soketi, you might be able to simply assign different keys to different tenants even if they ultimately all connect to the same server.

The latter requires more changes, but works with pretty much any broadcasting setup. It lets you use a single set of API keys and instead dynamically create channels for tenants (e.g. feed becomes {tenantId}.feed).

Making the broadcasting auth route tenant-aware

Apply the relevant tenancy middleware (likely tenant and universal, possibly a specific tenant identification middleware, depending on your routing setup) on your broadcasting routes:

BroadcastServiceProvider.php
Broadcast::routes(['middleware' => ['web', 'tenant', 'universal']]);

Tenant-specific broadcasting config

This approach lets you use a different broadcasting config (= API keys) for each tenant. As mentioned above, the benefit is that this is easy to set up, but the drawbacks are:

  • your tenants having to bring their own API keys (in the case of Pusher, with other solutions this might be easier)
  • not being able to send messages between tenant applications and the central application. Once tenancy is initialized, Laravel’s broadcasting logic will be fully switched to the new broadcasting config, which will differ from the config used in the central application

To use this approach, enable the respective bootstrapper:

config/tenancy.php
'bootstrappers' => [
BroadcastingConfigBootstrapper::class,
],

The BroadcastingConfigBootstrapper maps tenant attributes to the broadcasting config so that broadcasters can use attributes of the current tenant as credentials.

The bootstrapper comes with map presets for pusher, reverb, and ably:

'pusher' => [
'broadcasting.connections.pusher.key' => 'pusher_key',
'broadcasting.connections.pusher.secret' => 'pusher_secret',
'broadcasting.connections.pusher.app_id' => 'pusher_app_id',
'broadcasting.connections.pusher.options.cluster' => 'pusher_cluster',
],
'reverb' => [
'broadcasting.connections.reverb.key' => 'reverb_key',
'broadcasting.connections.reverb.secret' => 'reverb_secret',
'broadcasting.connections.reverb.app_id' => 'reverb_app_id',
'broadcasting.connections.reverb.options.cluster' => 'reverb_cluster',
],
'ably' => [
'broadcasting.connections.ably.key' => 'ably_key',
'broadcasting.connections.ably.public' => 'ably_public',
],

These are used based on the broadcaster being used. Meaning, if you’re using Pusher, tenant properties will be mapped like this:

  • $tenant->pusher_key => broadcasting.connections.pusher.key
  • $tenant->pusher_secret => broadcasting.connections.pusher.secret

You’re free to use your own config instead of these presets.

For example, to map the pusher_key tenant attribute ($tenant->pusher_key) to the Pusher broadcaster’s API key, you could do this:

TenancyServiceProvider.php
BroadcastingConfigBootstrapper::$credentialsMap[
'broadcasting.connections.pusher.key'
] = 'pusher_key';

On top of modifying config, the bootstrapper also overrides the BroadcastManager singleton with an instance of TenancyBroadcastManager, which refreshes broadcasters specified in the $tenantBroadcasters (‘pusher’, ‘reverb’, and ‘ably’ by default, feel free to change that) so that the current credentials are used.

The last step to making this work is to update your Laravel Echo configuration. Since the broadcasting configuration will be tenant-specific, we need to set these dynamically and can’t have hardcoded values as part of a frontend bundle.

If you can instantiate Echo directly in Blade, you could use:

layout.blade.php
// in a Blade template
window.Echo = new Echo({
// Make sure to not expose any private values to the frontend!
key: {{ config('broadcasting.connections.pusher.key') }},
// Most of the config will be static, so these values don't need anything from the backend
broadcaster: 'pusher',
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});

Alternatively, put the dynamic values into some global object that’s initialized before Echo will be instantiated:

layout.blade.php
window._broadcastingConfig = {
key: {{ config('broadcasting.connections.pusher.key') }},
// any other values you might need (but again, make sure you do NOT leak any private keys)
};

And then your frontend bundle can use the value:

app.js
window.Echo = new Echo({
key: window._broadcastingConfig.key,
broadcaster: 'pusher',
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
// etc
});

Prefixed channel names

This approach introduces the following concepts:

  • central channels (the ones you’re already using in your central app)
  • tenant channels (scoped to individual tenants)
  • universal channels (a channel that works the same way in the central context and in tenant contexts, but data is scoped to the central context/individual tenant)
  • global channels (messages are shared across all tenants and the central context — all contexts interact with the same channel)

The different channel registration is the first part of this approach.

The second part is tenancy automatically scoping broadcast messages — so that they use the right channel, given the current context.

The third part is integrating this with Echo.

Channel types

For Tenancy to accurately determine the types of your channels, they must follow our naming convention:

  • Central channels don’t require any prefix. Events broadcast on central channels will only be available in the central app
  • Tenant channels have to be prefixed with the tenant prefix ({tenant}. — e.g. {tenant}.channel). Events broadcast on tenant channels will be contained within that tenant
  • Universal channels is a term for a central channel that has a corresponding tenant channel (e.g. channel and {tenant}.channel). So a universal channel is not a single channel, but rather two separate channels: a central one without a prefix, and a tenant one with a prefix. Tenancy will determine which channel to use depending on the current context
  • Global channels are channels available in both the central and tenant apps. They have to be prefixed with the global__ prefix (e.g. global__channel). They’re similar to universal channels, except a global channel is just a single channel usable in any context, whereas a universal channel is separate, scoped channels with identical behavior.

Channel registration helpers

Tenancy provides helper functions for some of the mentioned channel types to make registering them more convenient.

For example, for universal channels, you’d normally have to register two separate channels using Broadcast::channel(...). Instead, we provide universal_channel() that you only have to call once.

Central channels are registered in the same way as in a single-tenant application: using Broadcast::channel(...). Central channels can’t use prefixes reserved for the other channel types.

The tenant_channel() helper registers a channel with the tenant prefix. The tenant channel closure accepts the tenant key (from the {tenant} parameter), but that’s solely for the purposes of name prefixing.

The universal_channel() method registers two channels: tenant and central, both using the closure you pass to the helper. The tenant channel includes the tenant prefix (meaning its closure also accepts the tenant parameter), while the central channel doesn’t get any prefix.

The global_channel() method registers a global channel.

For example, this:

// Registers a channel prefixed with 'global__'
global_channel('channel.{userId}', function ($user, $userId) {
return User::find($userId)->is($user);
});
// Registers a channel prefixed with '{tenant}.'
tenant_channel('another-channel.{userId}', function ($user, $userId) {
return User::find($userId)->is($user);
});
// Registers two channels – one prefixed with '{tenant}.', and one without the prefix
universal_channel('channel.{userId}', function ($user, $userId) {
return User::find($userId)->is($user);
});

is equivalent to this:

// global_channel()
Broadcast::channel('global__channel.{userId}', function ($user, $userId) {
return User::find($userId)->is($user);
});
// tenant_channel()
Broadcast::channel('{tenant}.another-channel.{userId}', function ($tenant, $user, $userId) {
return User::find($userId)->is($user);
});
// universal_channel()
Broadcast::channel('channel.{userId}', function ($user, $userId) {
return User::find($userId)->is($user);
});
Broadcast::channel('{tenant}.channel.{userId}', function ($tenant, $user, $userId) {
return User::find($userId)->is($user);
});

BroadcastChannelPrefixBootstrapper

After your channels are defined as central, tenant, universal, or global, you need to enable the bootstrapper that will scope broadcast messages to these channels on the backend:

config/tenancy.php
'bootstrappers' => [
BroadcastChannelPrefixBootstrapper::class,
],

Without this bootstrapper, you’d have to manually determine which channel should be used in the broadcastOn() method of your events:

app/Events/OrderCreated.php
class OrderCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
) {}
public function broadcastOn(): array
{
// Example of a universal channel
return [
new PrivateChannel('orders'),
new PrivateChannel(tenancy()->initialized
? tenant('id') . '.orders'
: 'orders'
),
];
}
}

With this bootstrapper, tenancy does this automatically for you in the background:

app/Events/OrderCreated.php
public function broadcastOn(): array
{
// Tenancy takes care of the rest
return [
new PrivateChannel('orders'),
];
}

The way the bootstrapper works is that it overrides individual broadcasters using anonymous classes that override the formatChannels() method. This lets the package run additional logic to determine how the channel from the event (orders in our example) should be formatted.

Out of the box, the bootstrapper only provides overrides for Pusher, Reverb, and Ably. These can be enabled using their respective methods:

TenancyServiceProvider.php
public function boot()
{
BroadcastChannelPrefixBootstrapper::pusher();
BroadcastChannelPrefixBootstrapper::reverb();
BroadcastChannelPrefixBootstrapper::ably();
}

Only enable the one you’re using.

These methods register an override in the $broadcasterOverrides static property. To add your own override, you could use:

BroadcastChannelPrefixBootstrapper::$broadcasterOverrides['custom_broadcaster'] = function (BroadcastManager $broadcastManager) {
$broadcastManager->extend('custom_broadcaster', function ($app, $config) use ($broadcastManager) {
// See the BroadcastChannelPrefixBootstrapper for reference implementation of these overrides
});
};

So far, we have completed two of the three steps: we have created separate channels for the central part of the application and the tenant part of the application, and we have enabled the BroadcastChannelPrefixBootstrapper, along with the right override (pusher(), reverb(), or ably()).

The final step is making your frontend listen to these prefixed channels.

Making Echo channels tenant-aware

Start by sharing the current tenant ID with your frontend. If you’re using backend-rendered templates, you can simply add this to your Blade layout:

layout.blade.php
window.tenantId = {{ Js::from(tenant()?->getTenantKey()) }};

Next, use this variable in your frontend bundle to create the tenant channel prefix:

app.js
const tenantChannelPrefix = window.tenantKey ? `${window.tenantKey}.` : '';

Now there are two ways you can use this prefix. You can either add it to your channel names manually:

app.js
window.Echo.private(`${tenantChannelPrefix}orders`)
.listen('SomeEvent', e => console.log(e))

Or you can register your channels upfront, and once all channels are registered, you can hook into the window.Echo object to clone all channels and create their tenant counterpart (even though for some it might not exist on the backend — this can’t be determined in JavaScript, but it’s fine since the channel will simply never be referenced assuming your frontend application logic is correct):

if (tenantChannelPrefix) {
Object.keys(window.Echo.connector.channels).forEach(channel => {
// Don't clone global channels
if (channel.startsWith('global__')) {
return;
}
let tenantChannel = null;
if (channel.startsWith('private-encrypted-')) {
tenantChannel = window.Echo.privateEncrypted(tenantChannelPrefix + channel.split('-')[2]);
} else if (channel.startsWith('private-')) {
tenantChannel = window.Echo.private(tenantChannelPrefix + channel.split('-')[1]);
} else if (channel.startsWith('presence-')) {
tenantChannel = window.Echo.presence(tenantChannelPrefix + channel.split('-')[1]);
} else {
tenantChannel = window.Echo.channel(tenantChannelPrefix + channel);
}
// Give the tenant channel the original channel's callbacks (listen() etc)
tenantChannel.subscription.callbacks._callbacks = window.Echo.connector.channels[channel].subscription.callbacks._callbacks
});
}