# Laravel private report channel: example and acceptance checks Companion: https://nomadicsoft.io/blog/laravel-reverb-private-channels Prepared 30 September 2026. Fictional single-owner report workflow. ## Scope These files demonstrate channel permission, a minimal event payload and dispatch after a database transaction. Local checks used PHP 8.3.6, Laravel 12.40.2, PHPUnit 11.5.44 and Pusher PHP SDK 7.3.0, with SQLite in memory. These are observed fixture versions, not a latest-version recommendation. The broadcaster signs local authorization responses; the queue is faked to inspect dispatch. No socket connection, worker delivery, browser login, TLS proxy or throughput test is claimed. The checks below distinguish those unexecuted deployment steps. Use a disposable compatible Laravel application with `App\Models\User` and the normal Laravel test bootstrap. Do not copy the test into a project whose `reports` table or authorization rules you intend to preserve. The database must be SQLite `:memory:`; the test asserts that before creating its table. The users are unsaved test models. The SDK key and secret are fictional and never connect to an external service. ## Event: app/Events/ReportStatusChanged.php ```php reportId)]; } public function broadcastAs(): string { return 'report.status.updated'; } public function broadcastWith(): array { return [ 'report_id' => $this->reportId, 'status' => $this->status, 'revision' => $this->revision, ]; } } ``` ## Channel registration: routes/channels.php ```php where('id', $reportId) ->where('user_id', $user->getAuthIdentifier()) ->exists(); }); ``` Register the channel file using the application's broadcasting setup. This example permits only the report owner. Real sharing, support staff and tenant membership need separately reviewed rules. Apply equivalent authorization to status and download HTTP routes. The writing service should update the report status and increment its revision atomically, then dispatch `ReportStatusChanged` with those saved values inside the transaction. It remains responsible for permission, valid transitions and concurrent changes. No generic public “set report status” endpoint is supplied. ## Test: tests/Feature/ReverbContractTest.php The test needs the Pusher PHP SDK, which Reverb uses through Laravel's Pusher-compatible broadcasting path. In the disposable fixture, install the checked SDK explicitly if it is not already present: ```bash composer require --dev pusher/pusher-php-server:7.3.0 ``` ```php 'contract', 'broadcasting.connections.contract' => ['driver' => 'contract']]); Broadcast::extend('contract', fn () => new PusherBroadcaster(new Pusher( 'fixture-key', 'fixture-secret', 'fixture-app', ['host' => '127.0.0.1', 'port' => 65534, 'scheme' => 'http', 'useTLS' => false] ))); require base_path('routes/channels.php'); $this->assertSame('sqlite', config('database.default')); $this->assertSame(':memory:', config('database.connections.sqlite.database')); Schema::create('reports', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('user_id'); $table->string('status'); $table->unsignedInteger('revision'); }); DB::table('reports')->insert(['id' => 41, 'user_id' => 7, 'status' => 'processing', 'revision' => 1]); } public function test_owner_can_authorize_but_guesses_and_guests_cannot(): void { $owner = (new User)->forceFill(['id' => 7]); $other = (new User)->forceFill(['id' => 8]); $result = Broadcast::auth($this->request($owner, 'private-reports.41')); $this->assertSame('fixture-key:'.hash_hmac('sha256', '123.456:private-reports.41', 'fixture-secret'), $result['auth']); foreach ([[$other, 'private-reports.41'], [null, 'private-reports.41'], [$owner, 'private-reports.42'], [$owner, 'private-reports.041'], [$owner, 'private-reports.bad'], [$owner, 'private-reports.0']] as [$user, $channel]) { try { Broadcast::auth($this->request($user, $channel)); $this->fail('Unauthorized channel accepted: '.$channel); } catch (AccessDeniedHttpException $exception) { $this->assertSame(403, $exception->getStatusCode()); } } } public function test_event_has_only_the_expected_report_payload(): void { $event = new ReportStatusChanged(41, 'ready', 2); $this->assertSame(['private-reports.41'], array_map(strval(...), $event->broadcastOn())); $this->assertSame('report.status.updated', $event->broadcastAs()); $this->assertSame('broadcasts', $event->queue); $this->assertSame(['report_id' => 41, 'status' => 'ready', 'revision' => 2], $event->broadcastWith()); } public function test_dispatch_waits_for_commit_and_rollback_does_not_announce_ready(): void { Queue::fake(); DB::beginTransaction(); DB::table('reports')->where('id', 41)->update(['status' => 'ready', 'revision' => 2]); ReportStatusChanged::dispatch(41, 'ready', 2); Queue::assertNothingPushed(); DB::commit(); Queue::assertPushedOn('broadcasts', BroadcastEvent::class); Queue::assertPushed(BroadcastEvent::class, 1); $this->assertSame('ready', DB::table('reports')->where('id', 41)->value('status')); DB::beginTransaction(); DB::table('reports')->where('id', 41)->update(['status' => 'failed', 'revision' => 3]); ReportStatusChanged::dispatch(41, 'failed', 3); DB::rollBack(); Queue::assertPushed(BroadcastEvent::class, 1); $this->assertSame('ready', DB::table('reports')->where('id', 41)->value('status')); $this->assertSame(2, DB::table('reports')->where('id', 41)->value('revision')); } private function request(?User $user, string $channel): Request { $request = Request::create('/broadcasting/auth', 'POST', ['socket_id' => '123.456', 'channel_name' => $channel]); $request->setUserResolver(fn () => $user); return $request; } } ``` Run with SQLite in memory, after verifying the test configuration does not override it with a real database: ```bash DB_CONNECTION=sqlite DB_DATABASE=:memory: php artisan test --filter=ReverbContractTest ``` Expected: the owner gets a signed channel authorization; another user, guest, absent report and malformed ID are denied. The payload has exactly the three named fields. One broadcast job is enqueued on `broadcasts` after commit; none is added by the rolled-back transaction. The test does not process the queued job. ## Additional acceptance on the target deployment Record actual outcomes, environment and evidence; do not mark these as passed from the PHP checks. | Step | Expected observation | Evidence | | --- | --- | --- | | Owner logs in and subscribes | Authorized channel subscription and initial status read | Browser auth/connection result; report ID | | Another account requests that report | Authorization and status/download access denied | HTTP and subscription responses | | Stop the broadcast worker, then complete a report | Database state commits; queued event waits | Database revision and queue entry | | Start the matching worker | Event reaches the subscribed owner | Worker result and client observation | | Disconnect browser before completion; reconnect later | Authenticated status refresh finds the current revision | Reconnect and refreshed response | | Receive duplicate events or overlapping HTTP responses | No repeated business action or regression to an older revision | UI state sequence | | Revoke access while connected | Defined disconnect/channel-rotation behavior; reads denied | Existing connection plus new request results | | Restart Reverb through the process manager | Process returns and clients recover | Process/release ID and client result | For a browser listener, use `.report.status.updated` with a leading dot for the custom event name. Fetch authoritative report state after subscribing and reconnecting. Restrict origins, keep the app secret server-side, and document the distinct bind, server-publish and browser addresses. References: [broadcasting](https://laravel.com/docs/12.x/broadcasting), [Reverb](https://laravel.com/docs/12.x/reverb).