📁 Volume II: Laravel Performance Tuning Kit

📊 Topic 10: Read/Write Splitting

Double your database capacity without changing a single line of application code.

"Your database does two things: reads and writes.
Reads are 90% of your traffic. Writes are 10%.
So why are you running them on the same machine?
Separate them. Watch your capacity double overnight."
⚠️ THE BOTTLENECK

Most Laravel apps use a single database server. Every query — SELECT, INSERT, UPDATE, DELETE — hits the same machine. When traffic grows, the database becomes the bottleneck. Read/write splitting is the first step to database scaling.

🔴 The Problem: One Database Does Everything

│ │ SINGLE DATABASE ARCHITECTURE (BAD) │ ═══════════════════════════════════════════════════════════════════ │ │ ┌─────────────────────────────────────┐ │ │ APPLICATION SERVER │ │ │ (Laravel / PHP-FPM) │ │ └─────────────────┬───────────────────┘ │ │ │ ┌─────────────────▼───────────────────┐ │ │ MASTER DATABASE │ │ │ (Handles ALL queries) │ │ │ │ │ │ SELECT queries (90% of traffic) │ │ │ INSERT/UPDATE/DELETE (10%) │ │ │ │ │ │ 💀 CPU: 100% │ │ │ 💀 I/O: Maxed out │ │ │ 💀 Memory: Contended │ │ └──────────────────────────────────────┘ │ │ RESULT: │ ┌─────────────────────────────────────────────────────────────────┐ │ │ • SELECT queries compete with INSERT for CPU and I/O │ │ │ • Heavy reports slow down user registrations │ │ │ • One machine to rule them all → One point of failure │ │ │ • When it dies, everything dies │ │ └─────────────────────────────────────────────────────────────────┘ │
THE NUMBERS

For a typical Laravel app:

✅ The Solution: Master + Replicas

│ │ READ/WRITE SPLITTING ARCHITECTURE (GOOD) │ ═══════════════════════════════════════════════════════════════════ │ │ ┌─────────────────────────────────────┐ │ │ APPLICATION SERVER │ │ │ (Laravel / PHP-FPM) │ │ └───────────────┬─────────────────────┘ │ │ │ ┌─────────────────────────┼─────────────────────────┐ │ │ │ │ │ │ (WRITES) │ (READS) │ │ ▼ ▼ ▼ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ MASTER DB │ │ REPLICA 1 │ │ REPLICA 2 │ │ │ (Primary) │ │ (Read-only) │ │ (Read-only) │ │ │ │ │ │ │ │ │ │ INSERT │◄──────│ REPLICATION │──────►│ REPLICATION │ │ │ UPDATE │ │ (async) │ │ (async) │ │ │ DELETE │ │ │ │ │ │ │ │ │ SELECT 1 │ │ SELECT 2 │ │ │ │ │ SELECT 3 │ │ SELECT 4 │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ BENEFITS: │ ┌─────────────────────────────────────────────────────────────────┐ │ │ • Reads distributed across multiple replicas │ │ │ • Writes only hit the master (10% of traffic) │ │ │ • Replicas can be added/removed without downtime │ │ │ • Master can be dedicated to writes (faster transactions) │ │ │ • If one replica dies, others still serve reads │ │ │ • Horizontal scaling: add replicas as traffic grows │ │ └─────────────────────────────────────────────────────────────────┘ │
THE GOLDEN RULE OF DATABASE SCALING

Writes go to master. Reads go to replicas. Laravel handles the routing automatically.

📊 Capacity Comparison

Architecture Max Reads/sec Max Writes/sec Cost Failure Tolerance
Single Database 1,000 500 1 server None (single point of failure)
1 Master + 1 Replica 2,000 (2x) 500 2 servers Replica can fail, master still works
1 Master + 3 Replicas 4,000 (4x) 500 4 servers Multiple replicas can fail
1 Master + 10 Replicas 11,000 (11x) 500 11 servers High fault tolerance
THE BOTTOM LINE

