Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7d749eb
BroadcastingConfigBootstrapper: test mapping credentials
lukinovec Mar 31, 2026
2ecc94d
BroadcastingConfigBootstrapper: test persistence of custom driver cre…
lukinovec Mar 31, 2026
fafd082
Fix typo in test
lukinovec Mar 31, 2026
c653c51
BroadcastingConfigBootstrapper: make tenant manager inherit central m…
lukinovec Mar 31, 2026
b1e91f1
BroadcastingConfigBootstrapper: make `Broadcaster::class` resolve to …
lukinovec Mar 31, 2026
65beecf
BroadcastingConfigBootstrapper: clear the `Broadcast` facade's resolv…
lukinovec Mar 31, 2026
0b860ea
Fix code style (php-cs-fixer)
github-actions[bot] Mar 31, 2026
c4f4451
Add assertions for the config of the bound manager's driver
lukinovec Mar 31, 2026
d939866
Fix custom creator assertions
lukinovec Mar 31, 2026
28b6119
TenancyBroadcastManager: update docblocks
lukinovec Apr 1, 2026
0fbe1bc
TenancyBroadcastManager: delete `Broadcaster` singleton binding
lukinovec Apr 2, 2026
b6c035c
Improve comments
lukinovec Apr 2, 2026
bbe2ff0
BroadcastingTest: update channel inheritance test
lukinovec Apr 2, 2026
b2add06
Delete BroadcastingTest
lukinovec Apr 2, 2026
9e9bedc
Fix code style (php-cs-fixer)
github-actions[bot] Apr 2, 2026
4b1cc9c
Improve comments
lukinovec Apr 2, 2026
fc45e09
Update BroadcastingConfigBootstrapperTest
lukinovec Apr 3, 2026
6b99921
BroadcastingConfigBootstrapper: correct `$credentialsMap` array_merge…
lukinovec Apr 3, 2026
29dd23d
BroadcastingConfigBootstrapperTest: add 'reverb' driver to datasets
lukinovec Apr 3, 2026
4937a74
BroadcastingConfigBootstrapper and TenancyBroadcastManager: comments
lukinovec Apr 3, 2026
c831393
Update comment
lukinovec Apr 3, 2026
ef476c5
Polish comments
lukinovec Apr 3, 2026
f8528fc
Add 'reverb' to `TenancyBroadcastManager::$tenantBroadcasters`
lukinovec Apr 3, 2026
dc344b7
Merge branch 'master' into broadcasting-fixes
stancl Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/Bootstrappers/BroadcastingConfigBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@
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;

/**
* Maps tenant properties to broadcasting config and overrides
* the BroadcastManager binding with TenancyBroadcastManager.
*
* @see TenancyBroadcastManager
*/
class BroadcastingConfigBootstrapper implements TenancyBootstrapper
{
/**
Expand Down Expand Up @@ -54,7 +62,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
Expand All @@ -64,10 +72,35 @@ 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 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) {
return new TenancyBroadcastManager($this->app);
$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);
}

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.
$this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) {
return $this->app->make(BroadcastManager::class)->connection();
});
Comment on lines +96 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Remove unused $broadcaster parameter to silence the static analysis warning.

The callback intentionally replaces the broadcaster rather than wrapping it, so the parameter isn't needed.

♻️ Proposed fix
-        $this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) {
+        $this->app->extend(Broadcaster::class, function () {
             return $this->app->make(BroadcastManager::class)->connection();
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->app->extend(Broadcaster::class, function (Broadcaster $broadcaster) {
return $this->app->make(BroadcastManager::class)->connection();
});
$this->app->extend(Broadcaster::class, function () {
return $this->app->make(BroadcastManager::class)->connection();
});
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 96-96: Avoid unused parameters such as '$broadcaster'. (undefined)

(UnusedFormalParameter)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Bootstrappers/BroadcastingConfigBootstrapper.php` around lines 96 - 98,
The closure passed to $this->app->extend currently declares an unused
Broadcaster $broadcaster parameter which triggers static analysis; change the
callback signature to remove that parameter so it becomes a zero-argument
closure (e.g., function () { ... } or an arrow fn) and return
$this->app->make(BroadcastManager::class)->connection(); leaving the extend call
and references to Broadcaster::class and BroadcastManager::class unchanged.


// 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);
}

public function revert(): void
Expand All @@ -76,6 +109,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 BroadcastManager
Broadcast::clearResolvedInstance(BroadcastingFactory::class);

$this->unsetConfig();
}

Expand Down
54 changes: 34 additions & 20 deletions src/Overrides/TenancyBroadcastManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,37 @@
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Contracts\Foundation\Application;

/**
* 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 newly resolved (tenant) broadcasters.
*
* Affects calls that use app(BroadcastManager::class)->get().
*
* @see Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper
*/
class TenancyBroadcastManager extends BroadcastManager
{
/**
* Names of broadcasters to always recreate using $this->resolve() (even when they're
* cached and available in the $broadcasters property).
*
* The reason for recreating the broadcasters is
* to make your app use the correct broadcaster credentials when tenancy is initialized.
* 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).
*/
public static array $tenantBroadcasters = ['pusher', 'ably'];
public static array $tenantBroadcasters = ['pusher', 'ably', 'reverb'];

/**
* 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,
* 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.
* 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)
{
Expand All @@ -35,24 +46,27 @@ 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 (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);
}

$this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster);

return $newBroadcaster;
}

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

beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Expand Down Expand Up @@ -137,3 +140,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();
});
Loading
Loading