cd /news/developer-tools/how-i-built-a-real-time-precious-met… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-11166] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

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

This article describes how a developer built a real-time precious metals price feed using Laravel 11, Laravel Reverb, and Redis to handle 30,000 concurrent users. The system uses WebSocket broadcasting through Laravel Reverb to push gold and silver price updates with sub-second latency, bypassing traditional polling methods. Key architectural decisions include using Redis for both price caching and queue backend, implementing a `broadcastWhen()` filter to eliminate ~80% of redundant price updates, and keeping the database out of the subscriber hot path by only writing once per tick rather than reading 30,000 times.

read6 min views20 publishedMay 23, 2026

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

── more in #developer-tools 4 stories Β· sorted by recency
── more on @laravel 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/how-i-built-a-real-t…] indexed:0 read:6min 2026-05-23 Β· β€”