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
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:
'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:
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)
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
Includes: models, events, jobs, services, Filament 3 admin panel, Docker Compose, migrations, and a price simulator.
Tags: #laravel #php #websocket #redis #architecture
Top comments (0)