public function hasBroadcastChannels( array|string|null $channelFiles = null, string $directory = '../routes',): static
Added in 2.4.0. A channel file is the one that calls Broadcast::channel() to authorize private
and presence channels. Before this existed, the only way to ship one from a package was to pass it
to hasRoutes() — which loads it through loadRoutesFrom(), inside a route group. That is the
wrong destination for an authorization callback, and it put your channel names into the router.
$packager ->name('Blog') ->hasBroadcastChannels();
Each declared file is required at boot, so its Broadcast::channel() calls run against the
application's broadcaster.
Writing the channel file
use Acme\Blog\Models\Post;use Illuminate\Support\Facades\Broadcast; Broadcast::channel('blog.post.{postId}', function ($user, int $postId) { return Post::find($postId)?->isVisibleTo($user) ?? false;}); Broadcast::channel('blog.presence.{postId}', function ($user, int $postId) { return ['id' => $user->id, 'name' => $user->name];});
Prefix your channel names with the package short name. Channel names are a flat global namespace
shared with the application and every other package — post.{id} will collide sooner or later.
Keep channels out of the routes directory
The default directory is ../routes, matching Laravel's own convention. That creates a trap:
$packager ->name('Blog') ->hasRoutes() // ← discovers routes/channels.php too ->hasBroadcastChannels(); // ← and so does this
Both builders discover the same directory, so channels.php is loaded twice — once correctly, once
as a route file. Two ways out, both fine. Name the files explicitly:
$packager ->hasRoutes() ->hasBroadcastChannels(); ->hasRoutes(['web.php', 'api.php']) ->hasBroadcastChannels(['channels.php']);
Or give channels their own directory, and let discovery keep working:
$packager ->hasRoutes() ->hasBroadcastChannels(directory: '../broadcasting');
Several channel files
$packager->hasBroadcastChannels( ['channels.php', 'presence-channels.php'], '../broadcasting',);
All of them are required, in order.
Applications without broadcasting
The toolkit requires illuminate/support, not illuminate/broadcasting. If the broadcasting
component is not installed, bootBroadcastChannels() checks for BroadcastManager and returns
early — no exception, no fatal error. Your package stays installable in an application that does not
broadcast.
if (! class_exists(BroadcastManager::class)) { return $this;}
Channels are deliberately not publishable
There is no blog::channels tag, and running vendor:publish --tag=blog::channels publishes
nothing:
php artisan vendor:publish --tag=blog::channels# Exits 0, writes nothing.
That is a design decision, not an omission. An application does not load routes/channels.php
unless its own bootstrap asks for it, so a published copy would sit there looking authoritative
while the package quietly kept using its own — the worst possible outcome for a file whose entire
job is authorization.
Consumers override a channel by re-registering it from their own application. The last registration for a channel name wins, and application providers boot after package providers:
public function boot(): void{ Broadcast::channel('blog.post.{postId}', function ($user, int $postId) { return $user->isAdmin() || Post::find($postId)?->isVisibleTo($user); });}
Document the channel names your package registers, the way you would document a public API — that list is the extension point.
Using the channels
From your package's events:
namespace Acme\Blog\Events; use Illuminate\Broadcasting\InteractsWithSockets;use Illuminate\Broadcasting\PrivateChannel;use Illuminate\Contracts\Broadcasting\ShouldBroadcast;use Illuminate\Foundation\Events\Dispatchable; class PostPublished implements ShouldBroadcast{ use Dispatchable, InteractsWithSockets; public function __construct(public Post $post) {} public function broadcastOn(): array { return [new PrivateChannel("blog.post.{$this->post->id}")]; } public function broadcastAs(): string { return 'blog.post.published'; }}
And from the consumer's JavaScript:
Echo.private(`blog.post.${postId}`) .listen('.blog.post.published', (event) => { console.log(event.post) })
Testing
use Illuminate\Support\Facades\Broadcast; test('the package registers its channels', function () { $channels = Broadcast::getChannels()->keys()->all(); expect($channels)->toContain('blog.post.{postId}') ->and($channels)->toContain('blog.presence.{postId}');}); test('channel files are not registered as routes', function () { expect(collect(app('router')->getRoutes()->getRoutes()) ->contains(fn ($route) => str_contains($route->uri(), 'blog.post'))) ->toBeFalse();});
Introspection
$packager->isBroadcastable(); // bool$packager->broadcastChannelFiles(); // Support\SplFileInfo[]