Back to Blog
Real-Time Architecture in Laravel — WebSockets, Events, and State Machines
May 26, 2026 laravelwebsocketsreverbarchitecture

Real-Time Architecture in Laravel — WebSockets, Events, and State Machines

How to model real-time flows with Laravel Reverb, broadcast events, and frontend state management — lessons from building two production real-time systems.

Building anything with real-time requirements forces you to think differently about state. A database record is a snapshot. A WebSocket event is a fact. Getting these two to stay in sync — across multiple user types, on unreliable connections, under concurrent writes — is where most of the complexity lives.

Here’s how I approach it with Laravel.

The State Machine Problem

The hardest part of any real-time system isn’t the WebSocket setup. It’s modeling the state machine that lives underneath it.

Take an auction lot lifecycle:

pending → live → sold / unsold

Or an order lifecycle:

pending → accepted → preparing → ready → picked-up → delivered

At every step, multiple actors need to see the same truth, instantly. If any of these get out of sync, the user experience breaks down — and worse, so does the business logic.

The state machine needs to be the single source of truth. Everything else — WebSocket events, push notifications, UI updates — should be a projection of that state, not a parallel copy of it.

Laravel Reverb + Broadcast Events

Laravel Reverb is the easiest way to get WebSockets running in a Laravel stack without reaching for a third-party service. It runs alongside your app, handles channel authorization natively, and integrates with Laravel Echo on the frontend.

The pattern I use:

// Fire an event after every state transition
BidPlaced::dispatch($vehicleId, $amount, $userId, $bidCount, $reserveMet);

// The event broadcasts on a public channel
class BidPlaced implements ShouldBroadcastNow
{
    public function broadcastOn(): array
    {
        return [new Channel("auction.{$this->vehicleId}")];
    }

    public function broadcastAs(): string
    {
        return 'BidPlaced';
    }
}

ShouldBroadcastNow bypasses the queue and broadcasts synchronously — useful during development and for time-sensitive events where a queue delay would feel wrong to the user.

For user-specific events (order status visible only to the customer who placed it), use private channels with PrivateChannel and define authorization in routes/channels.php. For events visible to all participants — like a bid in an open auction — public channels work fine.

One important detail: always define broadcastAs() on your events. Without it, Laravel broadcasts using the fully-qualified class name (App\Events\BidPlaced), and your frontend listener using .BidPlaced will never fire. The dot prefix convention in Echo only works when the short name is explicitly set on the event class.

Idempotency on State Transitions

Concurrent requests are the silent killer in real-time systems. A user taps a button twice. A network retry fires. Two buyers submit bids at the same millisecond.

Without idempotency guards, you get double state transitions, double entries, or duplicate notifications.

The fix is pessimistic locking inside a database transaction:

return DB::transaction(function () use ($vehicle, $user, $amount): Bid {
    $locked = Vehicle::query()
        ->lockForUpdate()
        ->findOrFail($vehicle->id);

    if ($locked->auction_status !== AuctionStatus::Live) {
        throw BidException::auctionNotLive();
    }

    $minimum = $this->resolveMinimumAmount($locked);

    if ($amount < $minimum) {
        throw BidException::belowMinimum();
    }

    // transition state, broadcast event
});

lockForUpdate acquires a row-level lock. If two requests hit this code simultaneously, the second waits until the first commits. At that point the state has already changed — the second request reads the updated minimum and fails validation cleanly. No double writes, no race condition.

Splitting Server State and Animation State

On the frontend, there are two distinct kinds of state in a real-time UI:

Server state — vehicle data, bid history, won vehicles. This comes from the server via page props or API responses and should be treated as the source of truth. On the next page load or partial reload, it gets replaced entirely.

Animation state — hammer position, current bid amount, phase labels. This changes many times per second in response to WebSocket events. It should live in the client and never block on a server round-trip.

I handle this split by combining Inertia.js for server state and Zustand for animation state:

// Inertia seeds the store on mount
useEffect(() => {
    initialize(
        currentLot.id,
        currentLot.current_bid,
        currentLot.starting_price,
        currentLot.is_leading,
        currentLot.reserve_met,
    );
}, [currentLot]);

