惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
V
V2EX
WordPress大学
WordPress大学
U
Unit 42
I
InfoQ
A
About on SuperTechFans
宝玉的分享
宝玉的分享
J
Java Code Geeks
博客园 - 司徒正美
爱范儿
爱范儿
Engineering at Meta
Engineering at Meta
G
Google Developers Blog
人人都是产品经理
人人都是产品经理
小众软件
小众软件
Microsoft Security Blog
Microsoft Security Blog
L
LangChain Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Last Week in AI
Last Week in AI
腾讯CDC
Recent Announcements
Recent Announcements

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
How I Built a Real-Time Precious Metals Price Feed for 30...
Rana Subhan · 2026-05-23 · via DEV Community

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

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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'),
    ],
],

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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,
    ],
);

Enter fullscreen mode Exit fullscreen mode

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));

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

  1. Horizontal Reverb scaling — multiple Reverb nodes behind a load balancer, using Redis pub/sub to sync broadcasts across nodes
  2. Separate read replicas for candle chart queries — OHLCV history doesn't need the primary DB
  3. Message compression — at 30k subscribers, gzip on WebSocket frames saves significant bandwidth
  4. 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