Skip to main content

Why We Moved Critical Services from PHP to Go

00:06:13:49

The Breaking Point

Our Laravel monolith was serving us well for 90% of the platform — CRM workflows, admin panels, KYC processing, and reporting. But three specific services were consistently hitting performance walls:

  1. Real-time trade event processing — 50,000+ events per minute during peak hours
  2. Commission calculation pipeline — cascading calculations across deep IB hierarchies
  3. WebSocket gateway — 10,000+ concurrent connections for live portfolio updates

PHP's request-response model wasn't designed for long-running processes and persistent connections. We could have thrown more servers at it, but the economics didn't make sense when Go could handle the same load on a fraction of the infrastructure.

Why Go, Not Node.js or Rust

We evaluated three options for our performance-critical services:

Node.js was the easiest migration path. Our team had JavaScript experience, and the async model handles concurrent connections well. But Node's single-threaded nature meant CPU-intensive commission calculations would block the event loop. Worker threads help, but add complexity.

Rust offered the best raw performance. But our team had zero Rust experience, and the learning curve for a systems language with ownership semantics was steep. The compile-time guarantees are amazing, but we needed to ship in weeks, not months.

Go hit the sweet spot. Simple syntax that PHP developers picked up in days. Goroutines handle concurrency without callback hell. Strong standard library. And the performance was more than sufficient — we weren't building a database engine, we were building backend services.

Service 1: Trade Event Processor

The trade event processor consumes events from our MT4/MT5 CPlugin via Redis Streams and fans them out to multiple consumers — commission calculator, portfolio updater, risk monitor, and notification service.

go
package main

import (
    "context"
    "log"

    "github.com/redis/go-redis/v9"
)

type TradeEvent struct {
    Login     int     `json:"login"`
    Symbol    string  `json:"symbol"`
    Action    string  `json:"action"`  // open, close, modify
    Volume    float64 `json:"volume"`
    Price     float64 `json:"price"`
    Timestamp int64   `json:"timestamp"`
}

func (p *Processor) ConsumeTradeEvents(ctx context.Context) {
    for {
        results, err := p.redis.XReadGroup(ctx, &redis.XReadGroupArgs{
            Group:    "trade-processors",
            Consumer: p.consumerID,
            Streams:  []string{"trade-events", ">"},
            Count:    100,
            Block:    5 * time.Second,
        }).Result()

        if err != nil {
            continue
        }

        for _, msg := range results[0].Messages {
            event := parseTradeEvent(msg)

            // Fan out to consumers via goroutines
            go p.calculateCommissions(event)
            go p.updatePortfolio(event)
            go p.checkRiskLimits(event)

            p.redis.XAck(ctx, "trade-events",
                "trade-processors", msg.ID)
        }
    }
}

The key design decisions:

  • Redis Streams over Pub/Sub — Streams provide consumer groups, acknowledgment, and replay capability. If our processor crashes, unacknowledged messages are re-delivered.
  • Batch reading — consuming 100 messages at a time reduces Redis round-trips during high-volume periods.
  • Goroutine fan-out — each downstream consumer runs concurrently. Commission calculation doesn't block portfolio updates.

Service 2: Commission Pipeline

Commission calculation is CPU-intensive. For each trade, we walk the IB hierarchy and calculate rebates at every level. Some hierarchies are 6-7 levels deep, and each level might have different commission structures.

go
type CommissionEngine struct {
    db        *sql.DB
    hierarchy *HierarchyCache
}

type CommissionResult struct {
    IBID   int
    Amount float64
    Depth  int
}

func (e *CommissionEngine) Calculate(
    trade TradeEvent,
) ([]CommissionResult, error) {
    // Get the IB chain from cached closure table
    ancestors := e.hierarchy.GetAncestors(trade.Login)

    results := make([]CommissionResult, 0, len(ancestors))

    for _, ancestor := range ancestors {
        scheme := e.getCommissionScheme(ancestor.IBID)
        amount := scheme.Calculate(trade.Volume, trade.Symbol)

        results = append(results, CommissionResult{
            IBID:   ancestor.IBID,
            Amount: amount,
            Depth:  ancestor.Depth,
        })
    }

    return results, nil
}

The hierarchy cache is critical. We preload the entire IB closure table into memory on startup and refresh it every 30 seconds. This turns what would be database queries on every trade into in-memory map lookups.

go
type HierarchyCache struct {
    mu        sync.RWMutex
    ancestors map[int][]Ancestor  // clientID -> ancestor chain
}

