In Laravel, a BidPlaced broadcast on a public auction channel exposes each bidder's email to every viewer; why does that happen, and how do broadcastWith and broadcastAs help?
answer
- public properties become the payload
- models go through toArray()
- public channel means anyone
- broadcastWith() replaces the payload
- broadcastAs() needs a leading dot
basics
~20 sBy default every public property of the event is broadcast, and an Eloquent model is sent as its toArray() output, loaded relations included. On a public channel anyone can read it. broadcastWith() sends only chosen fields; broadcastAs() renames the event.
solid answer
~40 sWhen `BroadcastEvent` builds the payload it uses `broadcastWith()` if the event defines it; otherwise it serializes **every public property**, and anything `Arrayable` such as a `Bid` model becomes its `toArray()` output, including loaded relations like the bidder `User` with every attribute not in `$hidden`. A public `Channel` needs no authorization, so anyone who opens the page's WebSocket can read it. The fix is to send a deliberate payload with `broadcastWith()`, for example the amount, time and a display name, and to put anything personal on a private channel. `broadcastAs()` returning `'bid.placed'` decouples the wire name from the PHP class; Echo must then listen for `'.bid.placed'` with a leading dot, otherwise it prepends `App.Events`. Because `SerializesModels` re-fetches models in the worker, the payload also reflects the database state at send time.
code
php · 31 lines<?php
namespace App\Events;
use App\Models\Bid;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class BidPlaced implements ShouldBroadcast
{
public function __construct(public Bid $bid) {}
public function broadcastOn(): Channel
{
return new Channel('auctions.'.$this->bid->auction_id);
}
public function broadcastAs(): string
{
return 'bid.placed';
}
public function broadcastWith(): array
{
return [
'amount_cents' => $this->bid->amount_cents,
'placed_at' => $this->bid->created_at->toIso8601String(),
'bidder' => $this->bid->bidder->display_name,
];
}
}go deeper
Recall that public properties of a broadcast event are sent to the browser and that broadcastWith() lets you choose the fields.
Explain how models are converted with toArray(), how broadcastAs() changes the wire name, and why Echo then needs a leading dot.
Review broadcast events as a data-exposure surface: channel choice per field, explicit payloads, and freshness under SerializesModels.
Set a policy that every broadcast defines an explicit payload and that personal data never crosses a public channel, with review checks to enforce it.
## How the payload is built When a broadcast event is sent, Laravel's `BroadcastEvent` job assembles three things: the channels from `broadcastOn()`, the **name** and the **payload**. For the payload it follows two rules: 1. If the event has a `broadcastWith()` method that returns an array, that array is the payload, with the `socket` value added for `toOthers()` handling. 2. Otherwise Laravel reflects over the event and takes **every public property**. Values that are `Arrayable`, which includes Eloquent models and collections, are converted with `toArray()`. So an event like `new BidPlaced($bid)` with `public Bid $bid` broadcasts `{"bid": {...every visible attribute of the bid...}}`. If the controller called `$bid->load('bidder')`, the serialized model keeps the loaded relation, and `toArray()` includes the whole `User` array: email, phone, whatever is not listed in `$hidden`. ## Why the channel type makes it worse Public and private channels differ in who can subscribe: | Channel | Authorization | Who reads the payload | |---|---|---| | `Channel` | none | anyone who knows the name, including scripts | | `PrivateChannel` | `Broadcast::channel` callback | authorized users | | `PresenceChannel` | callback returning member data | authorized members | Channel names are visible in the page's JavaScript, so "hard to guess" is no protection. Anything sent on a public channel should be treated as published. ## Fixing the leak - **Send a deliberate payload.** Define `broadcastWith()` and return only what viewers need: amount, time, a display name or an anonymised bidder label. - **Keep personal data on private channels.** Send "you were outbid" details on a per-user private channel such as `bidders.{id}`, authorized for that user only. - **Keep `$hidden` correct** as a second line of defence, since `toArray()` respects it. - **Avoid loading relations** on models that will be broadcast unless the payload is explicit. `broadcastWith()` is also how you keep payloads small; WebSocket servers cap message sizes and every subscriber receives every byte. ## What a viewer actually receives Without `broadcastWith()`, a subscriber on the public channel receives something shaped like this for every bid: ```json {"bid": {"id": 88, "amount_cents": 12500, "auction_id": 42, "bidder": {"id": 7, "name": "Dana", "email": "[email protected]", "phone": "..."}}} ``` Nothing in the page's UI needs to display the email for it to be exposed: any subscriber can open the browser's developer tools, or connect a script to the channel, and read the raw message. ## Naming the event with broadcastAs Without `broadcastAs()`, the event name on the wire is the PHP class name, `App\Events\BidPlaced`. Echo prepends its default namespace `App.Events` to names you listen for, so `listen('BidPlaced')` matches. With `broadcastAs()` returning `'bid.placed'`, the wire name is exactly `bid.placed`. On the client you must listen for **`'.bid.placed'`**: the leading dot tells Echo not to prepend the namespace. Forgetting the dot is the usual reason a renamed event silently stops arriving. A stable name is useful when the frontend is maintained separately or the PHP class is moved. ## Freshness of the data With `SerializesModels`, a queued broadcast stores only the model's identifier and re-fetches it when the worker runs. The payload therefore reflects the database **at send time**, not at dispatch time. For a bid feed that is usually fine; for "the price at the moment of this bid" put the amount in its own scalar property or in `broadcastWith()` from values captured in the constructor. ## A review checklist 1. What is on the event's public properties, and which relations are loaded? 2. Does the event define `broadcastWith()`? 3. Is every channel it broadcasts on appropriate for that data? 4. Does the Echo listener match the name, with a dot if `broadcastAs()` is used? Only the array that `broadcastWith()` returns, or the public properties when it is absent, ever leave the server, so reviewing those two places covers the exposure.
- After adding broadcastAs('bid.placed'), the frontend's listen('bid.placed') stops receiving events; why?Echo prepends its namespace to names that do not start with a dot, so it listens for `App\Events\bid\placed`, which is never sent. Listening for `'.bid.placed'` tells Echo to use the name as-is.
- Does $hidden on the User model protect a broadcast payload?Partly. The default payload converts models with `toArray()`, which omits `$hidden` attributes, so hidden columns stay out. Everything else, including columns added later, is sent. An explicit `broadcastWith()` is the reliable control.
saying these in an interview costs you the question
- Only the event's constructor arguments are sent, not its other public properties.
- Channel names nobody can guess make a public channel private enough.
- Echo listens for a broadcastAs() name correctly without a leading dot.
- broadcastWith() only adds fields on top of the public properties.
- Protected properties on the event are broadcast along with public ones.