# How I Built a Real-Time Precious Metals Price Feed for 30,000 Concurrent Users in Laravel

> Source: <https://dev.to/rana_subhan/how-i-built-a-real-time-precious-metals-price-feed-for-30000-concurrent-users-in-laravel-4gjg>
> Published: 2026-05-23 10:02:22+00:00

# How I Built a Real-Time Precious Metals Price Feed for 30,000 Concurrent Users in Laravel

Architecture walkthrough: Laravel Reverb, WebSocket broadcasting, Redis pub/sub, and the tricks that make it hold under real load.

## The Problem

A live precious metals trading platform needs to push gold and silver price updates to every connected user the moment a tick arrives — not every 5 seconds via polling, not via a REST endpoint they hammer on a timer. Real-time. Sub-second latency. And it needs to handle 30,000 simultaneous connections without melting.

This post walks through exactly how I built this using Laravel 11, Laravel Reverb, Redis, and a few architectural decisions that made the difference between a demo and something production-ready.

## Stack

-
**Laravel 11**+ PHP 8.2 -
**Laravel Reverb**— self-hosted WebSocket server (replaces Pusher) -
**Laravel Echo**— frontend WebSocket client -
**Redis**— pub/sub backbone + cache -
**Laravel Horizon**— queue worker monitoring -
**MySQL 8**— OHLCV candle storage with covering indexes

