From 7d749eb592f88687ddea7e03d1daaf4c3a986f20 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 15:32:45 +0200 Subject: [PATCH 01/53] BroadcastingConfigBootstrapper: test mapping credentials Test that BroadcastingConfigBootstrapper correctly maps tenant properties to broadcasting config/credentials, and that the credentials don't leak when switching contexts. Also add the `$config` property to `TestingBroadcaster` so that we can access the credentials used by the broadcaster. --- tests/BroadcastingTest.php | 42 ++++++++++++++++++++++++++++++++ tests/Etc/TestingBroadcaster.php | 3 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index c3509426b..69e7fb3b8 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -52,6 +52,48 @@ expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); }); +test('broadcasting config bootstrapper maps the config to broadcaster credentials correctly', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + 'broadcasting.connections.testing.key' => 'central_key', + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], + ]); + + BroadcastingConfigBootstrapper::$credentialsMap['broadcasting.connections.testing.key'] = 'testing_key'; + + // Register the testing broadcaster + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + + $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']); + $tenant2 = Tenant::create(['testing_key' => 'tenant2_key']); + + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + expect(Broadcast::driver()->config['key'])->toBe('central_key'); + + tenancy()->initialize($tenant1); + + // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); + // The Broadcast facade (used in BroadcastController::authenticate) uses the broadcaster with tenant config + // instead of the stale broadcaster instance resolved before tenancy was initialized + expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); + + tenancy()->initialize($tenant2); + + // Switching to another tenant context makes the current broadcaster use the new tenant's config + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant2_key'); + expect(Broadcast::driver()->config['key'])->toBe('tenant2_key'); + + tenancy()->end(); + + // Ending tenancy reverts the broadcaster changes + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + expect(Broadcast::driver()->config['key'])->toBe('central_key'); +}); + test('new broadcasters get the channels from the previously bound broadcaster', function() { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); config([ diff --git a/tests/Etc/TestingBroadcaster.php b/tests/Etc/TestingBroadcaster.php index 1a2258875..0b923f8fb 100644 --- a/tests/Etc/TestingBroadcaster.php +++ b/tests/Etc/TestingBroadcaster.php @@ -6,7 +6,8 @@ class TestingBroadcaster extends Broadcaster { public function __construct( - public string $message = 'nothing' + public string $message = 'nothing', + public array $config = [], ) {} public function auth($request) From 2ecc94d8e8914fe362ff2cef283d64d7bae5c1c9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 16:19:15 +0200 Subject: [PATCH 02/53] BroadcastingConfigBootstrapper: test persistence of custom driver creators Test that TenancyBroadcastManager inherits the custom driver creators from the central BroadcastManager. --- tests/BroadcastingTest.php | 44 ++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 69e7fb3b8..4455bfe97 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -94,6 +94,45 @@ expect(Broadcast::driver()->config['key'])->toBe('central_key'); }); +test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function() { + config([ + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], + ]); + + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + app(BroadcastManager::class)->extend('testing', $testingClosure = fn($app, $config) => new TestingBroadcaster('testing', $config)); + + $originalCustomCreators = invade(app(BroadcastManager::class))->customCreators; + + expect($originalCustomCreators['testing'])->toBe($testingClosure); + + tenancy()->initialize($tenant); + + app(BroadcastManager::class)->extend( + 'testing-tenant1', + $testingTenant1Closure = fn($app, $config) => new TestingBroadcaster('testing-tenant1', $config) + ); + + // Current BroadcastManager instance has the original custom creators plus the newly registered testing-tenant1 creator + expect(invade(app(BroadcastManager::class))->customCreators)->toBe($originalCustomCreators + ['testing-tenant1' => $testingTenant1Closure]); + + tenancy()->initialize($tenant2); + + // Current BroadcastManager only has the original custom creators, + // the creator added in the previous tenant's context doesn't persist. + expect(invade(app(BroadcastManager::class))->customCreators)->toBe($originalCustomCreators); + + tenancy()->end(); + + // Ending tenancy reverts the BroadcastManager binding back to the original state, + // the creator registered in the tenant context doesn't persist. + expect(invade(app(BroadcastManager::class))->customCreators)->toBe($originalCustomCreators); +}); + test('new broadcasters get the channels from the previously bound broadcaster', function() { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); config([ @@ -103,21 +142,18 @@ TenancyBroadcastManager::$tenantBroadcasters[] = $driver; - $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); $getCurrentChannels = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); - $registerTestingBroadcaster(); Broadcast::channel($channel = 'testing-channel', fn() => true); expect($channel)->toBeIn($getCurrentChannels()); tenancy()->initialize(Tenant::create()); - $registerTestingBroadcaster(); expect($channel)->toBeIn($getCurrentChannels()); tenancy()->end(); - $registerTestingBroadcaster(); expect($channel)->toBeIn($getCurrentChannels()); }); From fafd0822617f8f88d5557339890b0fba6afecc45 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 16:19:43 +0200 Subject: [PATCH 03/53] Fix typo in test --- tests/BroadcastingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 4455bfe97..5601fa207 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -237,7 +237,7 @@ expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue(); - // Use a new channel instance to delete the previously registered channels before testing the univeresal_channel helper + // Use a new channel instance to delete the previously registered channels before testing the universal_channel helper $broadcastManager->purge($driver); $broadcastManager->extend($driver, fn () => new NullBroadcaster); From c653c519282149b696ccae68d2af743239a067e5 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 16:22:41 +0200 Subject: [PATCH 04/53] BroadcastingConfigBootstrapper: make tenant manager inherit central manager's custom creators --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 66fee7043..c0b7b77a4 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -64,9 +64,17 @@ public function bootstrap(Tenant $tenant): void $this->setConfig($tenant); - // Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials + // Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials. + // TenantBroadcastManager also inherits the custom drivers registered in the original BroadcastManager, so that they can be used in tenant context as well. $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { - return new TenancyBroadcastManager($this->app); + $originalCustomCreators = invade($broadcastManager)->customCreators; + $tenantBroadcastManager = new TenancyBroadcastManager($this->app); + + foreach ($originalCustomCreators as $driver => $closure) { + $tenantBroadcastManager->extend($driver, $closure); + } + + return $tenantBroadcastManager; }); } From b1e91f1029b7244f398a4cdfb295c82a31c2bef9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 16:25:25 +0200 Subject: [PATCH 05/53] BroadcastingConfigBootstrapper: make `Broadcaster::class` resolve to tenant's broadcaster on `bootstrap()` --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index c0b7b77a4..6f0e8fbc8 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -76,6 +76,11 @@ public function bootstrap(Tenant $tenant): void return $tenantBroadcastManager; }); + + // Make the Broadcaster singleton resolve to the broadcaster of the TenantBroadcastManager so that it uses the tenant credentials + $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) { + return $this->app->make(BroadcastManager::class)->connection(); + }); } public function revert(): void From 65beecf265fc15ae60a133d659bcafe4da86fef7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 16:32:33 +0200 Subject: [PATCH 06/53] BroadcastingConfigBootstrapper: clear the `Broadcast` facade's resolved `Broadcasting\Factory` instance After initializing tenancy, calls like `Broadcast::auth()` use the central `BroadcastManager`. Clearing the facade's resolved `Broadcasting\Factory` instance fixes that problem. --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 6f0e8fbc8..41c6bc7f1 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -11,6 +11,8 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyBroadcastManager; +use Illuminate\Support\Facades\Broadcast; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; class BroadcastingConfigBootstrapper implements TenancyBootstrapper { @@ -81,6 +83,10 @@ public function bootstrap(Tenant $tenant): void $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) { return $this->app->make(BroadcastManager::class)->connection(); }); + + // Clear the resolved Broadcast facade instance so that it gets re-resolved as the tenant broadcast manager + // when used (e.g. in BroadcastController::authenticate) + Broadcast::clearResolvedInstance(BroadcastingFactory::class); } public function revert(): void @@ -89,6 +95,9 @@ public function revert(): void $this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager); $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); + // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central broadcast manager + Broadcast::clearResolvedInstance(BroadcastingFactory::class); + $this->unsetConfig(); } From 0b860ea38ab068f9aba9014c16de659e25a9a215 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 14:33:01 +0000 Subject: [PATCH 07/53] Fix code style (php-cs-fixer) --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 41c6bc7f1..df00bedae 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -7,12 +7,12 @@ use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Config\Repository; use Illuminate\Contracts\Broadcasting\Broadcaster; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Broadcast; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyBroadcastManager; -use Illuminate\Support\Facades\Broadcast; -use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; class BroadcastingConfigBootstrapper implements TenancyBootstrapper { From c4f4451fd5984135367b51bb6d13be05391a5248 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 17:10:50 +0200 Subject: [PATCH 08/53] Add assertions for the config of the bound manager's driver --- tests/BroadcastingTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 5601fa207..06855cfd7 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -70,11 +70,13 @@ $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']); $tenant2 = Tenant::create(['testing_key' => 'tenant2_key']); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); expect(Broadcast::driver()->config['key'])->toBe('central_key'); tenancy()->initialize($tenant1); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); // The Broadcast facade (used in BroadcastController::authenticate) uses the broadcaster with tenant config @@ -84,12 +86,14 @@ tenancy()->initialize($tenant2); // Switching to another tenant context makes the current broadcaster use the new tenant's config + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant2_key'); expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant2_key'); expect(Broadcast::driver()->config['key'])->toBe('tenant2_key'); tenancy()->end(); // Ending tenancy reverts the broadcaster changes + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); expect(Broadcast::driver()->config['key'])->toBe('central_key'); }); From d939866798baef6081c95e8de632a2b419b143be Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 17:11:08 +0200 Subject: [PATCH 09/53] Fix custom creator assertions --- tests/BroadcastingTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 06855cfd7..28b5296ef 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -108,33 +108,33 @@ $tenant = Tenant::create(); $tenant2 = Tenant::create(); - app(BroadcastManager::class)->extend('testing', $testingClosure = fn($app, $config) => new TestingBroadcaster('testing', $config)); + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); - $originalCustomCreators = invade(app(BroadcastManager::class))->customCreators; + $originalDrivers = array_keys(invade(app(BroadcastManager::class))->customCreators); - expect($originalCustomCreators['testing'])->toBe($testingClosure); + expect($originalDrivers)->toContain('testing'); tenancy()->initialize($tenant); app(BroadcastManager::class)->extend( 'testing-tenant1', - $testingTenant1Closure = fn($app, $config) => new TestingBroadcaster('testing-tenant1', $config) + fn($app, $config) => new TestingBroadcaster('testing-tenant1', $config) ); // Current BroadcastManager instance has the original custom creators plus the newly registered testing-tenant1 creator - expect(invade(app(BroadcastManager::class))->customCreators)->toBe($originalCustomCreators + ['testing-tenant1' => $testingTenant1Closure]); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe([...$originalDrivers, 'testing-tenant1']); tenancy()->initialize($tenant2); // Current BroadcastManager only has the original custom creators, // the creator added in the previous tenant's context doesn't persist. - expect(invade(app(BroadcastManager::class))->customCreators)->toBe($originalCustomCreators); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); tenancy()->end(); // Ending tenancy reverts the BroadcastManager binding back to the original state, // the creator registered in the tenant context doesn't persist. - expect(invade(app(BroadcastManager::class))->customCreators)->toBe($originalCustomCreators); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); }); test('new broadcasters get the channels from the previously bound broadcaster', function() { From 28b61198eda9b3b01e50bcca2a6160488296d06f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 1 Apr 2026 15:50:04 +0200 Subject: [PATCH 10/53] TenancyBroadcastManager: update docblocks --- src/Overrides/TenancyBroadcastManager.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 16e44a400..70f0bcf89 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -12,21 +12,21 @@ class TenancyBroadcastManager extends BroadcastManager { /** - * Names of broadcasters to always recreate using $this->resolve() (even when they're - * cached and available in the $broadcasters property). + * Names of broadcasters that should always be recreated using $this->resolve() + * (even when they're cached and available in the $broadcasters property to prevent + * any potential leaks between contexts) and that should inherit the original broadcaster's channels. * - * The reason for recreating the broadcasters is - * to make your app use the correct broadcaster credentials when tenancy is initialized. + * The main concern is inheriting the channels, since the channels get registered + * (e.g. in routes/channels.php) before this manager overrides the BroadcastManager instance + * and new broadcaster instances don't receive the channels automatically. */ public static array $tenantBroadcasters = ['pusher', 'ably']; /** * Override the get method so that the broadcasters in $tenantBroadcasters - * always get freshly resolved even when they're cached and available in the $broadcasters property, + * receive the original broadcaster's channels and always get freshly resolved + * even when they're cached and available in the $broadcasters property, * and that the resolved broadcaster will override the BroadcasterContract::class singleton. - * - * If there's a cached broadcaster with the same name as $name, - * give its channels to the newly resolved bootstrapper. */ protected function get($name) { From 0fbe1bc8461dccfc1b441200d6c289bb7a686e4d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 2 Apr 2026 15:24:52 +0200 Subject: [PATCH 11/53] TenancyBroadcastManager: delete `Broadcaster` singleton binding Moved binding `Broadcaster` to the bootstrapper. --- src/Overrides/TenancyBroadcastManager.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 70f0bcf89..3f1935c91 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -7,7 +7,6 @@ use Illuminate\Broadcasting\Broadcasters\Broadcaster; use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; -use Illuminate\Contracts\Foundation\Application; class TenancyBroadcastManager extends BroadcastManager { @@ -43,8 +42,6 @@ protected function get($name) $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); } - $this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster); - return $newBroadcaster; } From b6c035c912122bb7eb0cbb56adffe4a8121b2139 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 2 Apr 2026 15:28:33 +0200 Subject: [PATCH 12/53] Improve comments --- .../BroadcastingConfigBootstrapper.php | 15 ++++++--- src/Overrides/TenancyBroadcastManager.php | 31 +++++++++---------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index df00bedae..7bf1ab30d 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -66,8 +66,9 @@ public function bootstrap(Tenant $tenant): void $this->setConfig($tenant); - // Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials. - // TenantBroadcastManager also inherits the custom drivers registered in the original BroadcastManager, so that they can be used in tenant context as well. + // Make BroadcastManager resolve to TenancyBroadcastManager which always re-resolves the used broadcasters (so that + // the broadcasting credentials are always up-to-date at the point of broadcasting) and gives the channels of + // the broadcaster from the central context to the newly resolved broadcasters in tenant context. $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { $originalCustomCreators = invade($broadcastManager)->customCreators; $tenantBroadcastManager = new TenancyBroadcastManager($this->app); @@ -79,13 +80,17 @@ public function bootstrap(Tenant $tenant): void return $tenantBroadcastManager; }); - // Make the Broadcaster singleton resolve to the broadcaster of the TenantBroadcastManager so that it uses the tenant credentials + // Swap currently bound Broadcaster instance for one that's resolved through the tenant broadcast manager. + // Note that changing tenant's credentials in tenant context doesn't update them in the bound Broadcaster instance. + // If you need to e.g. send a notification in response to changing tenant's broadcasting credentials, + // it's recommended to use the broadcast() helper which always uses fresh broadcasters with the current credentials. $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) { return $this->app->make(BroadcastManager::class)->connection(); }); - // Clear the resolved Broadcast facade instance so that it gets re-resolved as the tenant broadcast manager - // when used (e.g. in BroadcastController::authenticate) + // Clear the resolved Broadcast facade's Illuminate\Contracts\Broadcasting\Factory instance + // so that it gets re-resolved as the tenant broadcast manager when used (e.g. the + // Broadcast::auth() call in BroadcastController::authenticate). Broadcast::clearResolvedInstance(BroadcastingFactory::class); } diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 3f1935c91..5f3fc4cd8 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -11,21 +11,18 @@ class TenancyBroadcastManager extends BroadcastManager { /** - * Names of broadcasters that should always be recreated using $this->resolve() - * (even when they're cached and available in the $broadcasters property to prevent - * any potential leaks between contexts) and that should inherit the original broadcaster's channels. - * - * The main concern is inheriting the channels, since the channels get registered - * (e.g. in routes/channels.php) before this manager overrides the BroadcastManager instance - * and new broadcaster instances don't receive the channels automatically. + * Names of broadcasters that + * - should always be recreated using $this->resolve(), even when they're cached and available + * in $this->drivers (so that e.g. when you update tenant's broadcaster credentials in the tenant context, + * the updated credentials will be used for broadcasting in the same context) + * - should inherit the original broadcaster's channels (= the channels registered in + * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager) */ public static array $tenantBroadcasters = ['pusher', 'ably']; /** - * Override the get method so that the broadcasters in $tenantBroadcasters - * receive the original broadcaster's channels and always get freshly resolved - * even when they're cached and available in the $broadcasters property, - * and that the resolved broadcaster will override the BroadcasterContract::class singleton. + * Override the get method so that the broadcasters in static::$tenantBroadcasters + * receive the original broadcaster's channels and always get freshly resolved. */ protected function get($name) { @@ -34,10 +31,10 @@ protected function get($name) $originalBroadcaster = $this->app->make(BroadcasterContract::class); $newBroadcaster = $this->resolve($name); - // If there is a current broadcaster, give its channels to the newly resolved one + // Give the channels of the original broadcaster (from the central context) to the newly resolved one. // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract - // Which doesn't require the channels property - // So passing the channels is only needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances + // which doesn't require the channels property, so passing the channels is only + // needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances. if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); } @@ -48,8 +45,10 @@ protected function get($name) return parent::get($name); } - // Because, unlike the original broadcaster, the newly resolved broadcaster won't have the channels registered using routes/channels.php - // Using it for broadcasting won't work, unless we make it have the original broadcaster's channels + // The newly resolved broadcasters don't automatically receive the channels registered + // in central context (e.g. in routes/channels.php), so we have to obtain the channels from the + // broadcaster used in central context and manually pass them to the new broadcasters + // (attempting to broadcast using a broadcaster with no channels results in a 403 error). protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void { // invade() because channels can't be retrieved through any of the broadcaster's public methods From bbe2ff02df260a72f1cc83dcb1aca8ef7c7987a7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 2 Apr 2026 15:33:35 +0200 Subject: [PATCH 13/53] BroadcastingTest: update channel inheritance test Test that the bound Broadcaster instance inherits the channels too. Also test that the channels aren't lost when switching context to another tenant. --- tests/BroadcastingTest.php | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 28b5296ef..305bf44b8 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -137,7 +137,7 @@ expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); }); -test('new broadcasters get the channels from the previously bound broadcaster', function() { +test('tenant broadcasters receive the channels from the broadcaster bound in central context', function() { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); config([ 'broadcasting.default' => $driver = 'testing', @@ -146,20 +146,36 @@ TenancyBroadcastManager::$tenantBroadcasters[] = $driver; + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); - $getCurrentChannels = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); + $getCurrentChannelsFromBoundBroadcaster = fn() => array_keys(invade(app(BroadcasterContract::class))->channels); + $getCurrentChannelsThroughManager = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); Broadcast::channel($channel = 'testing-channel', fn() => true); - expect($channel)->toBeIn($getCurrentChannels()); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - tenancy()->initialize(Tenant::create()); + tenancy()->initialize($tenant1); + + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); + + tenancy()->initialize($tenant2); - expect($channel)->toBeIn($getCurrentChannels()); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); tenancy()->end(); - expect($channel)->toBeIn($getCurrentChannels()); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); }); test('broadcasting channel helpers register channels correctly', function() { From b2add06a98d5e7c6b8388927970f6777aa4f064d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 2 Apr 2026 16:14:53 +0200 Subject: [PATCH 14/53] Delete BroadcastingTest Tests from BroadcastingTest moved to the appropriate bootstrapper test files. The new tenant credentials test has assertions equal to both the original property -> config mapping test and the config -> credentials test. --- ...BroadcastChannelPrefixBootstrapperTest.php | 96 +++++++ .../BroadcastingConfigBootstrapperTest.php | 137 ++++++--- tests/BroadcastingTest.php | 272 ------------------ 3 files changed, 199 insertions(+), 306 deletions(-) delete mode 100644 tests/BroadcastingTest.php diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 785430f52..40bac461a 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -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); @@ -137,3 +140,96 @@ 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.{userId}") + $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; + }); + + expect($tenantChannelClosure)->not()->toBe($centralChannelClosure); + + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + + tenancy()->initialize($tenant); + + // The channel closure runs in the central context + // Only the central user is available + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue(); + + // Use a new channel instance to delete the previously registered channels before testing the universal_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(); +}); diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 5eb987db6..f311bee46 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -10,6 +10,8 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Overrides\TenancyBroadcastManager; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; +use Illuminate\Support\Facades\Broadcast; +use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; beforeEach(function () { Event::listen(TenancyInitialized::class, BootstrapTenancy::class); @@ -38,68 +40,135 @@ expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); }); -test('BroadcastingConfigBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { +test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function() { config([ - 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + 'broadcasting.connections.testing.key' => 'central_key', + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], ]); - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; + BroadcastingConfigBootstrapper::$credentialsMap['broadcasting.connections.testing.key'] = 'testing_key'; - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + // Register the testing broadcaster + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + + $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']); + $tenant2 = Tenant::create(['testing_key' => 'tenant2_key']); + + expect(config('broadcasting.connections.testing.key'))->toBe('central_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + expect(Broadcast::driver()->config['key'])->toBe('central_key'); + + tenancy()->initialize($tenant1); + + expect(array_key_exists('testing_key', tenant()->getAttributes()))->toBeTrue(); + // Tenant's testing_key property is mapped to broadcasting.connections.testing.key config value + expect(config('broadcasting.connections.testing.key'))->toBe('tenant1_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); + // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); + // The Broadcast facade (used in BroadcastController::authenticate) uses the broadcaster with tenant config + // instead of the stale broadcaster instance resolved before tenancy was initialized + expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); + + tenancy()->initialize($tenant2); + + expect(array_key_exists('testing_key', tenant()->getAttributes()))->toBeTrue(); + expect(config('broadcasting.connections.testing.key'))->toBe('tenant2_key'); + // Switching to another tenant context makes the current broadcaster use the new tenant's config + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant2_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant2_key'); + expect(Broadcast::driver()->config['key'])->toBe('tenant2_key'); + + tenancy()->end(); + + expect(config('broadcasting.connections.testing.key'))->toBe('central_key'); + // Ending tenancy reverts the broadcaster changes + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + expect(Broadcast::driver()->config['key'])->toBe('central_key'); +}); + +test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function() { + config([ + 'tenancy.bootstrappers' => [ + BroadcastingConfigBootstrapper::class, + ], + ]); + + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + + $originalDrivers = array_keys(invade(app(BroadcastManager::class))->customCreators); + + expect($originalDrivers)->toContain('testing'); tenancy()->initialize($tenant); - expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue(); - expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); + app(BroadcastManager::class)->extend( + 'testing-tenant1', + fn($app, $config) => new TestingBroadcaster('testing-tenant1', $config) + ); + + // Current BroadcastManager instance has the original custom creators plus the newly registered testing-tenant1 creator + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe([...$originalDrivers, 'testing-tenant1']); tenancy()->initialize($tenant2); - expect(config('broadcasting.connections.testing.message'))->toBe($secondTenantMessage); + // Current BroadcastManager only has the original custom creators, + // the creator added in the previous tenant's context doesn't persist. + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); tenancy()->end(); - expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage); + // Ending tenancy reverts the BroadcastManager binding back to the original state, + // the creator registered in the tenant context doesn't persist. + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); }); -test('BroadcastingConfigBootstrapper makes the app use broadcasters with the correct credentials', function() { +test('tenant broadcasters receive the channels from the broadcaster bound in central context', function() { + config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); config([ - 'broadcasting.default' => 'testing', - 'broadcasting.connections.testing.driver' => 'testing', - 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, ]); - TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; - BroadcastingConfigBootstrapper::$credentialsMap = [ - 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', - ]; + TenancyBroadcastManager::$tenantBroadcasters[] = $driver; - $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster($config['message'])); + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); - $registerTestingBroadcaster(); + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); + $getCurrentChannelsFromBoundBroadcaster = fn() => array_keys(invade(app(BroadcasterContract::class))->channels); + $getCurrentChannelsThroughManager = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + Broadcast::channel($channel = 'testing-channel', fn() => true); - $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); - $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - tenancy()->initialize($tenant); - $registerTestingBroadcaster(); + tenancy()->initialize($tenant1); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); tenancy()->initialize($tenant2); - $registerTestingBroadcaster(); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); tenancy()->end(); - $registerTestingBroadcaster(); - expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + expect($channel) + ->toBeIn($getCurrentChannelsThroughManager()) + ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); }); diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php deleted file mode 100644 index 305bf44b8..000000000 --- a/tests/BroadcastingTest.php +++ /dev/null @@ -1,272 +0,0 @@ - [BroadcastingConfigBootstrapper::class]]); - config(['broadcasting.default' => 'null']); - TenancyBroadcastManager::$tenantBroadcasters[] = 'null'; - - $originalBroadcaster = app(BroadcasterContract::class); - - tenancy()->initialize(Tenant::create()); - - // TenancyBroadcastManager binds new broadcaster - $tenantBroadcaster = app(BroadcastManager::class)->driver(); - - expect($tenantBroadcaster)->not()->toBe($originalBroadcaster); - - tenancy()->end(); - - expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); -}); - -test('broadcasting config bootstrapper maps the config to broadcaster credentials correctly', function() { - config([ - 'broadcasting.default' => $driver = 'testing', - 'broadcasting.connections.testing.driver' => $driver, - 'broadcasting.connections.testing.key' => 'central_key', - 'tenancy.bootstrappers' => [ - BroadcastingConfigBootstrapper::class, - ], - ]); - - BroadcastingConfigBootstrapper::$credentialsMap['broadcasting.connections.testing.key'] = 'testing_key'; - - // Register the testing broadcaster - app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); - - $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']); - $tenant2 = Tenant::create(['testing_key' => 'tenant2_key']); - - expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); - expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); - expect(Broadcast::driver()->config['key'])->toBe('central_key'); - - tenancy()->initialize($tenant1); - - expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); - // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config - expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); - // The Broadcast facade (used in BroadcastController::authenticate) uses the broadcaster with tenant config - // instead of the stale broadcaster instance resolved before tenancy was initialized - expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); - - tenancy()->initialize($tenant2); - - // Switching to another tenant context makes the current broadcaster use the new tenant's config - expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant2_key'); - expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant2_key'); - expect(Broadcast::driver()->config['key'])->toBe('tenant2_key'); - - tenancy()->end(); - - // Ending tenancy reverts the broadcaster changes - expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); - expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); - expect(Broadcast::driver()->config['key'])->toBe('central_key'); -}); - -test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function() { - config([ - 'tenancy.bootstrappers' => [ - BroadcastingConfigBootstrapper::class, - ], - ]); - - $tenant = Tenant::create(); - $tenant2 = Tenant::create(); - - app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); - - $originalDrivers = array_keys(invade(app(BroadcastManager::class))->customCreators); - - expect($originalDrivers)->toContain('testing'); - - tenancy()->initialize($tenant); - - app(BroadcastManager::class)->extend( - 'testing-tenant1', - fn($app, $config) => new TestingBroadcaster('testing-tenant1', $config) - ); - - // Current BroadcastManager instance has the original custom creators plus the newly registered testing-tenant1 creator - expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe([...$originalDrivers, 'testing-tenant1']); - - tenancy()->initialize($tenant2); - - // Current BroadcastManager only has the original custom creators, - // the creator added in the previous tenant's context doesn't persist. - expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); - - tenancy()->end(); - - // Ending tenancy reverts the BroadcastManager binding back to the original state, - // the creator registered in the tenant context doesn't persist. - expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); -}); - -test('tenant broadcasters receive the channels from the broadcaster bound in central context', function() { - config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); - config([ - 'broadcasting.default' => $driver = 'testing', - 'broadcasting.connections.testing.driver' => $driver, - ]); - - TenancyBroadcastManager::$tenantBroadcasters[] = $driver; - - $tenant1 = Tenant::create(); - $tenant2 = Tenant::create(); - - app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); - $getCurrentChannelsFromBoundBroadcaster = fn() => array_keys(invade(app(BroadcasterContract::class))->channels); - $getCurrentChannelsThroughManager = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); - - Broadcast::channel($channel = 'testing-channel', fn() => true); - - expect($channel) - ->toBeIn($getCurrentChannelsThroughManager()) - ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - - tenancy()->initialize($tenant1); - - expect($channel) - ->toBeIn($getCurrentChannelsThroughManager()) - ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - - tenancy()->initialize($tenant2); - - expect($channel) - ->toBeIn($getCurrentChannelsThroughManager()) - ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); - - tenancy()->end(); - - expect($channel) - ->toBeIn($getCurrentChannelsThroughManager()) - ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); -}); - -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.{userId}") - $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; - }); - - expect($tenantChannelClosure)->not()->toBe($centralChannelClosure); - - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); - - tenancy()->initialize($tenant); - - // The channel closure runs in the central context - // Only the central user is available - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); - expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue(); - - // Use a new channel instance to delete the previously registered channels before testing the universal_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(); -}); From 9e9bedc0f27690a993845cb8a50407f69a36439d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 14:15:16 +0000 Subject: [PATCH 15/53] Fix code style (php-cs-fixer) --- src/Overrides/TenancyBroadcastManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 5f3fc4cd8..14588b1fa 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -16,7 +16,7 @@ class TenancyBroadcastManager extends BroadcastManager * in $this->drivers (so that e.g. when you update tenant's broadcaster credentials in the tenant context, * the updated credentials will be used for broadcasting in the same context) * - should inherit the original broadcaster's channels (= the channels registered in - * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager) + * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager). */ public static array $tenantBroadcasters = ['pusher', 'ably']; From 4b1cc9c84ac84bc2f2806ce580d8e29b84287a11 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 2 Apr 2026 16:29:03 +0200 Subject: [PATCH 16/53] Improve comments --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 2 +- src/Overrides/TenancyBroadcastManager.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 7bf1ab30d..5fdc7d56f 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -82,7 +82,7 @@ public function bootstrap(Tenant $tenant): void // Swap currently bound Broadcaster instance for one that's resolved through the tenant broadcast manager. // Note that changing tenant's credentials in tenant context doesn't update them in the bound Broadcaster instance. - // If you need to e.g. send a notification in response to changing tenant's broadcasting credentials, + // If you need to e.g. send a notification in response to updating tenant's broadcasting credentials in tenant context, // it's recommended to use the broadcast() helper which always uses fresh broadcasters with the current credentials. $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) { return $this->app->make(BroadcastManager::class)->connection(); diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 14588b1fa..3364bec42 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -13,7 +13,7 @@ class TenancyBroadcastManager extends BroadcastManager /** * Names of broadcasters that * - should always be recreated using $this->resolve(), even when they're cached and available - * in $this->drivers (so that e.g. when you update tenant's broadcaster credentials in the tenant context, + * in $this->drivers (so that e.g. when you update broadcasting credentials in the tenant context, * the updated credentials will be used for broadcasting in the same context) * - should inherit the original broadcaster's channels (= the channels registered in * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager). @@ -22,7 +22,7 @@ class TenancyBroadcastManager extends BroadcastManager /** * Override the get method so that the broadcasters in static::$tenantBroadcasters - * receive the original broadcaster's channels and always get freshly resolved. + * receive the original (central) broadcaster's channels and always get freshly resolved. */ protected function get($name) { @@ -31,7 +31,7 @@ protected function get($name) $originalBroadcaster = $this->app->make(BroadcasterContract::class); $newBroadcaster = $this->resolve($name); - // Give the channels of the original broadcaster (from the central context) to the newly resolved one. + // Give the channels of the original (central) broadcaster to the newly resolved one. // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract // which doesn't require the channels property, so passing the channels is only // needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances. @@ -46,8 +46,8 @@ protected function get($name) } // The newly resolved broadcasters don't automatically receive the channels registered - // in central context (e.g. in routes/channels.php), so we have to obtain the channels from the - // broadcaster used in central context and manually pass them to the new broadcasters + // in central context (e.g. in routes/channels.php), so the channels have to be obtained from the + // broadcaster used in central context and manually passed to the new broadcasters // (attempting to broadcast using a broadcaster with no channels results in a 403 error). protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void { From fc45e09dc981400b1532cb9ee12bae498b31ed92 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 11:17:06 +0200 Subject: [PATCH 17/53] Update BroadcastingConfigBootstrapperTest Tests now use datasets with all drivers that are in `TenancyBroadcastManager::$tenantBroadcasters` by default plus the custom driver. Also add assertions for updating the tenant properties/config in tenant context. --- .../BroadcastingConfigBootstrapperTest.php | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index f311bee46..2773c3f27 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -40,34 +40,38 @@ expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); }); -test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function() { +test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function(string $driver) { config([ - 'broadcasting.default' => $driver = 'testing', - 'broadcasting.connections.testing.driver' => $driver, - 'broadcasting.connections.testing.key' => 'central_key', + 'broadcasting.default' => $driver, + "broadcasting.connections.{$driver}.key" => 'central_key', 'tenancy.bootstrappers' => [ BroadcastingConfigBootstrapper::class, ], ]); - BroadcastingConfigBootstrapper::$credentialsMap['broadcasting.connections.testing.key'] = 'testing_key'; + if ($driver === 'custom') { + config(['broadcasting.connections.custom.driver' => 'custom']); - // Register the testing broadcaster - app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + // Custom driver, not included in TenancyBroadcastManager::$tenantBroadcasters by default + TenancyBroadcastManager::$tenantBroadcasters = ['custom']; + } + + BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'testing_key'; + + app(BroadcastManager::class)->extend($driver, fn ($app, $config) => new TestingBroadcaster('testing', $config)); $tenant1 = Tenant::create(['testing_key' => 'tenant1_key']); $tenant2 = Tenant::create(['testing_key' => 'tenant2_key']); - expect(config('broadcasting.connections.testing.key'))->toBe('central_key'); + expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); expect(Broadcast::driver()->config['key'])->toBe('central_key'); tenancy()->initialize($tenant1); - expect(array_key_exists('testing_key', tenant()->getAttributes()))->toBeTrue(); // Tenant's testing_key property is mapped to broadcasting.connections.testing.key config value - expect(config('broadcasting.connections.testing.key'))->toBe('tenant1_key'); + expect(config("broadcasting.connections.{$driver}.key"))->toBe('tenant1_key'); expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); @@ -77,21 +81,47 @@ tenancy()->initialize($tenant2); - expect(array_key_exists('testing_key', tenant()->getAttributes()))->toBeTrue(); - expect(config('broadcasting.connections.testing.key'))->toBe('tenant2_key'); + expect(config("broadcasting.connections.{$driver}.key"))->toBe('tenant2_key'); // Switching to another tenant context makes the current broadcaster use the new tenant's config expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant2_key'); expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant2_key'); expect(Broadcast::driver()->config['key'])->toBe('tenant2_key'); + $tenant2->update(['testing_key' => 'new_tenant2_key']); + + // Reinitialize tenancy to apply the tenant property update to config + tenancy()->end(); + tenancy()->initialize($tenant2); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('new_tenant2_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('new_tenant2_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('new_tenant2_key'); + expect(Broadcast::driver()->config['key'])->toBe('new_tenant2_key'); + + tenancy()->initialize($tenant1); + + // When updating tenant properties without reinitializing, the tenant property update doesn't update the config, + // so the config has to be modified manually. Only methods that use TenancyBroadcastManager::get() + // will use the updated credentials without needing to reinitialize tenancy (e.g. the bound + // BroadcasterContract instance will still the original credentials, even after config gets updated directly). + config(["broadcasting.connections.{$driver}.key" => 'new_tenant1_key']); + + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('new_tenant1_key'); + expect(Broadcast::driver()->config['key'])->toBe('new_tenant1_key'); + tenancy()->end(); - expect(config('broadcasting.connections.testing.key'))->toBe('central_key'); + expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); // Ending tenancy reverts the broadcaster changes expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); expect(Broadcast::driver()->config['key'])->toBe('central_key'); -}); +})->with([ + 'pusher', + 'ably', + // 'reverb', + 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default +]); test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function() { config([ @@ -132,19 +162,23 @@ expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); }); -test('tenant broadcasters receive the channels from the broadcaster bound in central context', function() { - config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); +test('tenant broadcasters receive the channels from the broadcaster bound in central context', function(string $driver) { config([ - 'broadcasting.default' => $driver = 'testing', - 'broadcasting.connections.testing.driver' => $driver, + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => $driver, ]); - TenancyBroadcastManager::$tenantBroadcasters[] = $driver; + if ($driver === 'custom') { + config(['broadcasting.connections.custom.driver' => 'custom']); + + // Custom driver, not included in TenancyBroadcastManager::$tenantBroadcasters by default + TenancyBroadcastManager::$tenantBroadcasters = ['custom']; + } $tenant1 = Tenant::create(); $tenant2 = Tenant::create(); - app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); + app(BroadcastManager::class)->extend($driver, fn($app, $config) => new TestingBroadcaster('testing')); $getCurrentChannelsFromBoundBroadcaster = fn() => array_keys(invade(app(BroadcasterContract::class))->channels); $getCurrentChannelsThroughManager = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); @@ -171,4 +205,9 @@ expect($channel) ->toBeIn($getCurrentChannelsThroughManager()) ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); -}); +})->with([ + 'pusher', + 'ably', + // 'reverb', + 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default +]); From 6b99921839917320df5d70552e6aa6b60cb1ffaa Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 11:23:24 +0200 Subject: [PATCH 18/53] BroadcastingConfigBootstrapper: correct `$credentialsMap` array_merge order Previously, credential mappings from `$mapPresets` overrode mappings defined in `$credentialsMap`. If someone used pusher/reverb/ably and wanted to override some of that preset's mappings, e.g. use 'pusher_app_key' instead of 'pusher_key' by specifying 'pusher_app_key' in `$credentialsMap`, the preset's mapping ('pusher_key') would still be used. --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 5fdc7d56f..277db58f6 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -56,7 +56,7 @@ 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 From 29dd23db612d08d5a03959905afa13bf9a46acea Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 11:26:46 +0200 Subject: [PATCH 19/53] BroadcastingConfigBootstrapperTest: add 'reverb' driver to datasets Adding 'reverb' to `TenancyBroadcastManager::$tenantBroadcasters` will make these tests pass. --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 2773c3f27..a0a6b6fa1 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -119,7 +119,7 @@ })->with([ 'pusher', 'ably', - // 'reverb', + 'reverb', 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default ]); @@ -208,6 +208,6 @@ })->with([ 'pusher', 'ably', - // 'reverb', + 'reverb', 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default ]); From 4937a74ed5c44ecefc90bd8a52a458a894c9831c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 13:07:40 +0200 Subject: [PATCH 20/53] BroadcastingConfigBootstrapper and TenancyBroadcastManager: comments --- .../BroadcastingConfigBootstrapper.php | 25 +++++++++++++------ src/Overrides/TenancyBroadcastManager.php | 23 +++++++++++++---- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 277db58f6..b0df125b9 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -14,6 +14,12 @@ use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyBroadcastManager; +/** + * Maps tenant properties to broadcasting config and overrides + * the BroadcastManager binding with TenancyBroadcastManager in tenant context. + * + * @see TenancyBroadcastManager + */ class BroadcastingConfigBootstrapper implements TenancyBootstrapper { /** @@ -66,13 +72,15 @@ public function bootstrap(Tenant $tenant): void $this->setConfig($tenant); - // Make BroadcastManager resolve to TenancyBroadcastManager which always re-resolves the used broadcasters (so that - // the broadcasting credentials are always up-to-date at the point of broadcasting) and gives the channels of - // the broadcaster from the central context to the newly resolved broadcasters in tenant context. + // Make BroadcastManager resolve to TenancyBroadcastManager which always re-resolves the used broadcasters so that + // the credentials used by broadcasters are always up-to-date with the config when retrieving the broadcasters using + // the manager and gives the channels of the broadcaster from central context to the newly resolved broadcasters in tenant context. $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { $originalCustomCreators = invade($broadcastManager)->customCreators; $tenantBroadcastManager = new TenancyBroadcastManager($this->app); + // TenancyBroadcastManager inherits the custom driver creators registered in the central context so that + // custom drivers work in tenant context without having to re-register the creators manually. foreach ($originalCustomCreators as $driver => $closure) { $tenantBroadcastManager->extend($driver, $closure); } @@ -81,16 +89,17 @@ public function bootstrap(Tenant $tenant): void }); // Swap currently bound Broadcaster instance for one that's resolved through the tenant broadcast manager. - // Note that changing tenant's credentials in tenant context doesn't update them in the bound Broadcaster instance. - // If you need to e.g. send a notification in response to updating tenant's broadcasting credentials in tenant context, - // it's recommended to use the broadcast() helper which always uses fresh broadcasters with the current credentials. + // Note that updating broadcasting config (credentials) in tenant context doesn't update the credentials + // used by the bound Broadcaster instance. If you need to e.g. send a notification in response to + // updating tenant's broadcasting credentials in tenant context, it's recommended to + // reinitialize tenancy after updating the credentials. $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) { return $this->app->make(BroadcastManager::class)->connection(); }); // Clear the resolved Broadcast facade's Illuminate\Contracts\Broadcasting\Factory instance - // so that it gets re-resolved as the tenant broadcast manager when used (e.g. the - // Broadcast::auth() call in BroadcastController::authenticate). + // so that it gets re-resolved as TenancyBroadcastManager instead of the central BroadcastManager + // when used e.g. in the Broadcast::auth() call in BroadcastController::authenticate (/broadcasting/auth). Broadcast::clearResolvedInstance(BroadcastingFactory::class); } diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 3364bec42..bc74e5393 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -8,13 +8,26 @@ use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; +/** + * BroadcastManager override that always re-resolves the broadcasters in static::$tenantBroadcasters + * when attempting to retrieve them and passes the channels of the original (central) broadcaster + * to the broadcasters newly resolved in tenant context. + * + * Affects calls that use app(BroadcastManager::class)->get(). + * + * @see Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper + */ class TenancyBroadcastManager extends BroadcastManager { /** * Names of broadcasters that * - should always be recreated using $this->resolve(), even when they're cached and available - * in $this->drivers (so that e.g. when you update broadcasting credentials in the tenant context, - * the updated credentials will be used for broadcasting in the same context) + * in $this->drivers so that when you update broadcasting config in the tenant context, + * the updated config/credentials will be used for broadcasting in the same context. + * Note that in cases like this, only direct config changes are reflected immediately. + * For the broadcasters to reflect tenant property changes made in tenant context, + * you still have to reinitialize tenancy after updating the tenant properties intended + * for broadcasting config mapping, since the properties are only mapped to config on BroadcastingConfigBootstrapper::bootstrap(). * - should inherit the original broadcaster's channels (= the channels registered in * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager). */ @@ -46,9 +59,9 @@ protected function get($name) } // The newly resolved broadcasters don't automatically receive the channels registered - // in central context (e.g. in routes/channels.php), so the channels have to be obtained from the - // broadcaster used in central context and manually passed to the new broadcasters - // (attempting to broadcast using a broadcaster with no channels results in a 403 error). + // in central context (e.g. Broadcast::channel() in routes/channels.php), so the channels + // have to be obtained from the original (central) broadcaster and manually passed to the new broadcasters + // (broadcasting using a broadcaster with no channels results in a 403 error on Broadcast::auth()). protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void { // invade() because channels can't be retrieved through any of the broadcaster's public methods From c831393589041da0b1aa7b1cd60d0b4e6241abc1 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 13:10:19 +0200 Subject: [PATCH 21/53] Update comment --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index b0df125b9..8ae13002f 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -16,7 +16,7 @@ /** * Maps tenant properties to broadcasting config and overrides - * the BroadcastManager binding with TenancyBroadcastManager in tenant context. + * the BroadcastManager binding with TenancyBroadcastManager. * * @see TenancyBroadcastManager */ From ef476c536133174fd621ca2b2a5870c37a927893 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 13:40:44 +0200 Subject: [PATCH 22/53] Polish comments --- .../BroadcastingConfigBootstrapper.php | 6 ++--- src/Overrides/TenancyBroadcastManager.php | 27 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 8ae13002f..6b90435d5 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -88,7 +88,7 @@ public function bootstrap(Tenant $tenant): void return $tenantBroadcastManager; }); - // Swap currently bound Broadcaster instance for one that's resolved through the tenant broadcast manager. + // Swap currently bound Broadcaster instance for one that's resolved through the tenant BroadcastManager. // Note that updating broadcasting config (credentials) in tenant context doesn't update the credentials // used by the bound Broadcaster instance. If you need to e.g. send a notification in response to // updating tenant's broadcasting credentials in tenant context, it's recommended to @@ -99,7 +99,7 @@ public function bootstrap(Tenant $tenant): void // Clear the resolved Broadcast facade's Illuminate\Contracts\Broadcasting\Factory instance // so that it gets re-resolved as TenancyBroadcastManager instead of the central BroadcastManager - // when used e.g. in the Broadcast::auth() call in BroadcastController::authenticate (/broadcasting/auth). + // when used. E.g. the Broadcast::auth() call in BroadcastController::authenticate (/broadcasting/auth). Broadcast::clearResolvedInstance(BroadcastingFactory::class); } @@ -109,7 +109,7 @@ public function revert(): void $this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager); $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); - // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central broadcast manager + // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager Broadcast::clearResolvedInstance(BroadcastingFactory::class); $this->unsetConfig(); diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index bc74e5393..0ff1e0bfc 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -11,7 +11,7 @@ /** * BroadcastManager override that always re-resolves the broadcasters in static::$tenantBroadcasters * when attempting to retrieve them and passes the channels of the original (central) broadcaster - * to the broadcasters newly resolved in tenant context. + * to the newly resolved (tenant) broadcasters. * * Affects calls that use app(BroadcastManager::class)->get(). * @@ -23,11 +23,12 @@ class TenancyBroadcastManager extends BroadcastManager * Names of broadcasters that * - should always be recreated using $this->resolve(), even when they're cached and available * in $this->drivers so that when you update broadcasting config in the tenant context, - * the updated config/credentials will be used for broadcasting in the same context. - * Note that in cases like this, only direct config changes are reflected immediately. + * the updated config/credentials will be used for broadcasting immediately. + * Note that in cases like this, only direct config changes are reflected right away. * For the broadcasters to reflect tenant property changes made in tenant context, * you still have to reinitialize tenancy after updating the tenant properties intended - * for broadcasting config mapping, since the properties are only mapped to config on BroadcastingConfigBootstrapper::bootstrap(). + * to be mapped to broadcasting config, since the properties are only mapped to config + * on BroadcastingConfigBootstrapper::bootstrap(). * - should inherit the original broadcaster's channels (= the channels registered in * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager). */ @@ -35,7 +36,8 @@ class TenancyBroadcastManager extends BroadcastManager /** * Override the get method so that the broadcasters in static::$tenantBroadcasters - * receive the original (central) broadcaster's channels and always get freshly resolved. + * - receive the original (central) broadcaster's channels + * - always get freshly resolved. */ protected function get($name) { @@ -45,9 +47,10 @@ protected function get($name) $newBroadcaster = $this->resolve($name); // Give the channels of the original (central) broadcaster to the newly resolved one. + // // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract - // which doesn't require the channels property, so passing the channels is only - // needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances. + // which doesn't require the channels property, so passing the channels is only needed for + // Illuminate\Broadcasting\Broadcasters\Broadcaster instances (= all the default broadcasters, e.g. PusherBroadcaster). if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); } @@ -58,10 +61,12 @@ protected function get($name) return parent::get($name); } - // The newly resolved broadcasters don't automatically receive the channels registered - // in central context (e.g. Broadcast::channel() in routes/channels.php), so the channels - // have to be obtained from the original (central) broadcaster and manually passed to the new broadcasters - // (broadcasting using a broadcaster with no channels results in a 403 error on Broadcast::auth()). + /** + * The newly resolved broadcasters don't automatically receive the channels registered + * in central context (e.g. Broadcast::channel() in routes/channels.php), so the channels + * have to be obtained from the original (central) broadcaster and manually passed to the new broadcasters + * (broadcasting using a broadcaster with no channels results in a 403 error on Broadcast::auth()). + */ protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void { // invade() because channels can't be retrieved through any of the broadcaster's public methods From f8528fc9ac6ae42a4c5afd8b22b795e0c7f61cba Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 3 Apr 2026 15:54:25 +0200 Subject: [PATCH 23/53] Add 'reverb' to `TenancyBroadcastManager::$tenantBroadcasters` --- src/Overrides/TenancyBroadcastManager.php | 2 +- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 0ff1e0bfc..6454156b6 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -32,7 +32,7 @@ class TenancyBroadcastManager extends BroadcastManager * - should inherit the original broadcaster's channels (= the channels registered in * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager). */ - public static array $tenantBroadcasters = ['pusher', 'ably']; + public static array $tenantBroadcasters = ['pusher', 'ably', 'reverb']; /** * Override the get method so that the broadcasters in static::$tenantBroadcasters diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index a0a6b6fa1..0ced35e20 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -18,12 +18,12 @@ Event::listen(TenancyEnded::class, RevertToCentralContext::class); BroadcastingConfigBootstrapper::$credentialsMap = []; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; + TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }); afterEach(function () { BroadcastingConfigBootstrapper::$credentialsMap = []; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; + TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }); test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { From 9ab0a729d6477ff72c6fed2b5c619982ff204d0e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 15:28:11 +0200 Subject: [PATCH 24/53] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 0ced35e20..93c0a81a2 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -103,7 +103,7 @@ // When updating tenant properties without reinitializing, the tenant property update doesn't update the config, // so the config has to be modified manually. Only methods that use TenancyBroadcastManager::get() // will use the updated credentials without needing to reinitialize tenancy (e.g. the bound - // BroadcasterContract instance will still the original credentials, even after config gets updated directly). + // BroadcasterContract instance will still use the original credentials, even after config gets updated directly). config(["broadcasting.connections.{$driver}.key" => 'new_tenant1_key']); expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('new_tenant1_key'); From 4aeaa66b23d3076acf584d30e6ed83fdc175ddd3 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 15:32:26 +0200 Subject: [PATCH 25/53] Remove unused `$broadcaster` parameter --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 6b90435d5..e9685ef5e 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -93,7 +93,7 @@ public function bootstrap(Tenant $tenant): void // used by the bound Broadcaster instance. If you need to e.g. send a notification in response to // updating tenant's broadcasting credentials in tenant context, it's recommended to // reinitialize tenancy after updating the credentials. - $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) { + $this->app->extend(Broadcaster::class, function () { return $this->app->make(BroadcastManager::class)->connection(); }); From b3d5197702df7f07fffc03b9e7dd3ea1e0e89733 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 15:44:35 +0200 Subject: [PATCH 26/53] Correct channel prefix test comments --- .../BroadcastChannelPrefixBootstrapperTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 40bac461a..3e93cba72 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -215,12 +215,12 @@ protected function formatChannels(array $channels) tenancy()->initialize($tenant); - // The channel closure runs in the central context - // Only the central user is available + // 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(); - // Use a new channel instance to delete the previously registered channels before testing the universal_channel helper + // 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); From a247bb0b9e0cc80a16be90b4e462c85b0c4a6f3e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 13 Apr 2026 15:48:03 +0200 Subject: [PATCH 27/53] Correct config bootstrapper test comment --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 93c0a81a2..965fbb6d2 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -70,7 +70,7 @@ tenancy()->initialize($tenant1); - // Tenant's testing_key property is mapped to broadcasting.connections.testing.key config value + // Tenant's testing_key property is mapped to the current broadcasting connection key expect(config("broadcasting.connections.{$driver}.key"))->toBe('tenant1_key'); expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); // Switching to tenant context makes the currently bound Broadcaster instance use the tenant's config From 20d494f4c6c268b3dd3585ffe5bc44197c2fa994 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 09:09:46 +0200 Subject: [PATCH 28/53] Reset `BroadcastingConfigBootstrapper::$broadcaster` in tests --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 965fbb6d2..7eb8b43b0 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -17,11 +17,13 @@ Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); + BroadcastingConfigBootstrapper::$broadcaster = null; BroadcastingConfigBootstrapper::$credentialsMap = []; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }); afterEach(function () { + BroadcastingConfigBootstrapper::$broadcaster = null; BroadcastingConfigBootstrapper::$credentialsMap = []; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }); From 7af2b8acb48798551920be58d2da38b64a6d1e0b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 14 Apr 2026 09:26:34 +0200 Subject: [PATCH 29/53] Improve BroadcastManager instance checks --- .../Bootstrappers/BroadcastingConfigBootstrapperTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 7eb8b43b0..6fc21e5b4 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -31,7 +31,9 @@ test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + expect(app(BroadcastManager::class)) + ->toBeInstanceOf(BroadcastManager::class) + ->not()->toBeInstanceOf(TenancyBroadcastManager::class); tenancy()->initialize(Tenant::create()); @@ -39,7 +41,9 @@ tenancy()->end(); - expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + expect(app(BroadcastManager::class)) + ->toBeInstanceOf(BroadcastManager::class) + ->not()->toBeInstanceOf(TenancyBroadcastManager::class); }); test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function(string $driver) { From a3c556870a854f591a4274db1cd410b76c632aa0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 15 Apr 2026 10:16:22 +0200 Subject: [PATCH 30/53] Add regression test for credentialsMap merge order --- .../BroadcastingConfigBootstrapperTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 6fc21e5b4..2b2a81d96 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -217,3 +217,29 @@ 'reverb', 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default ]); + +test('mappings specified in credentialsMap override default mapPresets', function($driver) { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => $driver, + ]); + + // Preset used for broadcasters included in TenancyBroadcastManager::$tenantBroadcasters by default. + // This is the default for all tenant broadcasters, but we set it here for clarity. + BroadcastingConfigBootstrapper::$mapPresets[$driver]["broadcasting.connections.{$driver}.key"] = "{$driver}_key"; + + // Custom mapping specified in credentialsMap should override the preset mapping for the tested broadcaster + BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'broadcasting_key'; + + app(BroadcastManager::class)->extend($driver, fn($app, $config) => new TestingBroadcaster('testing')); + + $tenant = Tenant::create([ + "{$driver}_key" => 'preset_value', + 'broadcasting_key' => 'custom_value', + ]); + + + tenancy()->initialize($tenant); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('custom_value'); +})->with(['pusher', 'ably', 'reverb']); From f3652a85098dc2ae155fbd9e82583cc3b9d6205b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 15 Apr 2026 10:18:29 +0200 Subject: [PATCH 31/53] Make `with()` formatting consistent --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 2b2a81d96..efb0aaf03 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -242,4 +242,8 @@ tenancy()->initialize($tenant); expect(config("broadcasting.connections.{$driver}.key"))->toBe('custom_value'); -})->with(['pusher', 'ably', 'reverb']); +})->with([ + 'pusher', + 'ably', + 'reverb', +]); From a7acd07e7082f73e7df421799a176fd3bb3435a7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 15 Apr 2026 10:44:18 +0200 Subject: [PATCH 32/53] Reset `BroadcastingConfigBootstrapper::$mapPresets` in tests --- .../BroadcastingConfigBootstrapperTest.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index efb0aaf03..8e58e5797 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -19,12 +19,48 @@ BroadcastingConfigBootstrapper::$broadcaster = null; BroadcastingConfigBootstrapper::$credentialsMap = []; + BroadcastingConfigBootstrapper::$mapPresets = [ + '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', + ], + ]; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }); afterEach(function () { BroadcastingConfigBootstrapper::$broadcaster = null; BroadcastingConfigBootstrapper::$credentialsMap = []; + BroadcastingConfigBootstrapper::$mapPresets = [ + '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', + ], + ]; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }); From 7db486d1b989713a2d2c0858dbe18a837a31cb18 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 15 Apr 2026 11:12:59 +0200 Subject: [PATCH 33/53] Extract cleanup in broadcasting config test file --- .../BroadcastingConfigBootstrapperTest.php | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 8e58e5797..2ba49a6f4 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -13,10 +13,7 @@ use Illuminate\Support\Facades\Broadcast; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; -beforeEach(function () { - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - +$cleanup = function () { BroadcastingConfigBootstrapper::$broadcaster = null; BroadcastingConfigBootstrapper::$credentialsMap = []; BroadcastingConfigBootstrapper::$mapPresets = [ @@ -38,32 +35,17 @@ ], ]; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; -}); +}; -afterEach(function () { - BroadcastingConfigBootstrapper::$broadcaster = null; - BroadcastingConfigBootstrapper::$credentialsMap = []; - BroadcastingConfigBootstrapper::$mapPresets = [ - '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', - ], - ]; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; +beforeEach(function () use ($cleanup) { + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + $cleanup(); }); +afterEach($cleanup); + test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); From d885659a16b45d7daaf5c9df2346c7828d7d8948 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 15 Apr 2026 11:20:59 +0200 Subject: [PATCH 34/53] Use toEqualCanonicalizing instead of toBe for custom creators The order of the array items shouldn't matter. --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 2ba49a6f4..b2074248c 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -171,19 +171,19 @@ ); // Current BroadcastManager instance has the original custom creators plus the newly registered testing-tenant1 creator - expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe([...$originalDrivers, 'testing-tenant1']); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing([...$originalDrivers, 'testing-tenant1']); tenancy()->initialize($tenant2); // Current BroadcastManager only has the original custom creators, // the creator added in the previous tenant's context doesn't persist. - expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); tenancy()->end(); // Ending tenancy reverts the BroadcastManager binding back to the original state, // the creator registered in the tenant context doesn't persist. - expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toBe($originalDrivers); + expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); }); test('tenant broadcasters receive the channels from the broadcaster bound in central context', function(string $driver) { From ddd8c68fbda6ce3f703c9d9fc1b5ab0fc8980bd9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 21 Apr 2026 13:25:05 +0200 Subject: [PATCH 35/53] Improve comments --- .../BroadcastingConfigBootstrapper.php | 19 ++++++++++++------- src/Overrides/TenancyBroadcastManager.php | 7 ++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index e9685ef5e..bf3927285 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -29,6 +29,8 @@ 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 = []; @@ -72,15 +74,18 @@ public function bootstrap(Tenant $tenant): void $this->setConfig($tenant); - // Make BroadcastManager resolve to TenancyBroadcastManager which always re-resolves the used broadcasters so that - // the credentials used by broadcasters are always up-to-date with the config when retrieving the broadcasters using - // the manager and gives the channels of the broadcaster from central context to the newly resolved broadcasters in tenant context. + // Make BroadcastManager resolve to TenancyBroadcastManager. The manager: + // - resolves fresh broadcasters so that the updated (tenant) broadcasting config is used while broadcasting + // - makes the tenant broadcasters inherit the channels of the original (central) broadcaster + // (since newly resolved broadcasters don't receive any channels by default, broadcasting on + // channels registered in central context, e.g. in routes/channels.php, would otherwise not + // work with the tenant broadcasters) $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { $originalCustomCreators = invade($broadcastManager)->customCreators; $tenantBroadcastManager = new TenancyBroadcastManager($this->app); - // TenancyBroadcastManager inherits the custom driver creators registered in the central context so that - // custom drivers work in tenant context without having to re-register the creators manually. + // Make TenancyBroadcastManager inherit the custom driver creators registered in the central context + // so that custom drivers work in tenant context without having to re-register the creators manually. foreach ($originalCustomCreators as $driver => $closure) { $tenantBroadcastManager->extend($driver, $closure); } @@ -105,8 +110,8 @@ public function bootstrap(Tenant $tenant): void 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); + // Revert the bound BroadcastManager and Broadcaster singletons back to their original state + $this->app->singleton(BroadcastManager::class, fn (Application $app): ?BroadcastManager => $this->originalBroadcastManager); $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 6454156b6..3d77cc4a4 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -10,10 +10,11 @@ /** * BroadcastManager override that always re-resolves the broadcasters in static::$tenantBroadcasters - * when attempting to retrieve them and passes the channels of the original (central) broadcaster + * when attempting to retrieve them so that they use the updated tenant-specific config + * and passes the channels of the original (central) broadcaster * to the newly resolved (tenant) broadcasters. * - * Affects calls that use app(BroadcastManager::class)->get(). + * Affects calls that use BroadcastManager's get() method. * * @see Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper */ @@ -49,7 +50,7 @@ protected function get($name) // Give the channels of the original (central) broadcaster to the newly resolved one. // // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract - // which doesn't require the channels property, so passing the channels is only needed for + // which doesn't require the channels property, so we only pass the channels to // Illuminate\Broadcasting\Broadcasters\Broadcaster instances (= all the default broadcasters, e.g. PusherBroadcaster). if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); From 614344af5202445a976f9a60a79972a60b6a9f3d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 29 Jun 2026 13:03:00 +0200 Subject: [PATCH 36/53] Add test for reverting the bound broadcaster when tenancy ends The original BroadcastingTest file had a very similar test that was removed during a refactor. Added it back and improved it (used the testing broadcaster instead of NullBroadcaster, improved the test's name comments) --- .../BroadcastingConfigBootstrapperTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index b2074248c..77f153cfb 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -64,6 +64,29 @@ ->not()->toBeInstanceOf(TenancyBroadcastManager::class); }); +test('ending tenancy reverts the bound broadcaster to the original instance', function() { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + ]); + TenancyBroadcastManager::$tenantBroadcasters = ['testing']; + + app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster('testing', $config)); + + $originalBroadcaster = app(BroadcasterContract::class); + + tenancy()->initialize(Tenant::create()); + + // BroadcastingConfigBootstrapper binds a freshly resolved broadcaster + expect(app(BroadcasterContract::class))->not()->toBe($originalBroadcaster); + + tenancy()->end(); + + // Ending tenancy reverts the binding back to the original broadcaster instance + expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); +}); + test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function(string $driver) { config([ 'broadcasting.default' => $driver, From 9af173553e6551d6de012fd7cec25c200fc2113f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 10 Jul 2026 10:42:29 +0200 Subject: [PATCH 37/53] Explain why we swap the bound Broadcaster singleton better, remove the invalid "notification sending" example --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index bf3927285..0f1c632ae 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -93,11 +93,12 @@ public function bootstrap(Tenant $tenant): void return $tenantBroadcastManager; }); - // Swap currently bound Broadcaster instance for one that's resolved through the tenant BroadcastManager. - // Note that updating broadcasting config (credentials) in tenant context doesn't update the credentials - // used by the bound Broadcaster instance. If you need to e.g. send a notification in response to - // updating tenant's broadcasting credentials in tenant context, it's recommended to - // reinitialize tenancy after updating the credentials. + // Swap the currently bound Broadcaster singleton (resolved earlier with the central credentials) + // for one resolved through the tenant BroadcastManager, so that anything resolving the Broadcaster + // contract gets a broadcaster that uses the tenant's credentials instead of the stale central one. + // Unlike broadcasters resolved through the manager (re-resolved on each call), this instance is + // resolved once, so credential changes made later in tenant context don't affect it until tenancy + // is reinitialized. $this->app->extend(Broadcaster::class, function () { return $this->app->make(BroadcastManager::class)->connection(); }); From 937c8c87d981854ed3e7ca4a3a382a4f056d4f32 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 10 Jul 2026 11:07:30 +0200 Subject: [PATCH 38/53] Delete the redundant BroadcastingFactory::class arg from the Broadcast::clearResolvedInstance calls Also in bootstrap(), update the clearResolvedInstance comment. Remove the FQCN mention and explain why exactly do we need to clear the resolved instance --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 0f1c632ae..f9ccb1128 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -7,7 +7,6 @@ use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Config\Repository; use Illuminate\Contracts\Broadcasting\Broadcaster; -use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Broadcast; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -103,10 +102,10 @@ public function bootstrap(Tenant $tenant): void return $this->app->make(BroadcastManager::class)->connection(); }); - // Clear the resolved Broadcast facade's Illuminate\Contracts\Broadcasting\Factory instance - // so that it gets re-resolved as TenancyBroadcastManager instead of the central BroadcastManager - // when used. E.g. the Broadcast::auth() call in BroadcastController::authenticate (/broadcasting/auth). - Broadcast::clearResolvedInstance(BroadcastingFactory::class); + // Extending the binding doesn't update the Broadcast facade's cached instance, + // so clear it to make the facade re-resolve to TenancyBroadcastManager instead of the central + // BroadcastManager — e.g. in the Broadcast::auth() call in BroadcastController (/broadcasting/auth). + Broadcast::clearResolvedInstance(); } public function revert(): void @@ -116,7 +115,7 @@ public function revert(): void $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager - Broadcast::clearResolvedInstance(BroadcastingFactory::class); + Broadcast::clearResolvedInstance(); $this->unsetConfig(); } From 24aeb23431f9fac1104ea98e8f034be724aba8dc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 10 Jul 2026 11:28:09 +0200 Subject: [PATCH 39/53] Remove unused `$app` param and `?BroadcastManager` typehint (`singleton(...)` calls in `BroadcastingConfigBootstrapper::revert`) --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index f9ccb1128..93f4366f4 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -111,8 +111,8 @@ public function bootstrap(Tenant $tenant): void public function revert(): void { // Revert the bound BroadcastManager and Broadcaster singletons back to their original state - $this->app->singleton(BroadcastManager::class, fn (Application $app): ?BroadcastManager => $this->originalBroadcastManager); - $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); + $this->app->singleton(BroadcastManager::class, fn () => $this->originalBroadcastManager); + $this->app->singleton(Broadcaster::class, fn () => $this->originalBroadcaster); // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager Broadcast::clearResolvedInstance(); From 2a0464d694dc12b819824394fa8b3f1918121314 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 10 Jul 2026 15:06:03 +0200 Subject: [PATCH 40/53] Resolve (and cache) tenant broadcasters once per tenancy initialization instead of re-resolving them every time TenancyBroadcastManager now overrides resolve() (only to pass the central channels to the newly resolved broadcasters) instead of get(), so broadcasters stay cached for the duration of the tenant's context. Remove TenancyBroadcastManager::$tenantBroadcasters -- all broadcasters now inherit the central channels, so even custom drivers work without any additional configuration/registration. Channels registered via Broadcast::channel() in tenant context are no longer lost on subsequent broadcaster retrievals (e.g. during /broadcasting/auth). Added a dedicated test for this, --- .../BroadcastingConfigBootstrapper.php | 14 ++-- src/Overrides/TenancyBroadcastManager.php | 55 +++++----------- .../BroadcastingConfigBootstrapperTest.php | 64 ++++++++++++++----- 3 files changed, 71 insertions(+), 62 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 93f4366f4..a421e85f6 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -15,7 +15,7 @@ /** * Maps tenant properties to broadcasting config and overrides - * the BroadcastManager binding with TenancyBroadcastManager. + * the BroadcastManager and Broadcaster bindings with tenant-aware instances. * * @see TenancyBroadcastManager */ @@ -73,8 +73,9 @@ public function bootstrap(Tenant $tenant): void $this->setConfig($tenant); - // Make BroadcastManager resolve to TenancyBroadcastManager. The manager: - // - resolves fresh broadcasters so that the updated (tenant) broadcasting config is used while broadcasting + // Make BroadcastManager resolve to a fresh TenancyBroadcastManager. The new manager: + // - has no cached broadcasters, so its broadcasters get resolved using the updated (tenant) + // broadcasting config and stay cached for the duration of the tenant's context // - makes the tenant broadcasters inherit the channels of the original (central) broadcaster // (since newly resolved broadcasters don't receive any channels by default, broadcasting on // channels registered in central context, e.g. in routes/channels.php, would otherwise not @@ -93,11 +94,8 @@ public function bootstrap(Tenant $tenant): void }); // Swap the currently bound Broadcaster singleton (resolved earlier with the central credentials) - // for one resolved through the tenant BroadcastManager, so that anything resolving the Broadcaster - // contract gets a broadcaster that uses the tenant's credentials instead of the stale central one. - // Unlike broadcasters resolved through the manager (re-resolved on each call), this instance is - // resolved once, so credential changes made later in tenant context don't affect it until tenancy - // is reinitialized. + // 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(Broadcaster::class, function () { return $this->app->make(BroadcastManager::class)->connection(); }); diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php index 3d77cc4a4..97383cf0e 100644 --- a/src/Overrides/TenancyBroadcastManager.php +++ b/src/Overrides/TenancyBroadcastManager.php @@ -9,57 +9,36 @@ use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; /** - * BroadcastManager override that always re-resolves the broadcasters in static::$tenantBroadcasters - * when attempting to retrieve them so that they use the updated tenant-specific config - * and passes the channels of the original (central) broadcaster - * to the newly resolved (tenant) broadcasters. + * BroadcastManager override that makes the newly resolved (tenant) broadcasters + * inherit the channels of the original (central) broadcaster. * - * Affects calls that use BroadcastManager's get() method. + * BroadcastingConfigBootstrapper binds a new instance of this manager on each tenancy + * initialization, so the broadcasters get resolved using the tenant's broadcasting config + * and stay cached (like in the parent manager) for the duration of the tenant's context. * * @see Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper */ class TenancyBroadcastManager extends BroadcastManager { /** - * Names of broadcasters that - * - should always be recreated using $this->resolve(), even when they're cached and available - * in $this->drivers so that when you update broadcasting config in the tenant context, - * the updated config/credentials will be used for broadcasting immediately. - * Note that in cases like this, only direct config changes are reflected right away. - * For the broadcasters to reflect tenant property changes made in tenant context, - * you still have to reinitialize tenancy after updating the tenant properties intended - * to be mapped to broadcasting config, since the properties are only mapped to config - * on BroadcastingConfigBootstrapper::bootstrap(). - * - should inherit the original broadcaster's channels (= the channels registered in - * the central context, e.g. in routes/channels.php, before this manager overrides the bound BroadcastManager). + * Resolve the broadcaster and pass it the channels of the currently bound broadcaster + * (the central one, when the default driver is resolved during bootstrap). */ - public static array $tenantBroadcasters = ['pusher', 'ably', 'reverb']; - - /** - * Override the get method so that the broadcasters in static::$tenantBroadcasters - * - receive the original (central) broadcaster's channels - * - always get freshly resolved. - */ - protected function get($name) + protected function resolve($name) { - if (in_array($name, static::$tenantBroadcasters)) { - /** @var Broadcaster|null $originalBroadcaster */ - $originalBroadcaster = $this->app->make(BroadcasterContract::class); - $newBroadcaster = $this->resolve($name); + $newBroadcaster = parent::resolve($name); - // Give the channels of the original (central) broadcaster to the newly resolved one. - // - // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract - // which doesn't require the channels property, so we only pass the channels to - // Illuminate\Broadcasting\Broadcasters\Broadcaster instances (= all the default broadcasters, e.g. PusherBroadcaster). - if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { - $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); - } + /** @var Broadcaster|null $originalBroadcaster */ + $originalBroadcaster = $this->app->make(BroadcasterContract::class); - return $newBroadcaster; + // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract + // which doesn't require the channels property, so we only pass the channels to + // Illuminate\Broadcasting\Broadcasters\Broadcaster instances (= all the default broadcasters, e.g. PusherBroadcaster). + if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { + $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); } - return parent::get($name); + return $newBroadcaster; } /** diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 77f153cfb..a58014c67 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -34,7 +34,6 @@ 'broadcasting.connections.ably.public' => 'ably_public', ], ]; - TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably', 'reverb']; }; beforeEach(function () use ($cleanup) { @@ -70,7 +69,6 @@ 'broadcasting.default' => 'testing', 'broadcasting.connections.testing.driver' => 'testing', ]); - TenancyBroadcastManager::$tenantBroadcasters = ['testing']; app(BroadcastManager::class)->extend('testing', fn ($app, $config) => new TestingBroadcaster('testing', $config)); @@ -81,6 +79,9 @@ // BroadcastingConfigBootstrapper binds a freshly resolved broadcaster expect(app(BroadcasterContract::class))->not()->toBe($originalBroadcaster); + // The bound broadcaster is the same instance as the tenant BroadcastManager's default driver + expect(app(BroadcasterContract::class))->toBe(app(BroadcastManager::class)->driver()); + tenancy()->end(); // Ending tenancy reverts the binding back to the original broadcaster instance @@ -98,9 +99,6 @@ if ($driver === 'custom') { config(['broadcasting.connections.custom.driver' => 'custom']); - - // Custom driver, not included in TenancyBroadcastManager::$tenantBroadcasters by default - TenancyBroadcastManager::$tenantBroadcasters = ['custom']; } BroadcastingConfigBootstrapper::$credentialsMap["broadcasting.connections.{$driver}.key"] = 'testing_key'; @@ -147,14 +145,15 @@ tenancy()->initialize($tenant1); - // When updating tenant properties without reinitializing, the tenant property update doesn't update the config, - // so the config has to be modified manually. Only methods that use TenancyBroadcastManager::get() - // will use the updated credentials without needing to reinitialize tenancy (e.g. the bound - // BroadcasterContract instance will still use the original credentials, even after config gets updated directly). + // Direct config changes aren't picked up by the broadcasters -- they get resolved + // using the config mapped from tenant properties at initialization and stay cached + // until tenancy is reinitialized. config(["broadcasting.connections.{$driver}.key" => 'new_tenant1_key']); - expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('new_tenant1_key'); - expect(Broadcast::driver()->config['key'])->toBe('new_tenant1_key'); + expect(config("broadcasting.connections.{$driver}.key"))->toBe('new_tenant1_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('tenant1_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); + expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); tenancy()->end(); @@ -167,7 +166,7 @@ 'pusher', 'ably', 'reverb', - 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default + 'custom', ]); test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function() { @@ -217,9 +216,6 @@ if ($driver === 'custom') { config(['broadcasting.connections.custom.driver' => 'custom']); - - // Custom driver, not included in TenancyBroadcastManager::$tenantBroadcasters by default - TenancyBroadcastManager::$tenantBroadcasters = ['custom']; } $tenant1 = Tenant::create(); @@ -256,9 +252,45 @@ 'pusher', 'ably', 'reverb', - 'custom', // Except for this custom driver, assume that the drivers are included in TenancyBroadcastManager::$tenantBroadcasters by default + 'custom', ]); +test('channels registered in tenant context persist within that context but do not leak into other contexts', function() { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + ]); + + app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); + + Broadcast::channel('central-channel', fn() => true); + + tenancy()->initialize(Tenant::create()); + + Broadcast::channel('tenant-channel', fn() => true); + + // Retrieving the broadcaster again (e.g. on Broadcast::auth() during a /broadcasting/auth request) + // returns the cached broadcaster, so the channel registered in tenant context is still available + expect(array_keys(invade(Broadcast::driver())->channels)) + ->toContain('central-channel') + ->toContain('tenant-channel'); + + // The channel registered in the previous tenant's context doesn't leak to another tenant's broadcaster + tenancy()->initialize(Tenant::create()); + + expect(array_keys(invade(Broadcast::driver())->channels)) + ->toContain('central-channel') + ->not()->toContain('tenant-channel'); + + tenancy()->end(); + + // The channel registered in tenant context doesn't leak to the central broadcaster + expect(array_keys(invade(Broadcast::driver())->channels)) + ->toContain('central-channel') + ->not()->toContain('tenant-channel'); +}); + test('mappings specified in credentialsMap override default mapPresets', function($driver) { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], From d80fed1468519530dba02ea269526c2d31d563e2 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 10 Jul 2026 15:23:57 +0200 Subject: [PATCH 41/53] Remove stale $tenantBroadcasters mention --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index a58014c67..63295b060 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -297,8 +297,7 @@ 'broadcasting.default' => $driver, ]); - // Preset used for broadcasters included in TenancyBroadcastManager::$tenantBroadcasters by default. - // This is the default for all tenant broadcasters, but we set it here for clarity. + // The preset mapping for the tested broadcaster (this is the default, we only set it here for clarity) BroadcastingConfigBootstrapper::$mapPresets[$driver]["broadcasting.connections.{$driver}.key"] = "{$driver}_key"; // Custom mapping specified in credentialsMap should override the preset mapping for the tested broadcaster From af14d403c57b04a0d7992a6a5afbb9ceda9430ec Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 10 Jul 2026 15:54:27 +0200 Subject: [PATCH 42/53] Delete extra newline, fix comment inconsistency Use "userName" in the channel helper test's comment for consistency (the channel name uses "userName", the comment used "userId", fixed that) --- tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php | 2 +- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 3e93cba72..352a3606e 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -196,7 +196,7 @@ protected function formatChannels(array $channels) // 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.{userId}") + // Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userName}") $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); expect($tenantChannelClosure)->toBe($centralChannelClosure); diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 63295b060..418d9f6dc 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -310,7 +310,6 @@ 'broadcasting_key' => 'custom_value', ]); - tenancy()->initialize($tenant); expect(config("broadcasting.connections.{$driver}.key"))->toBe('custom_value'); From b8fddf8a57cae3177673cd0e997393cc375e2232 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 11 Jul 2026 14:32:03 +0200 Subject: [PATCH 43/53] Retrieve the re-registered channel closure to assert what tenant_channel() actually stored https://github.com/archtechx/tenancy/pull/1448#discussion_r3561157859 --- .../BroadcastChannelPrefixBootstrapperTest.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 352a3606e..c6554702a 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -208,10 +208,16 @@ protected function formatChannels(array $channels) return User::firstWhere('name', $userName)?->is($user) ?? false; }); - expect($tenantChannelClosure)->not()->toBe($centralChannelClosure); + // 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($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); - expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + 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); From 128a1ad0369497a9349c095ddcf7cbbaab7f0589 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sat, 11 Jul 2026 14:55:59 +0200 Subject: [PATCH 44/53] Improve BroadcastingConfigBootstrapper docblock --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index a421e85f6..193cff74b 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -14,8 +14,8 @@ use Stancl\Tenancy\Overrides\TenancyBroadcastManager; /** - * Maps tenant properties to broadcasting config and overrides - * the BroadcastManager and Broadcaster bindings with tenant-aware instances. + * Maps tenant credentials to the broadcasting config and rebinds BroadcastManager + * and Broadcaster so that broadcasters get resolved using the tenant credentials. * * @see TenancyBroadcastManager */ From 9e6e0a988bbd1988254a78a5257ddc6280ff9737 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 11:49:34 +0200 Subject: [PATCH 45/53] Remove TenancyBroadcastManager, pass the channel auth closures directly in the Broadcaster extend() closure Channel auth closures (registered using Broadcast::channel() e.g. in routes/channels.php) are only ever read from the default broadcaster -- both Broadcast::channel() and Broadcast::auth() go through the default broadcaster. TenancyBroadcastManager passed the closures to every broadcaster it resolved, which is pointless -- even in central context, Laravel doesn't do anything like that). Instead, bind a fresh BroadcastManager and pass the central channel closures to its default broadcaster while extending Broadcaster. So now, tenant context behaves exactly like central context, just with broadcasters resolved using the tenant credentials. Also update comments. --- .../BroadcastingConfigBootstrapper.php | 65 +++++++++++-------- src/Overrides/TenancyBroadcastManager.php | 59 ----------------- .../BroadcastingConfigBootstrapperTest.php | 15 ++--- 3 files changed, 45 insertions(+), 94 deletions(-) delete mode 100644 src/Overrides/TenancyBroadcastManager.php diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 193cff74b..387fefc40 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -4,20 +4,18 @@ 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. - * - * @see TenancyBroadcastManager */ class BroadcastingConfigBootstrapper implements TenancyBootstrapper { @@ -37,7 +35,7 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper protected array $originalConfig = []; protected BroadcastManager|null $originalBroadcastManager = null; - protected Broadcaster|null $originalBroadcaster = null; + protected BroadcasterContract|null $originalBroadcaster = null; public static array $mapPresets = [ 'pusher' => [ @@ -69,40 +67,55 @@ public function __construct( 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 fresh TenancyBroadcastManager. The new manager: - // - has no cached broadcasters, so its broadcasters get resolved using the updated (tenant) - // broadcasting config and stay cached for the duration of the tenant's context - // - makes the tenant broadcasters inherit the channels of the original (central) broadcaster - // (since newly resolved broadcasters don't receive any channels by default, broadcasting on - // channels registered in central context, e.g. in routes/channels.php, would otherwise not - // work with the tenant broadcasters) - $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { - $originalCustomCreators = invade($broadcastManager)->customCreators; - $tenantBroadcastManager = new TenancyBroadcastManager($this->app); - - // Make TenancyBroadcastManager inherit the custom driver creators registered in the central context + // 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 ($originalCustomCreators as $driver => $closure) { - $tenantBroadcastManager->extend($driver, $closure); + foreach (invade($centralManager)->customCreators as $driver => $creator) { + $tenantManager->extend($driver, $creator); } - return $tenantBroadcastManager; + return $tenantManager; }); // 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(Broadcaster::class, function () { - return $this->app->make(BroadcastManager::class)->connection(); + $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 proeprty 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); + + 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 TenancyBroadcastManager instead of the central - // BroadcastManager — e.g. in the Broadcast::auth() call in BroadcastController (/broadcasting/auth). + // 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(); } @@ -110,7 +123,7 @@ public function revert(): void { // Revert the bound BroadcastManager and Broadcaster singletons back to their original state $this->app->singleton(BroadcastManager::class, fn () => $this->originalBroadcastManager); - $this->app->singleton(Broadcaster::class, fn () => $this->originalBroadcaster); + $this->app->singleton(BroadcasterContract::class, fn () => $this->originalBroadcaster); // Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager Broadcast::clearResolvedInstance(); diff --git a/src/Overrides/TenancyBroadcastManager.php b/src/Overrides/TenancyBroadcastManager.php deleted file mode 100644 index 97383cf0e..000000000 --- a/src/Overrides/TenancyBroadcastManager.php +++ /dev/null @@ -1,59 +0,0 @@ -app->make(BroadcasterContract::class); - - // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract - // which doesn't require the channels property, so we only pass the channels to - // Illuminate\Broadcasting\Broadcasters\Broadcaster instances (= all the default broadcasters, e.g. PusherBroadcaster). - if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { - $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); - } - - return $newBroadcaster; - } - - /** - * The newly resolved broadcasters don't automatically receive the channels registered - * in central context (e.g. Broadcast::channel() in routes/channels.php), so the channels - * have to be obtained from the original (central) broadcaster and manually passed to the new broadcasters - * (broadcasting using a broadcaster with no channels results in a 403 error on Broadcast::auth()). - */ - protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void - { - // invade() because channels can't be retrieved through any of the broadcaster's public methods - $originalBroadcaster = invade($originalBroadcaster); - - foreach ($originalBroadcaster->channels as $channel => $callback) { - $newBroadcaster->channel($channel, $callback, $originalBroadcaster->retrieveChannelOptions($channel)); - } - } -} diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 418d9f6dc..d20656279 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -8,7 +8,6 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\RevertToCentralContext; -use Stancl\Tenancy\Overrides\TenancyBroadcastManager; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Illuminate\Support\Facades\Broadcast; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; @@ -45,22 +44,20 @@ afterEach($cleanup); -test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { +test('BroadcastingConfigBootstrapper binds a fresh BroadcastManager and reverts the binding when tenancy is ended', function() { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); - expect(app(BroadcastManager::class)) - ->toBeInstanceOf(BroadcastManager::class) - ->not()->toBeInstanceOf(TenancyBroadcastManager::class); + $centralManager = app(BroadcastManager::class); tenancy()->initialize(Tenant::create()); - expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class); + expect(app(BroadcastManager::class)) + ->toBeInstanceOf(BroadcastManager::class) + ->not()->toBe($centralManager); tenancy()->end(); - expect(app(BroadcastManager::class)) - ->toBeInstanceOf(BroadcastManager::class) - ->not()->toBeInstanceOf(TenancyBroadcastManager::class); + expect(app(BroadcastManager::class))->toBe($centralManager); }); test('ending tenancy reverts the bound broadcaster to the original instance', function() { From 5911619887ab64bda3067e9777a7c8bcfbaa3bc8 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:20:40 +0200 Subject: [PATCH 46/53] Simplify channel-passing test Remove the with() -- the test ran four times with the same TestingBroadcaster, so the only tested broadcaster was actually the testing one. Running the test just once is enough, the same things are still covered. --- .../BroadcastingConfigBootstrapperTest.php | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index d20656279..d8ff8ee35 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -205,20 +205,17 @@ expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); }); -test('tenant broadcasters receive the channels from the broadcaster bound in central context', function(string $driver) { +test('tenant broadcasters receive the channels from the broadcaster bound in central context', function() { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], - 'broadcasting.default' => $driver, + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', ]); - if ($driver === 'custom') { - config(['broadcasting.connections.custom.driver' => 'custom']); - } - $tenant1 = Tenant::create(); $tenant2 = Tenant::create(); - app(BroadcastManager::class)->extend($driver, fn($app, $config) => new TestingBroadcaster('testing')); + app(BroadcastManager::class)->extend('testing', fn() => new TestingBroadcaster('testing')); $getCurrentChannelsFromBoundBroadcaster = fn() => array_keys(invade(app(BroadcasterContract::class))->channels); $getCurrentChannelsThroughManager = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); @@ -245,12 +242,7 @@ expect($channel) ->toBeIn($getCurrentChannelsThroughManager()) ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); -})->with([ - 'pusher', - 'ably', - 'reverb', - 'custom', -]); +}); test('channels registered in tenant context persist within that context but do not leak into other contexts', function() { config([ From 120cec440b7399c1cb85000743b3c79635c5dd2d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:22:07 +0200 Subject: [PATCH 47/53] Add spaces after `fn`/`function` for consistency --- .../BroadcastingConfigBootstrapperTest.php | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index d8ff8ee35..8c6e1beb7 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -44,7 +44,7 @@ afterEach($cleanup); -test('BroadcastingConfigBootstrapper binds a fresh BroadcastManager and reverts the binding when tenancy is ended', function() { +test('BroadcastingConfigBootstrapper binds a fresh BroadcastManager and reverts the binding when tenancy is ended', function () { config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); $centralManager = app(BroadcastManager::class); @@ -60,7 +60,7 @@ expect(app(BroadcastManager::class))->toBe($centralManager); }); -test('ending tenancy reverts the bound broadcaster to the original instance', function() { +test('ending tenancy reverts the bound broadcaster to the original instance', function () { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'broadcasting.default' => 'testing', @@ -85,7 +85,7 @@ expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); }); -test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function(string $driver) { +test('BroadcastingConfigBootstrapper maps tenant properties to broadcaster credentials correctly', function (string $driver) { config([ 'broadcasting.default' => $driver, "broadcasting.connections.{$driver}.key" => 'central_key', @@ -166,7 +166,7 @@ 'custom', ]); -test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function() { +test('tenant broadcast manager receives the custom driver creators of the central broadcast manager', function () { config([ 'tenancy.bootstrappers' => [ BroadcastingConfigBootstrapper::class, @@ -205,7 +205,7 @@ expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); }); -test('tenant broadcasters receive the channels from the broadcaster bound in central context', function() { +test('tenant broadcasters receive the channels from the broadcaster bound in central context', function () { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'broadcasting.default' => 'testing', @@ -215,11 +215,11 @@ $tenant1 = Tenant::create(); $tenant2 = Tenant::create(); - app(BroadcastManager::class)->extend('testing', fn() => new TestingBroadcaster('testing')); - $getCurrentChannelsFromBoundBroadcaster = fn() => array_keys(invade(app(BroadcasterContract::class))->channels); - $getCurrentChannelsThroughManager = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); + app(BroadcastManager::class)->extend('testing', fn () => new TestingBroadcaster('testing')); + $getCurrentChannelsFromBoundBroadcaster = fn () => array_keys(invade(app(BroadcasterContract::class))->channels); + $getCurrentChannelsThroughManager = fn () => array_keys(invade(app(BroadcastManager::class)->driver())->channels); - Broadcast::channel($channel = 'testing-channel', fn() => true); + Broadcast::channel($channel = 'testing-channel', fn () => true); expect($channel) ->toBeIn($getCurrentChannelsThroughManager()) @@ -244,7 +244,7 @@ ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); }); -test('channels registered in tenant context persist within that context but do not leak into other contexts', function() { +test('channels registered in tenant context persist within that context but do not leak into other contexts', function () { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'broadcasting.default' => 'testing', @@ -253,11 +253,11 @@ app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing', $config)); - Broadcast::channel('central-channel', fn() => true); + Broadcast::channel('central-channel', fn () => true); tenancy()->initialize(Tenant::create()); - Broadcast::channel('tenant-channel', fn() => true); + Broadcast::channel('tenant-channel', fn () => true); // Retrieving the broadcaster again (e.g. on Broadcast::auth() during a /broadcasting/auth request) // returns the cached broadcaster, so the channel registered in tenant context is still available @@ -280,7 +280,7 @@ ->not()->toContain('tenant-channel'); }); -test('mappings specified in credentialsMap override default mapPresets', function($driver) { +test('mappings specified in credentialsMap override default mapPresets', function ($driver) { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'broadcasting.default' => $driver, From 12e51d0378e395b68aa8738104b4eedbd787d392 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:28:01 +0200 Subject: [PATCH 48/53] Test that when a tenant doesn't have the property that should be mapped to config, the central config values are kept --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 8c6e1beb7..1ea35d669 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -152,6 +152,13 @@ expect(app(BroadcasterContract::class)->config['key'])->toBe('tenant1_key'); expect(Broadcast::driver()->config['key'])->toBe('tenant1_key'); + // Initializing tenancy for a tenant without the mapped property keeps the central config value + tenancy()->initialize(Tenant::create()); + + expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); + expect(app(BroadcastManager::class)->driver()->config['key'])->toBe('central_key'); + expect(app(BroadcasterContract::class)->config['key'])->toBe('central_key'); + tenancy()->end(); expect(config("broadcasting.connections.{$driver}.key"))->toBe('central_key'); From 0ba588d2997c835ea3a591c8f1071256c7b119fd Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:34:42 +0200 Subject: [PATCH 49/53] Improve channel closure inheritance test Originally, the test only asserted that the broadcasters inherited the correct channel names. Now, the test also asserts that the actual closure and the channel options are inherited too. --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index 1ea35d669..f2a06065f 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -226,7 +226,7 @@ $getCurrentChannelsFromBoundBroadcaster = fn () => array_keys(invade(app(BroadcasterContract::class))->channels); $getCurrentChannelsThroughManager = fn () => array_keys(invade(app(BroadcastManager::class)->driver())->channels); - Broadcast::channel($channel = 'testing-channel', fn () => true); + Broadcast::channel($channel = 'testing-channel', $callback = fn () => true, $options = ['guards' => ['web']]); expect($channel) ->toBeIn($getCurrentChannelsThroughManager()) @@ -238,6 +238,10 @@ ->toBeIn($getCurrentChannelsThroughManager()) ->toBeIn($getCurrentChannelsFromBoundBroadcaster()); + // The channel auth closure and the channel options are copied to the tenant broadcaster as-is + expect(invade(app(BroadcasterContract::class))->channels[$channel])->toBe($callback); + expect(invade(app(BroadcasterContract::class))->retrieveChannelOptions($channel))->toBe($options); + tenancy()->initialize($tenant2); expect($channel) From e6c4c317fe849e6a5258605fb1509f1fb038b5e0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:40:31 +0200 Subject: [PATCH 50/53] Test that using broadcasters that only implement the Broadcaster contract (instead of the abstract class) don't break bootstrap() Covers the `$centralBroadcaster instanceof Broadcaster && $tenantBroadcaster instanceof Broadcaster` check in extend(BroadcasterContract::class, ...) --- .../BroadcastingConfigBootstrapperTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index f2a06065f..aa866d455 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -318,3 +318,29 @@ 'ably', 'reverb', ]); + +test('initializing tenancy does not fail when the broadcaster does not extend the abstract Broadcaster class', function () { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'contract', + 'broadcasting.connections.contract.driver' => 'contract', + ]); + + $contractBroadcaster = new class implements BroadcasterContract { + public function auth($request) {} + public function validAuthenticationResponse($request, $result) {} + public function broadcast(array $channels, $event, array $payload = []) {} + }; + + app(BroadcastManager::class)->extend('contract', fn () => clone $contractBroadcaster); + + $centralBroadcaster = app(BroadcasterContract::class); + + // Channel auth closures only exist on broadcasters extending the abstract Broadcaster class, + // so the bootstrapper skips copying them instead of failing. + tenancy()->initialize(Tenant::create()); + + expect(app(BroadcasterContract::class)) + ->toBeInstanceOf(get_class($contractBroadcaster)) + ->not()->toBe($centralBroadcaster); +}); From 4422df2e0487b8811c892460874dea3e3e47f854 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:41:30 +0200 Subject: [PATCH 51/53] Test configuring BroadcastingConfigBootstrapper::$broadcaster --- .../BroadcastingConfigBootstrapperTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index aa866d455..e746c278e 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -344,3 +344,21 @@ public function broadcast(array $channels, $event, array $payload = []) {} ->toBeInstanceOf(get_class($contractBroadcaster)) ->not()->toBe($centralBroadcaster); }); + +test('setting the broadcaster property overrides which map preset is used', function () { + config([ + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], + 'broadcasting.default' => 'testing', + 'broadcasting.connections.testing.driver' => 'testing', + 'broadcasting.connections.pusher.key' => 'central_key', + ]); + + app(BroadcastManager::class)->extend('testing', fn () => new TestingBroadcaster('testing')); + + // Use the pusher preset even though the default connection isn't pusher + BroadcastingConfigBootstrapper::$broadcaster = 'pusher'; + + tenancy()->initialize(Tenant::create(['pusher_key' => 'tenant_key'])); + + expect(config('broadcasting.connections.pusher.key'))->toBe('tenant_key'); +}); From 1409d72dab26144c0c3a8a0c418f383b03353faf Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:49:03 +0200 Subject: [PATCH 52/53] Make test name more specific --- tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php index e746c278e..863d29360 100644 --- a/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastingConfigBootstrapperTest.php @@ -212,7 +212,7 @@ expect(array_keys(invade(app(BroadcastManager::class))->customCreators))->toEqualCanonicalizing($originalDrivers); }); -test('tenant broadcasters receive the channels from the broadcaster bound in central context', function () { +test('tenant broadcasters receive the channel auth closures from the broadcaster bound in central context', function () { config([ 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], 'broadcasting.default' => 'testing', From 31d1663347863dafad8617722680651a36cb14ac Mon Sep 17 00:00:00 2001 From: lukinovec Date: Sun, 12 Jul 2026 12:49:12 +0200 Subject: [PATCH 53/53] Fix typo in comment --- src/Bootstrappers/BroadcastingConfigBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 387fefc40..3ffd93376 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -98,7 +98,7 @@ public function bootstrap(Tenant $tenant): void // 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 proeprty and the channel() method aren't part of the Broadcaster contract -- they come + // 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) {