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

  1. Transactions are everything — Wrap all matching logic in database transactions
  2. Lock before you check — Use lockForUpdate() to prevent race conditions
  3. Price-time priority — Match best prices first, then by order time
  4. Separate concerns — Keep matching logic in a dedicated service
  5. 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.