Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
7d749eb
BroadcastingConfigBootstrapper: test mapping credentials
lukinovec Mar 31, 2026
2ecc94d
BroadcastingConfigBootstrapper: test persistence of custom driver cre…
lukinovec Mar 31, 2026
fafd082
Fix typo in test
lukinovec Mar 31, 2026
c653c51
BroadcastingConfigBootstrapper: make tenant manager inherit central m…
lukinovec Mar 31, 2026
b1e91f1
BroadcastingConfigBootstrapper: make `Broadcaster::class` resolve to …
lukinovec Mar 31, 2026
65beecf
BroadcastingConfigBootstrapper: clear the `Broadcast` facade's resolv…
lukinovec Mar 31, 2026
0b860ea
Fix code style (php-cs-fixer)
github-actions[bot] Mar 31, 2026
c4f4451
Add assertions for the config of the bound manager's driver
lukinovec Mar 31, 2026
d939866
Fix custom creator assertions
lukinovec Mar 31, 2026
28b6119
TenancyBroadcastManager: update docblocks
lukinovec Apr 1, 2026
0fbe1bc
TenancyBroadcastManager: delete `Broadcaster` singleton binding
lukinovec Apr 2, 2026
b6c035c
Improve comments
lukinovec Apr 2, 2026
bbe2ff0
BroadcastingTest: update channel inheritance test
lukinovec Apr 2, 2026
b2add06
Delete BroadcastingTest
lukinovec Apr 2, 2026
9e9bedc
Fix code style (php-cs-fixer)
github-actions[bot] Apr 2, 2026
4b1cc9c
Improve comments
lukinovec Apr 2, 2026
fc45e09
Update BroadcastingConfigBootstrapperTest
lukinovec Apr 3, 2026
6b99921
BroadcastingConfigBootstrapper: correct `$credentialsMap` array_merge…
lukinovec Apr 3, 2026
29dd23d
BroadcastingConfigBootstrapperTest: add 'reverb' driver to datasets
lukinovec Apr 3, 2026
4937a74
BroadcastingConfigBootstrapper and TenancyBroadcastManager: comments
lukinovec Apr 3, 2026
c831393
Update comment
lukinovec Apr 3, 2026
ef476c5
Polish comments
lukinovec Apr 3, 2026
f8528fc
Add 'reverb' to `TenancyBroadcastManager::$tenantBroadcasters`
lukinovec Apr 3, 2026
dc344b7
Merge branch 'master' into broadcasting-fixes
stancl Apr 12, 2026
9ab0a72
Fix typo
lukinovec Apr 13, 2026
4aeaa66
Remove unused `$broadcaster` parameter
lukinovec Apr 13, 2026
b3d5197
Correct channel prefix test comments
lukinovec Apr 13, 2026
a247bb0
Correct config bootstrapper test comment
lukinovec Apr 13, 2026
20d494f
Reset `BroadcastingConfigBootstrapper::$broadcaster` in tests
lukinovec Apr 14, 2026
7af2b8a
Improve BroadcastManager instance checks
lukinovec Apr 14, 2026
a3c5568
Add regression test for credentialsMap merge order
lukinovec Apr 15, 2026
f3652a8
Make `with()` formatting consistent
lukinovec Apr 15, 2026
a7acd07
Reset `BroadcastingConfigBootstrapper::$mapPresets` in tests
lukinovec Apr 15, 2026
7db486d
Extract cleanup in broadcasting config test file
lukinovec Apr 15, 2026
d885659
Use toEqualCanonicalizing instead of toBe for custom creators
lukinovec Apr 15, 2026
e592b37
Merge branch 'master' into broadcasting-fixes
lukinovec Apr 15, 2026
ddd8c68
Improve comments
lukinovec Apr 21, 2026
614344a
Add test for reverting the bound broadcaster when tenancy ends
lukinovec Jun 29, 2026
f20f801
Merge branch 'master' into broadcasting-fixes
lukinovec Jun 29, 2026
9af1735
Explain why we swap the bound Broadcaster singleton better, remove th…
lukinovec Jul 10, 2026
937c8c8
Delete the redundant BroadcastingFactory::class arg from the Broadcas…
lukinovec Jul 10, 2026
a19d3b5
Merge branch 'master' into broadcasting-fixes
lukinovec Jul 10, 2026
24aeb23
Remove unused `$app` param and `?BroadcastManager` typehint (`singlet…
lukinovec Jul 10, 2026
2a0464d
Resolve (and cache) tenant broadcasters once per tenancy initializati…
lukinovec Jul 10, 2026
d80fed1
Remove stale $tenantBroadcasters mention
lukinovec Jul 10, 2026
af14d40
Delete extra newline, fix comment inconsistency
lukinovec Jul 10, 2026
b8fddf8
Retrieve the re-registered channel closure to assert what tenant_chan…
lukinovec Jul 11, 2026
128a1ad
Improve BroadcastingConfigBootstrapper docblock
lukinovec Jul 11, 2026
9e6e0a9
Remove TenancyBroadcastManager, pass the channel auth closures direct…
lukinovec Jul 12, 2026
5911619
Simplify channel-passing test
lukinovec Jul 12, 2026
120cec4
Add spaces after `fn`/`function` for consistency
lukinovec Jul 12, 2026
12e51d0
Test that when a tenant doesn't have the property that should be mapp…
lukinovec Jul 12, 2026
0ba588d
Improve channel closure inheritance test
lukinovec Jul 12, 2026
e6c4c31
Test that using broadcasters that only implement the Broadcaster cont…
lukinovec Jul 12, 2026
4422df2
Test configuring BroadcastingConfigBootstrapper::$broadcaster
lukinovec Jul 12, 2026
1409d72
Make test name more specific
lukinovec Jul 12, 2026
31d1663
Fix typo in comment
lukinovec Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 63 additions & 11 deletions src/Bootstrappers/BroadcastingConfigBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@

namespace Stancl\Tenancy\Bootstrappers;

use Illuminate\Broadcasting\Broadcasters\Broadcaster;
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Broadcasting\Broadcaster;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Broadcast;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;

/**
* Maps tenant credentials to the broadcasting config and rebinds BroadcastManager
* and Broadcaster so that broadcasters get resolved using the tenant credentials.
*/
class BroadcastingConfigBootstrapper implements TenancyBootstrapper
{
/**
Expand All @@ -21,14 +26,16 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper
* [
* 'config.key.name' => 'tenant_property',
* ]
*
* $tenant->tenant_property will be mapped to config('config.key.name') when tenancy is initialized.
*/
public static array $credentialsMap = [];

public static string|null $broadcaster = null;

protected array $originalConfig = [];
protected BroadcastManager|null $originalBroadcastManager = null;
protected Broadcaster|null $originalBroadcaster = null;
protected BroadcasterContract|null $originalBroadcaster = null;

public static array $mapPresets = [
'pusher' => [
Expand All @@ -54,27 +61,72 @@ public function __construct(
protected Application $app
) {
static::$broadcaster ??= $config->get('broadcasting.default');
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []);
static::$credentialsMap = array_merge(static::$mapPresets[static::$broadcaster] ?? [], static::$credentialsMap);
}

public function bootstrap(Tenant $tenant): void
{
$this->originalBroadcastManager = $this->app->make(BroadcastManager::class);
$this->originalBroadcaster = $this->app->make(Broadcaster::class);
$this->originalBroadcaster = $this->app->make(BroadcasterContract::class);

$this->setConfig($tenant);

// Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials
$this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) {
return new TenancyBroadcastManager($this->app);
// Make BroadcastManager resolve to a fresh manager with no cached broadcasters,
// so that its broadcasters get resolved using the updated (tenant) broadcasting
// config and stay cached for the duration of the tenant's context.
$this->app->extend(BroadcastManager::class, function (BroadcastManager $centralManager) {
$tenantManager = new BroadcastManager($this->app);

// Pass the custom driver creators registered in the central context to the new manager
// so that custom drivers work in tenant context without having to re-register the creators manually.
foreach (invade($centralManager)->customCreators as $driver => $creator) {
$tenantManager->extend($driver, $creator);
}

return $tenantManager;
});
Comment on lines +77 to +87

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the reason for creating the instance manually like this that if we simply forgot the bound instance from the container, the custom creators that were already registered would be lost?

To confirm - when would BroadcastManager::extend() be used? Is this change simply because of how we do things in our tests or would real applications also extend the manager before tenancy bootstrap?

And what are some common use cases.


// Swap the currently bound Broadcaster singleton (resolved earlier with the central credentials)
// for the tenant BroadcastManager's default broadcaster, so that anything resolving the Broadcaster
// contract gets the same tenant broadcaster that the manager uses, instead of the stale central one.
$this->app->extend(BroadcasterContract::class, function (BroadcasterContract $centralBroadcaster) {
$tenantBroadcaster = $this->app->make(BroadcastManager::class)->connection();

// The newly resolved broadcaster doesn't have any channel auth closures registered, so the
// closures registered in central context (e.g. in routes/channels.php) have to be passed to it
// manually, otherwise, Broadcast::auth() would throw a 403 for those channels.
// Since Laravel only ever uses the default broadcaster's channel auth closures for broadcasting auth,
// we only have to pass the channel closures to the default broadcaster.
//
// The $channels property and the channel() method aren't part of the Broadcaster contract -- they come
// from the abstract Broadcaster class, so the closures can only be copied between broadcasters extending it
// (which all of Laravel's default broadcasters, e.g. PusherBroadcaster, do).
if ($centralBroadcaster instanceof Broadcaster && $tenantBroadcaster instanceof Broadcaster) {
// invade() because channels can't be retrieved through any of the broadcaster's public methods
$centralBroadcaster = invade($centralBroadcaster);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a getChannels() method on Broadcaster I think


foreach ($centralBroadcaster->channels as $channel => $callback) {
$tenantBroadcaster->channel($channel, $callback, $centralBroadcaster->retrieveChannelOptions($channel));
}
}

return $tenantBroadcaster;
});

// Extending the binding doesn't update the Broadcast facade's cached instance,
// so clear it to make the facade re-resolve to the tenant BroadcastManager instead of the central
// one — e.g. in the Broadcast::auth() call in BroadcastController (/broadcasting/auth).
Broadcast::clearResolvedInstance();
}

public function revert(): void
{
// Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy
$this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager);
$this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster);
// Revert the bound BroadcastManager and Broadcaster singletons back to their original state
$this->app->singleton(BroadcastManager::class, fn () => $this->originalBroadcastManager);
$this->app->singleton(BroadcasterContract::class, fn () => $this->originalBroadcaster);
Comment on lines +124 to +126

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Revert the bound BroadcastManager and Broadcaster singletons back to their original state
$this->app->singleton(BroadcastManager::class, fn () => $this->originalBroadcastManager);
$this->app->singleton(BroadcasterContract::class, fn () => $this->originalBroadcaster);
// Revert the bound BroadcastManager and Broadcaster singletons back to their original state
$this->app->instance(BroadcastManager::class, $this->originalBroadcastManager);
$this->app->instance(BroadcasterContract::class, $this->originalBroadcaster);

Would this be equivalent?


// Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager
Broadcast::clearResolvedInstance();

$this->unsetConfig();
}
Expand Down
65 changes: 0 additions & 65 deletions src/Overrides/TenancyBroadcastManager.php

This file was deleted.

102 changes: 102 additions & 0 deletions tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use function Stancl\Tenancy\Tests\pest;
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Collection;

beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Expand Down Expand Up @@ -137,3 +140,102 @@ protected function formatChannels(array $channels)
expect(app(BroadcastManager::class)->driver())->toBe($broadcaster);
expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames);
});

test('broadcasting channel helpers register channels correctly', function() {
config([
'broadcasting.default' => $driver = 'testing',
'broadcasting.connections.testing.driver' => $driver,
]);

config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);

Schema::create('users', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});

$centralUser = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test']);
$tenant = Tenant::create();

migrateTenants();

tenancy()->initialize($tenant);

// Same ID as $centralUser
$tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test']);

tenancy()->end();

/** @var BroadcastManager $broadcastManager */
$broadcastManager = app(BroadcastManager::class);

// Use a driver with no channels
$broadcastManager->extend($driver, fn () => new NullBroadcaster);

$getChannels = fn (): Collection => $broadcastManager->driver($driver)->getChannels();

expect($getChannels())->toBeEmpty();

// Basic channel registration
Broadcast::channel($channelName = 'user.{userName}', $channelClosure = function ($user, $userName) {
return User::firstWhere('name', $userName)?->is($user) ?? false;
});

// Check if the channel is registered
$centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName);
expect($centralChannelClosure)->not()->toBeNull();

// Channel closures work as expected (running in central context)
expect($centralChannelClosure($centralUser, $centralUser->name))->toBeTrue();
expect($centralChannelClosure($centralUser, $tenantUser->name))->toBeFalse();

// Register a tenant broadcasting channel (almost identical to the original channel, just able to accept the tenant key)
tenant_channel($channelName, $channelClosure);

// Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userName}")
$tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName");
expect($tenantChannelClosure)->toBe($centralChannelClosure);

// The tenant channels are prefixed with '{tenant}.'
// They accept the tenant key, but their closures only run in tenant context when tenancy is initialized
// The regular channels don't accept the tenant key, but they also respect the current context
// The tenant key is used solely for the name prefixing – the closures can still run in the central context
tenant_channel($channelName, $tenantChannelClosure = function ($user, $tenant, $userName) {
return User::firstWhere('name', $userName)?->is($user) ?? false;
});

// Retrieve the stored closure to verify that re-registering the channel replaced it
// (asserting on $tenantChannelClosure wouldn't tell us what tenant_channel() actually stored)
$reregisteredTenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName");

expect($reregisteredTenantChannelClosure)
->toBe($tenantChannelClosure)
->not()->toBe($centralChannelClosure);

expect($reregisteredTenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue();
expect($reregisteredTenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse();

tenancy()->initialize($tenant);

// The channel closure runs in the tenant context
// Only the tenant user is available
expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse();
expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Use a new channel instance to delete the previously registered channels before testing the global_channel helper
$broadcastManager->purge($driver);
$broadcastManager->extend($driver, fn () => new NullBroadcaster);

expect($getChannels())->toBeEmpty();

// Global channel helper prefixes the channel name with 'global__'
global_channel($channelName, $channelClosure);

// Channel prefixed with 'global__' found
$foundChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === 'global__' . $channelName);
expect($foundChannelClosure)->not()->toBeNull();
});
Loading
Loading