func (h *HierarchyCache) Refresh(db *sql.DB) {
    rows, _ := db.Query(`
        SELECT descendant_id, ancestor_id, depth
        FROM ib_closure
        ORDER BY descendant_id, depth
    `)

    newCache := make(map[int][]Ancestor)
    for rows.Next() {
        var desc, anc, depth int
        rows.Scan(&desc, &anc, &depth)
        newCache[desc] = append(newCache[desc], Ancestor{
            IBID: anc, Depth: depth,
        })
    }

    h.mu.Lock()
    h.ancestors = newCache
    h.mu.Unlock()
}

With this approach, we went from ~45ms per commission calculation (database queries on each trade) to ~0.2ms (in-memory lookups). At 50,000 trades per minute during peak, that's the difference between needing 37 CPU-minutes and 0.17 CPU-minutes.

Service 3: WebSocket Gateway

The WebSocket gateway maintains persistent connections with client browsers, pushing real-time portfolio updates, price feeds, and notifications.

go
type WSGateway struct {
    clients sync.Map  // login -> *Client
}

type Client struct {
    conn     *websocket.Conn
    login    int
    send     chan []byte
}

func (gw *WSGateway) HandleConnection(w http.ResponseWriter, r *http.Request) {
    conn, _ := upgrader.Upgrade(w, r, nil)
    login := authenticateFromToken(r)

    client := &Client{
        conn:  conn,
        login: login,
        send:  make(chan []byte, 256),
    }

    gw.clients.Store(login, client)

    go client.writePump()
    go client.readPump()
}

// Called when portfolio changes
func (gw *WSGateway) PushUpdate(login int, data []byte) {
    if client, ok := gw.clients.Load(login); ok {
        select {
        case client.(*Client).send <- data:
        default:
            // Client buffer full, skip update
        }
    }
}

Go's goroutine model is perfect for WebSockets. Each connection gets two goroutines (read + write) that cost ~4KB of memory each. 10,000 concurrent connections use ~80MB — trivial. The same in PHP would require 10,000 processes or a separate WebSocket server entirely.

What Stayed in Laravel

Not everything moved to Go. Laravel still handles:

  • Admin CRM interface — CRUD operations, search, filters. Laravel + Blade/Livewire is unbeatable for building admin panels quickly.
  • KYC workflows — document upload, Sumsub webhook processing, status management. Business logic heavy, not performance critical.
  • Reporting and exports — PDF generation, Excel exports, scheduled reports. Laravel's queue system handles these beautifully.
  • API for the client portal — authentication, deposit/withdrawal requests, profile management. Standard REST API work.

The rule of thumb: if it's request-response CRUD, Laravel. If it's long-running, high-throughput, or requires persistent connections, Go.

Deployment: Two Services, One Platform

We run both services on the same infrastructure using Docker:

yaml
# Simplified docker-compose
services:
  web:
    image: laravel-crm:latest
    replicas: 3

  trade-processor:
    image: go-trade-processor:latest
    replicas: 2

  commission-engine:
    image: go-commission-engine:latest
    replicas: 2

  ws-gateway:
    image: go-ws-gateway:latest
    replicas: 1

  redis:
    image: redis:7-alpine

Redis acts as the communication bridge. Laravel dispatches events to Redis, Go services consume them. Go services publish updates to Redis, Laravel reads them for reporting. Clean separation, no direct coupling.

The Results

After migrating the three services to Go:

  • Trade processing latency dropped from ~200ms to ~5ms (40x improvement)
  • Commission calculation throughput went from 1,500/min to 300,000/min
  • WebSocket capacity went from 2,000 connections (with Node.js) to 15,000 on a single instance
  • Infrastructure cost dropped 60% — we went from 8 Laravel worker servers to 2 Go instances

The total migration took about 6 weeks with a team of two developers. The Go services have been running in production for over a year with zero downtime related to the services themselves.

Key Takeaways

  1. Don't rewrite everything — identify the 10% of services causing 90% of performance issues
  2. Go is approachable for PHP developers — the syntax is simple, the tooling is excellent
  3. Redis Streams bridge the gap between Laravel and Go cleanly without tight coupling
  4. In-memory caching of hot data (like IB hierarchies) is a massive performance multiplier
  5. Goroutines make concurrency trivial — WebSockets, fan-out, and background processing just work
  6. Measure before and after — the numbers justify the investment to stakeholders

You don't need to pick one language for everything. Use Laravel where it shines (rapid development, CRUD, admin tools) and Go where you need raw throughput. The combination is more powerful than either alone.

Want to discuss this topic or work together? Get in touch.

Contact me