DEV Community

Ikenna Anunuso
Ikenna Anunuso

Posted on

Building an SPA, Real-Time Trading Platform with Vanilla PHP & JavaScript

The Beginning: Why Vanilla?

In an era dominated by frameworks—Laravel, Symfony, React, Vue—I made what many would consider a controversial decision: build a fully-featured trading platform using vanilla PHP and vanilla JavaScript. No Laravel magic, no React complexity, no npm dependency hell. Just pure, unadulterated code.

Was I crazy? Maybe. But by the end of this journey, I had created Tradity, a progressive web application that streams live cryptocurrency prices, executes trades in real-time, and feels as snappy as any framework-powered platform—all while maintaining complete control over every line of code.

This is the story of how simplicity conquered complexity, and why sometimes, the old ways are the best ways.


Chapter 1: The Architecture Decision

The Problem

I wanted to build a stock/crypto trading simulator that could:

  • Stream real-time price data from Binance
  • Handle concurrent user connections without breaking a sweat
  • Work offline with PWA capabilities
  • Be deployable anywhere—from shared hosting to VPS
  • Remain maintainable by a single developer (me!)

The Framework Temptation

Every tutorial screamed: "Use Laravel! Use Next.js! Use [insert framework here]!"

But frameworks come with baggage:

  • Vendor lock-in: What happens when Laravel 15 breaks your Laravel 10 code?
  • Complexity overhead: Do I really need 50MB of node_modules for a trading app?
  • Learning curve: Framework conventions vs. fundamental programming
  • Performance: Abstraction layers add latency—critical in trading

The Vanilla Revelation

Then it hit me: PHP is already a framework. It has routing ($_SERVER['REQUEST_URI']), templating (<?php echo $data ?>), and database access (mysqli, PDO). JavaScript has fetch API, ES6 modules, and native WebSocket support.

Decision made: Vanilla stack it is.


Chapter 2: Building the Backend—PHP's Hidden Power

The API Router (No Laravel Required)

// index.php - The heart of the backend
$request = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];

// Simple, explicit routing
if ($request === '/api/auth/login' && $method === 'POST') {
    AuthController::login();
} elseif ($request === '/api/trades' && $method === 'GET') {
    TradeController::getTradeHistory();
} else {
    Response::error('Endpoint not found', 404);
}
Enter fullscreen mode Exit fullscreen mode

Why this is beautiful:

  • ✅ Zero routing overhead
  • ✅ Explicit control flow
  • ✅ Easy debugging (no magic middleware chains)
  • ✅ Works on any PHP 7.4+ server

The Service Layer Pattern

I adopted a clean architecture approach:

Controllers → Services → Models
Enter fullscreen mode Exit fullscreen mode

Example: Executing a Trade

// TradeController.php
class TradeController {
    public static function executeTrade() {
        $data = json_decode(file_get_contents('php://input'), true);

        // Controller only handles HTTP
        $result = TradeService::executeTrade(
            $data['user_id'],
            $data['symbol'],
            $data['type'],
            $data['amount']
        );

        Response::success($result);
    }
}

// TradeService.php - Business logic lives here
class TradeService {
    public static function executeTrade($userId, $symbol, $type, $amount) {
        // Validate balance
        $account = TradeAccountService::getAccountById($userId);
        if ($account['balance'] < $amount) {
            throw new Exception('Insufficient funds');
        }

        // Get real-time price from Binance
        $price = BinanceService::getCurrentPrice($symbol);

        // Execute trade
        $trade = Trade::create([
            'user_id' => $userId,
            'symbol' => $symbol,
            'type' => $type,
            'amount' => $amount,
            'price' => $price,
            'status' => 'completed'
        ]);

        // Update account balance
        TradeAccountService::updateBalance($userId, -$amount);

        return $trade;
    }
}
Enter fullscreen mode Exit fullscreen mode

The payoff:

  • Business logic separated from HTTP concerns
  • Reusable services across controllers
  • Testable without mocking HTTP requests
  • Clear dependency flow

Chapter 3: The WebSocket Revelation—ReactPHP Enters the Chat

Here's where things got interesting. HTTP polling is dead. Long polling is a hack. WebSockets are the future.

The Challenge

Trading platforms need push-based updates:

  • Price changes every second
  • Order fills happen instantly
  • Account balances update in real-time

Traditional PHP dies after each request. How do you keep a persistent connection?

Enter ReactPHP + Ratchet

// websocket/server.php
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;

require __DIR__ . '/../vendor/autoload.php';

class TradingWebSocket implements MessageComponentInterface {
    protected $clients;
    protected $binanceClient;

