skip to content

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?

level: seniorimportance: should knowfreq 36%

answer

  1. public properties become the payload
  2. models go through toArray()
  3. public channel means anyone
  4. broadcastWith() replaces the payload
  5. broadcastAs() needs a leading dot

basics

~20 s

By 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 s

When `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
<?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

for a junior

Recall that public properties of a broadcast event are sent to the browser and that broadcastWith() lets you choose the fields.

for a middle

Explain how models are converted with toArray(), how broadcastAs() changes the wire name, and why Echo then needs a leading dot.

for a senior

Review broadcast events as a data-exposure surface: channel choice per field, explicit payloads, and freshness under SerializesModels.

for a principal

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.