Read capacity scales linearly with number of replicas. With 10 replicas, you can handle 10x the read traffic. Write capacity stays the same (but that's fine because writes are only 10% of traffic).

⚙️ Laravel Configuration (Zero Code Change)

THE BEST PART: NO APPLICATION CODE CHANGES

Laravel handles read/write splitting automatically. You just configure it in config/database.php.

Step 1: Configure your connections

// config/database.php
'connections' => [
    'mysql' => [
        // Write connection (Master)
        'write' => [
            'host' => env('DB_HOST_WRITE', 'master.db.example.com'),
        ],
        
        // Read connections (Replicas) — Laravel randomly picks one
        'read' => [
            'host' => [
                env('DB_HOST_READ_1', 'replica1.db.example.com'),
                env('DB_HOST_READ_2', 'replica2.db.example.com'),
                env('DB_HOST_READ_3', 'replica3.db.example.com'),
            ],
        ],
        
        // Common settings (shared by both)
        'port' => env('DB_PORT', '3306'),
        'database' => env('DB_DATABASE', 'forge'),
        'username' => env('DB_USERNAME', 'forge'),
        'password' => env('DB_PASSWORD', ''),
        'driver' => 'mysql',
    ],
],

Step 2: Set environment variables

# .env
DB_HOST_WRITE=master.db.example.com
DB_HOST_READ_1=replica1.db.example.com
DB_HOST_READ_2=replica2.db.example.com
DB_HOST_READ_3=replica3.db.example.com
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=myuser
DB_PASSWORD=mypassword

Step 3: That's it. Your code stays the same.

// Your existing code — NO CHANGES NEEDED
$users = DB::table('users')->get();        // ✅ Automatically uses a replica
$user = User::find(123);                   // ✅ Automatically uses a replica
$user->name = 'New Name';                  // ✅ Automatically uses master for writes
$user->save();                             // ✅ Master

// Eloquent works the same way
User::where('active', true)->get();        // ✅ Replica
User::create([...]);                       // ✅ Master

// Transactions always go to master
DB::transaction(function () {
    // All queries inside transaction → Master
});

⚠️ The Trade-Off: Replication Lag

ASYNC REPLICATION = EVENTUAL CONSISTENCY

MySQL replication is asynchronous. When you write to the master, there's a small delay (milliseconds to seconds) before the replica receives the change.

│ │ REPLICATION LAG SCENARIO │ ═══════════════════════════════════════════════════════════════════ │ │ Time 0ms: User creates post (INSERT into master) │ │ │ ▼ │ Time 1ms: Master has post. Replica does NOT have it yet. │ │ │ ▼ │ Time 2ms: User refreshes page (SELECT from replica) │ │ │ ▼ │ Time 2ms: Replica → No post found! (User thinks their post disappeared!) │ │ │ ▼ │ Time 50ms: Replication catches up. Post appears. │ │ ═══════════════════════════════════════════════════════════════════ │ │ THIS IS CALLED "READ-AFTER-WRITE INCONSISTENCY" │
FIX 1: Use session stickiness (not recommended)

Force the user to always read from master for a few seconds after writing. Laravel doesn't do this automatically.

FIX 2: Critical reads go to master

For operations where consistency is critical, explicitly use the master connection:

// Force a read to use master (not replica)
$post = DB::connection('mysql_write')
    ->table('posts')
    ->where('user_id', $userId)
    ->get();

// Or in Eloquent
$post = DB::connection('mysql_write')
    ->table('posts')
    ->find($postId);
FIX 3: Accept eventual consistency (most apps)

For most Laravel apps (forums, social media, blogs), a 50ms delay is acceptable. Users won't notice. Only use master reads for critical operations like payments.

🛠️ How to Set Up MySQL Replication (Summary)

On the Master (my.cnf):

[mysqld]
server-id = 1
log_bin = /var/log/mysql/mysql-bin.log
binlog_do_db = myapp_database
binlog_format = ROW

On the Replica (my.cnf):

[mysqld]
server-id = 2
relay-log = /var/log/mysql/mysql-relay-bin.log
log_bin = /var/log/mysql/mysql-bin.log
read_only = 1  # Important! Prevents writes to replica

Create replication user on Master:

CREATE USER 'replication'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'replication'@'%';

Configure replica to connect to master:

CHANGE MASTER TO
    MASTER_HOST='master.db.example.com',
    MASTER_USER='replication',
    MASTER_PASSWORD='password',
    MASTER_LOG_FILE='mysql-bin.000001',
    MASTER_LOG_POS=0;
START SLAVE;
PRO TIP: Use Managed Services

AWS RDS, DigitalOcean Managed Databases, and Laravel Forge have one-click read replica creation. No manual configuration needed.

📈 Monitoring Replication Health

Check replica status:

SHOW SLAVE STATUS\G

-- Look for:
-- Slave_IO_Running: Yes
-- Slave_SQL_Running: Yes
-- Seconds_Behind_Master: 0 (or small number)

Laravel health check for replicas:

// routes/health.php
Route::get('/health/database', function () {
    try {
        $replicas = config('database.connections.mysql.read.host', []);
        $results = [];
        
        foreach ($replicas as $replica) {
            $start = microtime(true);
            DB::connection('mysql')->select('SELECT 1');
            $time = (microtime(true) - $start) * 1000;
            $results[$replica] = ['status' => 'ok', 'latency_ms' => $time];
        }
        
        return response()->json($results);
    } catch (\Exception $e) {
        return response()->json(['error' => $e->getMessage()], 500);
    }
});

📝 Topic 10 Summary: Read/Write Splitting

Concept Single Database Master + Replicas
Read capacity 1x (bottleneck) Nx (number of replicas)
Write capacity 1x 1x (master only)
Read/write contention High (reads block writes) None (separate servers)
Fault tolerance None (single point of failure) High (replicas can fail)
Laravel code changes None None (just config)
Best for Small apps (< 1000 req/sec) Medium to large apps
📌 THE RULE: When your database CPU hits 70%, add a read replica. Laravel will automatically use it. No code changes. No downtime. Instant capacity boost.

Read/write splitting is the cheapest, easiest way to scale your database. One replica doubles your read capacity. Three replicas quadruple it. Start with one replica today.
NEXT TOPIC PREVIEW

Topic 11: Database Partitioning (Vertical & Horizontal) — When even replicas aren't enough. How to split massive tables across multiple servers. Sharding by user_id or date.