// WebSocket events update the store directly — no server round-trip
function useAuctionSocket(vehicleId: number | null): void {
    useEchoPublic(`auction.${vehicleId}`, ['.BidPlaced', '.AuctionClosed'], (payload) => {
        if (isBidPlacedPayload(payload)) {
            onBidReceived(payload.amount, payload.isLeading, payload.reserve_met);
        }
        if (isAuctionClosedPayload(payload)) {
            slam(payload.status);
        }
    });
}

When a lot closes and the next one starts, a partial Inertia reload fetches the new current_lot from the server. The Zustand store reinitialises from the new props. Server truth wins — the animation layer just reflects it.

The Echo Hook Pattern

Rather than setting up Echo listeners directly in components, I wrap them in custom hooks. Each hook manages a single channel subscription, cleans up on unmount, and exposes nothing to the component tree — the store handles all state updates.

export function useAuctionSocket(vehicleId: number | null): void {
    const onBidReceived = useAuctionStore((s) => s.onBidReceived);
    const slam = useAuctionStore((s) => s.slam);
    const tick = useAuctionStore((s) => s.tick);

    useEchoPublic(
        vehicleId !== null ? `auction.${vehicleId}` : 'auction.__inactive__',
        vehicleId !== null ? ['.BidPlaced', '.AuctionClosed'] : [],
        vehicleId !== null ? handleBroadcast : undefined,
        [vehicleId, handleBroadcast],
    );

    useEffect(() => {
        if (vehicleId === null) return;
        const id = window.setInterval(() => tick(), 1000);
        return () => window.clearInterval(id);
    }, [vehicleId, tick]);
}

The __inactive__ channel trick avoids conditional hook calls — the hook always runs, but subscribes to a harmless placeholder channel when there’s no active vehicle. This keeps React’s rules of hooks satisfied without needing to gate the hook call at the component level.

For sale-level events (next lot started, sale ended), a second hook subscribes to a different channel scope:

export function useSaleSocket(saleId: number | null, enabled: boolean): void {
    useEchoPublic(
        saleId !== null && enabled ? `sale.${saleId}` : 'sale.__inactive__',
        enabled ? ['.NextLotStarted', '.SaleEnded'] : [],
        enabled ? handleBroadcast : undefined,
        [saleId, enabled, handleBroadcast],
    );
}

Keeping auction-level and sale-level subscriptions in separate hooks makes the event routing easy to reason about and test in isolation.

Frontend-Triggered Close with Server Authority

One pattern worth calling out: when the hammer timeout is driven by the browser but the actual close needs to be authoritative on the server.

The browser tracks silence duration for smooth animation. When the threshold is crossed, it sends a POST to the server. The server performs the close inside a transaction with lockForUpdate. Multiple close requests from different tabs or users are safe — the first to acquire the lock wins, the rest receive a { success: false } response and are silently ignored.

if ($locked->auction_status !== AuctionStatus::Live) {
    return CloseVehicleResult::alreadyClosed();
}

The browser never trusts its own timer for business logic. It only trusts what the server broadcasts back.

Sensitive Data and Broadcast Payloads

A real-time system broadcasts to everyone on the channel. That means every field in broadcastWith() is effectively public. Be explicit about what goes in.

In an auction context, the reserve price must never leave the server. The broadcast payload includes only a computed boolean:

public function broadcastWith(): array
{
    return [
        'vehicle_id'  => $this->vehicleId,
        'amount'      => $this->amount,
        'user_id'     => $this->userId,
        'bid_count'   => $this->bidCount,
        'reserve_met' => $this->reserveMet, // boolean only — never the actual price
    ];
}

The model carries #[Hidden(['reserve_price'])] as a backstop, but the broadcast payload is assembled manually so there’s no reliance on serialization behavior. Explicit is safer than implicit when the stakes are financial.

The same principle applies to winner identity. When an auction closes, other bidders do not need to know who won — only that the lot is sold and at what price. The winner’s user ID stays on the PHP event object for internal use but is never included in broadcastWith().