Building a Crypto Trading Platform in One Day: Technical Deep Dive
Building a cryptocurrency exchange sounds intimidating. Order matching, real-time updates, atomic transactions, race conditions — the complexity seems endless. But with the right architecture and a focused approach, you can build a functional trading platform in a single day.
This is a technical breakdown of the fullstack-challenge-in-a-day project — a simulated crypto exchange built with Laravel 12 and Vue.js 3.
The Architecture
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │OrderForm│ │Orderbook │ │PriceChart│ │ WalletOverview │ │
│ └────┬────┘ └────┬─────┘ └────┬─────┘ └───────┬────────┘ │
│ └───────────┴────────────┴───────────────┘ │
│ │ │
│ Axios API │
└───────────────────────────┼───────────────────────────────────┘
│
┌───────────────────────────┼───────────────────────────────────┐
│ Laravel API │
│ ┌────────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ OrderController│ │MarketController│ │ TradeController │ │
│ └───────┬────────┘ └──────┬──────┘ └──────────┬───────────┘ │
│ └─────────────────┴────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ OrderMatchingService │ │
│ └─────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
The frontend is a Vue.js SPA communicating with a Laravel API. The heart of the system is the OrderMatchingService — where buy meets sell.
The Order Matching Engine
This is where the magic happens. When a user places a buy order, we need to find matching sell orders and execute trades atomically.
The Matching Algorithm
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\Trade;
use Illuminate\Support\Facades\DB;
class OrderMatchingService
{
private const COMMISSION_RATE = 0.015; // 1.5%
public function matchOrder(Order $incomingOrder): array
{
return DB::transaction(function () use ($incomingOrder) {
$trades = [];
// Find matching orders (opposite side, price alignment)
$matchingOrders = $this->findMatchingOrders($incomingOrder);
foreach ($matchingOrders as $existingOrder) {
if ($incomingOrder->remaining_quantity <= 0) {
break;
}
$trade = $this->executeTrade($incomingOrder, $existingOrder);
$trades[] = $trade;
}
return $trades;
});
}
private function findMatchingOrders(Order $order): Collection
{
$query = Order::where('asset', $order->asset)
->where('side', $order->side === 'buy' ? 'sell' : 'buy')
->where('status', 'open')
->where('remaining_quantity', '>', 0);
if ($order->side === 'buy') {
// Buy order matches sell orders at or below the buy price
$query->where('price', '<=', $order->price)
->orderBy('price', 'asc'); // Best (lowest) price first
} else {
// Sell order matches buy orders at or above the sell price
$query->where('price', '>=', $order->price)
->orderBy('price', 'desc'); // Best (highest) price first
}
return $query->orderBy('created_at', 'asc') // Time priority
->lockForUpdate() // Prevent race conditions
->get();
}
}
Price-Time Priority
Real exchanges use price-time priority: best price wins, and if prices are equal, the older order wins. This is implemented with:
->orderBy('price', 'asc') // Price priority
->orderBy('created_at', 'asc') // Time priority
Executing the Trade
private function executeTrade(Order $buyOrder, Order $sellOrder): Trade
{
// Determine trade quantity (minimum of both remaining quantities)
$quantity = min(
$buyOrder->remaining_quantity,
$sellOrder->remaining_quantity
);
// Execute at seller's price (maker gets their price)
$price = $sellOrder->price;
$total = $quantity * $price;
$commission = $total * self::COMMISSION_RATE;
// Create trade record
$trade = Trade::create([
'buy_order_id' => $buyOrder->id,
'sell_order_id' => $sellOrder->id,
'asset' => $buyOrder->asset,
'quantity' => $quantity,
'price' => $price,
'commission' => $commission,
]);
// Update order quantities
$this->updateOrderQuantities($buyOrder, $sellOrder, $quantity);
// Transfer assets between users
$this->transferAssets($buyOrder->user, $sellOrder->user, $quantity, $total, $commission);
return $trade;
}
Preventing Race Conditions
In a trading system, race conditions can be catastrophic. Two orders matching simultaneously could result in double-spending or negative balances.
Database Locking
return DB::transaction(function () use ($incomingOrder) {
// Lock matching orders to prevent concurrent modifications
$matchingOrders = Order::where('status', 'open')
->lockForUpdate() // SELECT ... FOR UPDATE
->get();
// Now we have exclusive access to these rows
});
Balance Validation
Before placing an order, we lock and validate the user’s balance:
public function placeOrder(User $user, array $data): Order
{
return DB::transaction(function () use ($user, $data) {
// Lock user's assets
$asset = $user->assets()
->where('symbol', $data['side'] === 'buy' ? 'USD' : $data['asset'])
->lockForUpdate()
->first();
$requiredAmount = $data['side'] === 'buy'
? $data['quantity'] * $data['price']
: $data['quantity'];
if ($asset->available < $requiredAmount) {
throw new InsufficientBalanceException();
}
// Lock the funds
$asset->available -= $requiredAmount;
$asset->locked += $requiredAmount;
$asset->save();
// Create the order
return Order::create([...]);
});
}
The Data Models
Order Model
<?php
namespace App\Models;
class Order extends Model
{
protected $fillable = [
'user_id',
'asset', // BTC, ETH
'side', // buy, sell
'type', // limit, market
'price',
'quantity',
'remaining_quantity',
'status', // open, filled, cancelled, partially_filled
];
protected $casts = [
'price' => 'decimal:8',
'quantity' => 'decimal:8',
'remaining_quantity' => 'decimal:8',
];
public function trades()
{
return $this->hasMany(Trade::class, 'buy_order_id')
->orWhere('sell_order_id', $this->id);
}
public function isFilled(): bool
{
return $this->remaining_quantity <= 0;
}
}
Trade Model
<?php
namespace App\Models;
class Trade extends Model
{
protected $fillable = [
'buy_order_id',
'sell_order_id',
'asset',
'quantity',
'price',
'commission',
];
public function buyOrder()
{
return $this->belongsTo(Order::class, 'buy_order_id');
}
public function sellOrder()
{
return $this->belongsTo(Order::class, 'sell_order_id');
}
}
The API Layer
Order Controller
<?php
namespace App\Http\Controllers\Api;
class OrderController extends Controller
{
public function __construct(
private OrderMatchingService $matchingService
) {}
public function store(CreateOrderRequest $request)
{
$order = $this->matchingService->placeOrder(
$request->user(),
$request->validated()
);
// Attempt to match immediately
$trades = $this->matchingService->matchOrder($order);
return response()->json([
'order' => new OrderResource($order->fresh()),
'trades' => TradeResource::collection($trades),
], 201);
}
public function cancel(Order $order)
{
$this->authorize('cancel', $order);
$this->matchingService->cancelOrder($order);
return response()->json([
'message' => 'Order cancelled',
'order' => new OrderResource($order->fresh()),
]);
}
}
Request Validation
<?php
namespace App\Http\Requests;
class CreateOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'asset' => ['required', 'in:BTC,ETH'],
'side' => ['required', 'in:buy,sell'],
'type' => ['required', 'in:limit'],
'price' => ['required', 'numeric', 'min:0.00000001'],
'quantity' => ['required', 'numeric', 'min:0.00000001'],
];
}
}
The Frontend
Orderbook Component
<template>
<div class="orderbook">
<div class="sells">
<div
v-for="order in sellOrders"
:key="order.price"
class="order-row sell"
>
<span class="price text-red-500"></span>
<span class="quantity"></span>
<div
class="depth-bar bg-red-500/20"
:style="{ width: getDepthWidth(order, 'sell') }"
/>
</div>
</div>
<div class="spread">
</div>
<div class="buys">
<div
v-for="order in buyOrders"
:key="order.price"
class="order-row buy"
>
<span class="price text-green-500"></span>
<span class="quantity"></span>
<div
class="depth-bar bg-green-500/20"
:style="{ width: getDepthWidth(order, 'buy') }"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useMarketStore } from '@/stores/market'
const store = useMarketStore()
const sellOrders = computed(() =>
store.orderbook.sells.slice().reverse()
)
const buyOrders = computed(() => store.orderbook.buys)
const currentSpread = computed(() => {
const lowestSell = sellOrders.value[0]?.price || 0
const highestBuy = buyOrders.value[0]?.price || 0
return (lowestSell - highestBuy).toFixed(2)
})
</script>
Order Form Component
<template>
<form @submit.prevent="submitOrder" class="order-form">
<div class="side-toggle">
<button
type="button"
:class="{ active: side === 'buy' }"
@click="side = 'buy'"
>
Buy
</button>
<button
type="button"
:class="{ active: side === 'sell' }"
@click="side = 'sell'"
>
Sell
</button>
</div>
<input
v-model.number="price"
type="number"
step="0.01"
placeholder="Price (USD)"
/>
<input
v-model.number="quantity"
type="number"
step="0.0001"
placeholder="Quantity"
/>
<div class="total">
Total: $
</div>
<button type="submit" :disabled="loading">
</button>
</form>
</template>
<script setup>
import { ref } from 'vue'
import { useOrderStore } from '@/stores/orders'
const props = defineProps(['asset'])
const orderStore = useOrderStore()
const side = ref('buy')
const price = ref(null)
const quantity = ref(null)
const loading = ref(false)
async function submitOrder() {
loading.value = true
try {
await orderStore.createOrder({
asset: props.asset,
side: side.value,
type: 'limit',
price: price.value,
quantity: quantity.value,
})
price.value = null
quantity.value = null
} finally {
loading.value = false
}
}
</script>
Testing Strategy
Unit Tests for Matching Engine
<?php
use App\Services\OrderMatchingService;
test('buy order matches lower-priced sell order', function () {
$seller = User::factory()->withBalance('BTC', 1)->create();
$buyer = User::factory()->withBalance('USD', 10000)->create();
$sellOrder = Order::factory()->create([
'user_id' => $seller->id,
'side' => 'sell',
'asset' => 'BTC',
'price' => 50000,
'quantity' => 0.5,
]);
$buyOrder = Order::factory()->create([
'user_id' => $buyer->id,
'side' => 'buy',
'asset' => 'BTC',
'price' => 51000, // Willing to pay more
'quantity' => 0.5,
]);
$service = new OrderMatchingService();
$trades = $service->matchOrder($buyOrder);
expect($trades)->toHaveCount(1);
expect($trades[0]->price)->toBe(50000); // Executed at seller's price
expect($sellOrder->fresh()->status)->toBe('filled');
expect($buyOrder->fresh()->status)->toBe('filled');
});
test('prevents matching own orders', function () {
$user = User::factory()
->withBalance('USD', 10000)
->withBalance('BTC', 1)
->create();
$sellOrder = Order::factory()->create([
'user_id' => $user->id,
'side' => 'sell',
'price' => 50000,
]);
$buyOrder = Order::factory()->create([
'user_id' => $user->id,
'side' => 'buy',
'price' => 51000,
]);
$service = new OrderMatchingService();
$trades = $service->matchOrder($buyOrder);
expect($trades)->toBeEmpty();
});
E2E Tests with Playwright
import { test, expect } from '@playwright/test'
test('user can place and cancel an order', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/trading')
// Place a buy order
await page.click('button:has-text("Buy")')
await page.fill('[placeholder="Price (USD)"]', '50000')
await page.fill('[placeholder="Quantity"]', '0.1')
await page.click('button:has-text("Buy BTC")')
// Verify order appears
await expect(page.locator('.open-orders')).toContainText('50000')
// Cancel the order
await page.click('.open-orders .cancel-btn')
await expect(page.locator('.open-orders')).not.toContainText('50000')
})
Key Takeaways
- Transactions are everything — Wrap all matching logic in database transactions
- Lock before you check — Use
lockForUpdate()to prevent race conditions - Price-time priority — Match best prices first, then by order time
- Separate concerns — Keep matching logic in a dedicated service
- Test the edges — Partial fills, self-matching, insufficient balance
Building a trading platform in a day forces you to focus on what matters: the core matching engine, clean data models, and a usable interface. Everything else is polish.
The full source code is available at github.com/Stdubic/fullstack-challenge-in-a-day.