    public function __construct() {
        $this->clients = new \SplObjectStorage;
        $this->connectToBinance();
    }

    // When a client connects
    public function onOpen(ConnectionInterface $conn) {
        $this->clients->attach($conn);
        echo "New connection! ({$conn->resourceId})\n";
    }

    // When Binance sends price update
    private function connectToBinance() {
        $connector = new \Ratchet\Client\Connector($this->loop);

        $connector('wss://stream.binance.com:443/ws/btcusdt@kline_1s')
            ->then(function($conn) {
                $conn->on('message', function($msg) {
                    $data = json_decode($msg);
                    $price = $data->k->c; // Close price

                    // Broadcast to ALL connected clients
                    $this->broadcastPrice('BTCUSDT', $price);
                });
            });
    }

    // Push updates to all clients
    private function broadcastPrice($symbol, $price) {
        $payload = json_encode([
            'type' => 'price_update',
            'symbol' => $symbol,
            'price' => $price,
            'timestamp' => time()
        ]);

        foreach ($this->clients as $client) {
            $client->send($payload);
        }
    }
}

// Start the WebSocket server
$server = IoServer::factory(
    new HttpServer(
        new WsServer(
            new TradingWebSocket()
        )
    ),
    8080
);

echo "WebSocket server running on port 8080...\n";
$server->run();
Enter fullscreen mode Exit fullscreen mode

The Magic Explained

  1. ReactPHP provides an event loop (like Node.js, but in PHP!)
  2. Ratchet wraps ReactPHP for WebSocket protocol handling
  3. Server connects to Binance WebSocket stream
  4. Receives price updates every second
  5. Broadcasts to all connected frontend clients

No polling. No HTTP overhead. Pure real-time magic.


Chapter 4: The Production Reality—Shared Hosting Strikes Back

The Port 8080 Problem

In development: ws://localhost:8080 worked perfectly.

In production (cPanel): Connection refused 💀

Why? Most shared hosting blocks non-standard ports (8080, 9443) for security.

The Solution: Apache Proxy on Port 443

# websocket/.htaccess
<IfModule mod_proxy.c>
    <IfModule mod_proxy_wstunnel.c>
        # Proxy WebSocket connections through HTTPS (port 443)
        RewriteEngine On
        RewriteCond %{HTTP:Upgrade} =websocket [NC]
        RewriteRule ^(.*)$ ws://127.0.0.1:8080/$1 [P,L]

        # Fallback for non-WebSocket requests
        RewriteRule ^(.*)$ http://127.0.0.1:8080/$1 [P,L]
    </IfModule>
</IfModule>
Enter fullscreen mode Exit fullscreen mode

How it works:

  • Frontend connects to wss://yourdomain.com/app/backend/websocket/ (port 443—always open!)
  • Apache detects Upgrade: websocket header
  • Proxies connection to internal ws://127.0.0.1:8080
  • WebSocket server handles the connection

Result: WebSockets work on ANY hosting with Apache! 🎉


Chapter 5: The Frontend—SPA Without the Framework

The Vanilla JS Module Pattern

// js/modules/websocket.js
export class WebSocketManager {
    constructor() {
        this.ws = null;
        this.reconnectAttempts = 0;
        this.maxReconnectAttempts = 10;
    }

    connect() {
        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
        const host = window.location.host;
        const wsUrl = `${protocol}//${host}/app/backend/websocket/`;

        this.ws = new WebSocket(wsUrl);

        this.ws.onmessage = (event) => {
            const data = JSON.parse(event.data);

            if (data.type === 'price_update') {
                this.updatePriceUI(data.symbol, data.price);
            }
        };

        this.ws.onclose = () => {
            if (this.reconnectAttempts < this.maxReconnectAttempts) {
                setTimeout(() => this.connect(), 1000);
                this.reconnectAttempts++;
            }
        };
    }

    updatePriceUI(symbol, price) {
        const priceElement = document.querySelector(`[data-symbol="${symbol}"]`);
        if (priceElement) {
            priceElement.textContent = `$${parseFloat(price).toFixed(2)}`;

            // Add flash animation
            priceElement.classList.add('price-flash');
            setTimeout(() => priceElement.classList.remove('price-flash'), 300);
        }
    }
}

// app.js
import { WebSocketManager } from './modules/websocket.js';
import { Router } from './modules/router.js';
import { Auth } from './modules/auth.js';

const app = {
    ws: new WebSocketManager(),
    router: new Router(),
    auth: new Auth(),

    init() {
        this.router.init();
        if (this.auth.isLoggedIn()) {
            this.ws.connect();
        }
    }
};

