From 7d749eb592f88687ddea7e03d1daaf4c3a986f20 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 31 Mar 2026 15:32:45 +0200 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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() {