Full source: [github.com/Hafiz-M-Subhan/laravel-precious-metals-platform](https://github.com/Hafiz-M-Subhan/laravel-precious-metals-platform)

## Architecture Overview

```
Price Feed (external API)
        ↓
  PriceService::ingestTick()
        ↓
  DB update + Redis cache (5s TTL)
        ↓
  broadcast(new PriceUpdated($asset))
        ↓
  Laravel Reverb (WebSocket server)
        ↓
  prices.XAU channel → 30k subscribers
```

The key insight: **the database is not in the hot path for subscribers.** Price data flows through Redis and directly out via WebSocket. The DB only gets written once per tick, not read 30,000 times.

## Step 1 — The Event

The `PriceUpdated`

event is what gets broadcast. The most important decisions are:

**Which channel?** Public, so unauthenticated visitors on the live page receive ticks too.

**What payload?** As small as possible. Every extra byte is multiplied by 30,000.

```
class PriceUpdated implements ShouldBroadcast
{
    public function broadcastOn(): array
    {
        return [
            new Channel("prices.{$this->asset->symbol}"),
            new PresenceChannel('live-event'), // carries viewer count
        ];
    }

    public function broadcastWith(): array
    {
        // 7 fields — deliberately minimal
        return [
            'symbol'     => $this->asset->symbol,
            'spot'       => (float) $this->asset->spot_price,
            'bid'        => (float) $this->asset->bid_price,
            'ask'        => (float) $this->asset->ask_price,
            'change_pct' => (float) $this->asset->daily_change_pct,
            'direction'  => $this->asset->spot_price > $this->previousPrice ? 'up' : 'down',
            'ts'         => now()->toIso8601String(),
        ];
    }

    // Skip broadcast if price moved less than 0.001% — kills ~80% of noise
    public function broadcastWhen(): bool
    {
        if ($this->previousPrice == 0) return true;
        $change = abs(($this->asset->spot_price - $this->previousPrice) / $this->previousPrice);
        return $change >= 0.00001;
    }
}
```

The `broadcastWhen()`

gate is underused in most Laravel projects. In a metals feed, prices sometimes tick the same value repeatedly. Without filtering, you're broadcasting thousands of no-op messages to 30,000 clients. With it, you cut ~80% of queue messages.

## Step 2 — The WebSocket Server (Laravel Reverb)

Laravel Reverb is Laravel's official self-hosted WebSocket server, released in 2024. Before Reverb, you either paid for Pusher or ran a separate Node.js server (Soketi, etc.). Reverb runs as a native PHP process:

```
php artisan reverb:start --host=0.0.0.0 --port=8080
```

In `config/broadcasting.php`

:

``` js
'reverb' => [
    'driver'  => 'reverb',
    'app_id'  => env('REVERB_APP_ID'),
    'app_key' => env('REVERB_APP_KEY'),
    'app_secret' => env('REVERB_APP_SECRET'),
    'options' => [
        'host' => env('REVERB_HOST', '0.0.0.0'),
        'port' => env('REVERB_PORT', 8080),
        'scheme' => env('REVERB_SCHEME', 'http'),
    ],
],
```

Reverb uses a non-blocking event loop under the hood (ReactPHP). It handles thousands of concurrent connections on a single process — no thread-per-connection model like traditional PHP.

## Step 3 — Redis as the Backbone

Redis does two jobs here:

**1. Price cache** — every tick writes to Redis with a 5-second TTL. API responses read from Redis, not MySQL. Under a burst of requests (live event with 30k viewers all hitting `/api/v1/assets`

), the DB sees exactly 0 extra reads.

```
// PriceService::ingestTick()
Cache::put("asset:price:{$symbol}", [
    'spot' => $spot, 'bid' => $bid, 'ask' => $ask,
    'ts'   => now()->toIso8601String(),
], 5); // 5 seconds
```

**2. Queue backend** — the broadcast job goes through Redis queues, not the database. This is critical. `QUEUE_CONNECTION=redis`

in `.env`

. Database queues serialize and will struggle under a price feed that fires every 2 seconds across 4 metals.

**Horizon** monitors all of this with a real dashboard at `/horizon`

. You can see queue throughput, failed jobs, and worker load in real time.

## Step 4 — OHLCV Candle Storage

Every tick needs to update the current 1-minute candle (open, high, low, close). Naive approach: SELECT + UPDATE. At 30 ticks/minute across 4 metals, that's 120 roundtrips per minute, plus locking issues.

Better approach: `upsert()`

— one query, atomic, no SELECT needed:

``` js
PriceHistory::upsert(
    [[
        'asset_id'    => $assetId,
        'resolution'  => '1m',
        'open'        => $price,  // only set on INSERT
        'high'        => $price,
        'low'         => $price,
        'close'       => $price,
        'volume'      => 0,
        'recorded_at' => now()->startOfMinute(),
    ]],
    uniqueBy: ['asset_id', 'resolution', 'recorded_at'],
    update: [
        'high'  => DB::raw("GREATEST(high, {$price})"),
        'low'   => DB::raw("LEAST(low, {$price})"),
        'close' => $price,
    ],
);
```

The unique index on `(asset_id, resolution, recorded_at)`

makes this both fast and idempotent — if the same tick somehow arrives twice, nothing breaks.

## Step 5 — The Frontend (Laravel Echo)

``` python
import Echo from 'laravel-echo';
import Pusher from 'pusher-js'; // Echo uses Pusher protocol even with Reverb

window.Pusher = Pusher;

const echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: false,
    enabledTransports: ['ws', 'wss'],
});

// Subscribe to gold price updates
echo.channel('prices.XAU')
    .listen('.price.updated', (data) => {
        updatePriceTicker(data.symbol, data.spot, data.direction);
    });

// Presence channel — get viewer count for live event page
echo.join('live-event')
    .here((users) => setViewerCount(users.length))
    .joining(() => setViewerCount(prev => prev + 1))
    .leaving(() => setViewerCount(prev => prev - 1));
```

The presence channel gives you live viewer count for free — the same mechanism Kettner uses for their live event page that peaks at 30,000 simultaneous viewers.

## Performance Numbers

| Scenario | Without optimization | With optimization |
|---|---|---|
| API under burst (1k req/s) | MySQL: 1000 reads/s | MySQL: ~0 reads (Redis cache) |
| Queue messages per minute | ~120 raw ticks | ~25 (broadcastWhen filters) |
| Candle upserts | 2 queries (SELECT+UPDATE) | 1 query (upsert) |
| Connection overhead | 30k × polling interval | 1 persistent connection per user |

## Running It Locally

```
git clone https://github.com/Hafiz-M-Subhan/laravel-precious-metals-platform.git
cd laravel-precious-metals-platform
docker compose up -d          # MySQL + Redis + Elasticsearch + Reverb + Horizon
php artisan migrate --seed
php artisan reverb:start
php artisan horizon
php artisan prices:simulate --interval=2  # simulates live price feed
```

The simulator uses Geometric Brownian Motion to generate realistic price movements — the same model used in Black-Scholes options pricing.

## What I'd Do Differently at True Scale

-
**Horizontal Reverb scaling**— multiple Reverb nodes behind a load balancer, using Redis pub/sub to sync broadcasts across nodes -
**Separate read replicas** for candle chart queries — OHLCV history doesn't need the primary DB -
**Message compression**— at 30k subscribers, gzip on WebSocket frames saves significant bandwidth -
**Backpressure handling**— slow consumers should be detected and disconnected before they cause memory pressure on the Reverb process

## Source Code

Full project on GitHub: [github.com/Hafiz-M-Subhan/laravel-precious-metals-platform](https://github.com/Hafiz-M-Subhan/laravel-precious-metals-platform)

Includes: models, events, jobs, services, Filament 3 admin panel, Docker Compose, migrations, and a price simulator.

*Tags: #laravel #php #websocket #redis #architecture*