document.addEventListener('DOMContentLoaded', () => app.init());
Enter fullscreen mode Exit fullscreen mode

Client-Side Routing (No React Router Needed)

// js/modules/router.js
export class Router {
    constructor() {
        this.routes = {
            '/': 'pages/dashboard.html',
            '/trade': 'pages/trade.html',
            '/history': 'pages/history.html'
        };
    }

    init() {
        window.addEventListener('popstate', () => this.loadRoute());

        document.addEventListener('click', (e) => {
            if (e.target.matches('[data-link]')) {
                e.preventDefault();
                this.navigate(e.target.href);
            }
        });

        this.loadRoute();
    }

    async navigate(url) {
        history.pushState(null, null, url);
        await this.loadRoute();
    }

    async loadRoute() {
        const path = window.location.pathname;
        const template = this.routes[path] || this.routes['/'];

        const response = await fetch(template);
        const html = await response.text();

        document.getElementById('app').innerHTML = html;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this approach rocks:

  • No build step (just write code and refresh!)
  • Native ES6 modules (browser-native, no Webpack)
  • Small bundle size (~20KB total JS)
  • Easy debugging (no sourcemaps, no transpilation)

Chapter 6: Progressive Web App—Offline Trading

The Service Worker Strategy

// service-worker.js
const CACHE_VERSION = 'tradity-v1';
const STATIC_CACHE = [
    '/',
    '/css/app.css',
    '/js/app.js',
    '/js/modules/websocket.js',
    '/img/logo.png'
];

// Install: Cache static assets
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_VERSION).then((cache) => {
            return cache.addAll(STATIC_CACHE);
        })
    );
});

// Fetch: Network-first for API, cache-first for assets
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    // Never cache API calls (real-time data!)
    if (url.pathname.includes('/api/')) {
        event.respondWith(fetch(event.request));
        return;
    }

    // Cache-first for static assets
    event.respondWith(
        caches.match(event.request).then((response) => {
            return response || fetch(event.request);
        })
    );
});
Enter fullscreen mode Exit fullscreen mode

Result:

  • App loads instantly (even on slow 3G)
  • Works offline for viewing trade history
  • Updates in background when online

The manifest.json

{
  "name": "Tradity Trading Platform",
  "short_name": "Tradity",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#1a1a2e",
  "theme_color": "#0f3460",
  "icons": [
    {
      "src": "/img/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/img/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

One tap install on mobile—looks like a native app! 📱


Chapter 7: The Lessons Learned

What Worked Brilliantly

1. Vanilla PHP Scalability

  • Deployed on $5/month VPS
  • Handles 150+ concurrent WebSocket connections
  • Zero framework overhead = fast responses

2. ReactPHP Power

  • PHP isn't just for request/response anymore
  • Event-driven architecture in PHP (who knew?!)
  • Binance WebSocket integration was seamless

3. PWA Capabilities

  • Users "install" the app like a native app
  • Offline support for critical features
  • Push notifications for trade alerts

4. Maintainability

  • No framework updates breaking things
  • Every line of code is intentional
  • Debugging is straightforward (no magic)

The Challenges

1. WebSocket Port Blocking

  • Solution: Apache proxy on port 443
  • Learning: Production environments are different beasts

2. Browser Caching vs. Service Worker

  • Problem: API responses getting cached (disaster for trading!)
  • Solution: Explicit cache-control headers + service worker configuration

3. Race Conditions in Production

  • Problem: Users seeing wrong account data under load
  • Cause: Type mismatch in SQL queries ("s" vs "i" binding)
  • Solution: Strict type declarations in prepared statements

4. MySQL Connections in Long-Running Processes

  • Problem: WebSocket server loses DB connection after 8 hours
  • Solution: Connection pooling + reconnection logic

Chapter 8: Performance Metrics—The Proof

Backend Response Times

Endpoint              Avg Response Time
----------------------------------------
/api/auth/login       45ms
/api/trades/execute   78ms
/api/user/profile     23ms
WebSocket message     <5ms (push-based!)
Enter fullscreen mode Exit fullscreen mode

Frontend Load Times

Metric                First Load    Cached Load
------------------------------------------------
HTML                  120ms         5ms
CSS Bundle            45ms          2ms
JS Bundle (20KB)      35ms          1ms
Total Interactive     310ms         15ms
Enter fullscreen mode Exit fullscreen mode

Lighthouse Score: 98/100 🎯

WebSocket Performance

  • Message latency: <50ms from Binance → Frontend
  • Concurrent users: 150+ (tested with ws-benchmark)
  • Memory usage: 180MB for server process
  • Uptime: 99.8% (cron job restarts if crashed)

Chapter 9: Deployment—From Localhost to Production

The Development Environment

XAMPP (Windows)
└── htdocs/
    └── tradity-backend/
        ├── api/
        ├── services/
        ├── controllers/
        ├── websocket/
        └── index.php
Enter fullscreen mode Exit fullscreen mode

The Production Setup (cPanel VPS)

# 1. Upload files via Git or FTP
git clone https://github.com/yourusername/tradity-software.git

# 2. Install Composer dependencies
composer install --no-dev --optimize-autoloader

# 3. Setup database
mysql -u root -p < db/migrations/schema.sql

# 4. Start WebSocket server
cd websocket/
php server.php > server.log 2>&1 &

# 5. Setup cron job for auto-restart
*/5 * * * * /bin/bash /path/to/websocket/start_websocket.sh
Enter fullscreen mode Exit fullscreen mode

The Magic Setup Script (No SSH Needed!)

I created a web-based setup wizard for clients:

// websocket/setup.php - Deploy with zero technical knowledge!
<?php
$websocketDir = __DIR__;
$pidFile = "$websocketDir/server.pid";

// One-click server start
if ($_POST['action'] === 'start') {
    $command = "nohup /usr/local/bin/php $websocketDir/server.php > $websocketDir/server.log 2>&1 & echo $! > $pidFile";
    exec($command);
    echo "✅ Server started!";
}

// Display current status
$pid = file_exists($pidFile) ? file_get_contents($pidFile) : null;
$running = $pid && posix_kill($pid, 0);
?>

<div class="status">
    <?php if ($running): ?>
        ✅ Server is running (PID: <?= $pid ?>)
    <?php else: ?>
        ❌ Server is stopped
    <?php endif; ?>
</div>

<button onclick="startServer()">▶️ Start Server</button>
<button onclick="stopServer()">⏹️ Stop Server</button>
Enter fullscreen mode Exit fullscreen mode

Clients deploy in 3 clicks. No terminal commands. No SSH panic. 🎉


Chapter 10: The Verdict—Was Vanilla Worth It?

By the Numbers

Metric Vanilla Stack Framework Stack
Total Code Size 12MB (with vendor) 250MB+ (node_modules)
Boot Time 8ms 50-200ms
Memory Footprint 32MB per process 100MB+ per process
Learning Curve 2 weeks 2 months
Deployment Copy files, done Build, transpile, deploy
Long-term Maintenance Full control Framework updates required

The Philosophical Win

I understand every line of code in this project.

No magic. No "it just works" (until it doesn't). When something breaks, I know exactly where to look because I wrote it.

When Vanilla Makes Sense

Use vanilla when:

  • You need full control over performance
  • You're building long-term (5+ years)
  • You want zero dependencies to update
  • You're deploying to diverse environments (shared hosting to VPS)
  • You want to deeply understand how things work

Use frameworks when:

  • Rapid prototyping is priority
  • You have a large team with framework experience
  • You need battle-tested solutions (authentication, ORM)
  • Time-to-market is critical
  • You're building standard CRUD apps

Epilogue: What's Next?

This project taught me that modern web development doesn't require modern frameworks. PHP 8, vanilla JavaScript, and WebSockets are powerful enough to build production-grade real-time applications.

Future Enhancements

  • Multi-exchange support (Coinbase, Kraken via adapter pattern)
  • Technical indicators (Moving averages, RSI, Bollinger Bands)
  • Social trading (Copy trades from top performers)
  • Mobile apps (PWA already works, native iOS/Android next)

Open Source?

I'm considering open-sourcing the core platform. If you're interested, drop a comment!


The Code

You can explore the source code at:


Final Thoughts

Building Tradity was a rebellion against the framework-first mindset. It proved that fundamentals matter more than trends.

Sure, Laravel would've given me authentication scaffolding. React would've given me state management. But at what cost?

I traded convenience for control, and I'd do it again.

Because at the end of the day, when your users are trading real money (even simulated), you need to understand every single line of code that stands between their click and the database.

Vanilla PHP and vanilla JavaScript gave me that understanding.

And that's priceless.


What's your take? Are frameworks overhyped, or am I a masochist? Let's debate in the comments! 👇


Bonus: Resources for Aspiring Vanilla Builders

PHP WebSockets:

Vanilla JS Patterns:

PWA Guide:

Trading APIs:


Happy coding, and may your trades always be profitable! 📈

Top comments (0)