Debug School

rakesh kumar
rakesh kumar

Posted on

System Design Coding Examples for Laravel Developers

Each example has a problem, complete application code for the lesson, and a way to observe the result. The examples share one Laravel 12 playground. Examples 4, 7, 8, 10, 11, and 20 use local stand-ins so you can trigger failures on demand. Examples 16–19 include the infrastructure configuration you need to supply.

Set up the playground once

Use PHP 8.2+, Composer, and a local database. Laravel's default SQLite setup works for most exercises. Use MySQL or MariaDB with InnoDB for example 2, since SQLite does not implement row-level SELECT FOR UPDATE.

composer create-project laravel/laravel system-design-lab "^12.0"
cd system-design-lab
php artisan migrate
php artisan serve --port=8000
Enter fullscreen mode Exit fullscreen mode

Set CACHE_STORE=database and QUEUE_CONNECTION=database in .env. The standard Laravel 12 migrations create those tables. Restart running workers after editing job code.

Create routes/lab.php with this header. Append each chapter's route block to this file, without adding another PHP opening tag.

<?php

use App\Jobs\IngestPdf;
use App\Jobs\SendOrderConfirmation;
use App\Events\MessageSent;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
Enter fullscreen mode Exit fullscreen mode

Append the following to routes/web.php. This makes the POST examples easy to run with curl in the local playground. Keep this separate from deployed routes.

if (app()->environment('local')) {
    \Illuminate\Support\Facades\Route::prefix('lab')
        ->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class)
        ->group(base_path('routes/lab.php'));
}
Enter fullscreen mode Exit fullscreen mode

Run each SQL setup block once against the database your app uses. For SQLite, save the block to a temporary file and run sqlite3 database/database.sqlite < setup.sql. For MySQL or MariaDB, paste it into your SQL client after selecting the lab database.

All curl commands assume http://127.0.0.1:8000. Use -i to see HTTP status codes. A Content-Type: application/json header tells Laravel how to parse the body. An Accept: application/json header requests JSON errors.

1. Rate limiting — password guessing

Problem. A bot keeps trying different passwords for the same account. It can switch IP addresses and keep guessing.

Solution. Count failed attempts for the account. After five failures in this example, temporarily reject more attempts. A separate IP limit slows one source that tries many accounts.

Create a local demo user.

php artisan tinker --execute='\App\Models\User::factory()->create(["email" => "learner@example.test", "password" => \Illuminate\Support\Facades\Hash::make("demo-password")]);'
Enter fullscreen mode Exit fullscreen mode

routes/lab.php

Route::post('/login', function (Request $request) {
    $data = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required', 'string'],
    ]);

    $email = strtolower(trim($data['email']));
    $key = 'lab-login:' . hash('sha256', $email);

    // Serialize check and increment for the same account.
    return Cache::lock($key . ':lock', 15)->block(3, function () use ($key, $email, $data, $request) {
        if (RateLimiter::tooManyAttempts($key, 5)) {
            return response()->json(
                ['message' => 'Try again later'],
                429,
                ['Retry-After' => RateLimiter::availableIn($key)]
            );
        }

        if (!Auth::attempt(['email' => $email, 'password' => $data['password']])) {
            RateLimiter::hit($key, 60);
            return response()->json(['message' => 'Invalid credentials'], 401);
        }

        RateLimiter::clear($key);
        $request->session()->regenerate();
        return response()->json(['user_id' => Auth::id()]);
    });
})->middleware('throttle:30,1');
Enter fullscreen mode Exit fullscreen mode
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/lab/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"learner@example.test","password":"wrong"}'
done
Enter fullscreen mode Exit fullscreen mode

Expected: five 401 responses followed by 429. The account counter also applies when the caller changes IP. The route has a separate guest IP limit. Use shared cache storage across instances. OWASP guidance

2. Pessimistic locking — one seat, two buyers

Problem. Asha and Ravi both click Book for train seat 42. Both requests could see the seat as available and try to reserve it.

Solution. Think of two ticket clerks sharing one seat register. Asha’s request locks the seat row while it checks and updates it. Ravi’s request waits, then sees that the seat is reserved and gets a conflict. The database releases the lock when Asha’s transaction commits or rolls back. If the transaction takes 20 ms after acquiring the lock, the lock lasts about 20 ms. A five-minute payment hold would be stored separately as a reservation expiry.

On the InnoDB connection used by Laravel, run:

CREATE TABLE lab_seats (
    id INT PRIMARY KEY,
    reserved_by INT NULL
) ENGINE=InnoDB;
INSERT INTO lab_seats (id) VALUES (42);
Enter fullscreen mode Exit fullscreen mode
Route::post('/seats/{id}/reserve', function (Request $request, int $id) {
    $data = $request->validate(['buyer_id' => ['required', 'integer', 'min:1']]);

    return DB::transaction(function () use ($id, $data) {
        $seat = DB::table('lab_seats')->where('id', $id)->lockForUpdate()->first();
        abort_if($seat === null, 404);
        abort_if($seat->reserved_by !== null, 409, 'Seat already reserved');

        DB::table('lab_seats')->where('id', $id)->update([
            'reserved_by' => $data['buyer_id'],
        ]);

        return response()->json(['seat_id' => $id, 'buyer_id' => $data['buyer_id']], 201);
    });
});
Enter fullscreen mode Exit fullscreen mode
for buyer in 1 2; do
  curl -s -w ' HTTP %{http_code}\n' http://127.0.0.1:8000/lab/seats/42/reserve \
    -H 'Accept: application/json' -H 'Content-Type: application/json' \
    -d "{\"buyer_id\":$buyer}" &
done
wait
Enter fullscreen mode Exit fullscreen mode

One request gets 201, the other 409. To see actual lock waiting, run a second server on port 8001, hold SELECT * FROM lab_seats WHERE id = 42 FOR UPDATE inside an open database transaction, call either server, then commit the SQL transaction. Reset between runs with UPDATE lab_seats SET reserved_by = NULL WHERE id = 42;. In an authenticated app, obtain the buyer ID from the logged-in user. Commit the reservation before calling a payment provider.

3. Optimistic locking — protect edits made from stale forms

Problem. Two admins open the same listing. Asha saves first, then Ravi submits his older copy and could overwrite her changes.

Solution. Give the listing a version number. Save only if the submitted version still matches the database. After Asha’s save increases the version, Ravi gets a conflict and can reload the listing.

CREATE TABLE lab_listings (id INT PRIMARY KEY, title VARCHAR(120), version INT NOT NULL);
INSERT INTO lab_listings VALUES (1, 'Honda Activa', 1);
Enter fullscreen mode Exit fullscreen mode
Route::get('/listings/{id}', function (int $id) {
    return response()->json(DB::table('lab_listings')->where('id', $id)->firstOrFail());
});

Route::patch('/listings/{id}', function (Request $request, int $id) {
    $data = $request->validate([
        'title' => ['required', 'string', 'max:120'],
        'version' => ['required', 'integer', 'min:1'],
    ]);

    $updated = DB::table('lab_listings')->where('id', $id)
        ->where('version', $data['version'])
        ->update(['title' => $data['title'], 'version' => $data['version'] + 1]);

    abort_if($updated === 0, 409, 'Reload the listing and review your changes.');
    return response()->json(['version' => $data['version'] + 1]);
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/listings/1
for title in 'Activa 2025' 'Activa Deluxe'; do
  curl -i -X PATCH http://127.0.0.1:8000/lab/listings/1 \
    -H 'Content-Type: application/json' -H 'Accept: application/json' \
    -d "{\"title\":\"$title\",\"version\":1}"
done
Enter fullscreen mode Exit fullscreen mode

The first update saves version 2. The second sends version 1 and receives 409. The version comparison and write happen in one SQL statement.

4. Idempotency — retry a payment request safely

Problem. The payment request times out. The provider may already have charged the customer, so retrying could charge them again.

Solution. Use the same idempotency key and amount for each retry of that payment attempt. The provider can recognize the repeat and return the original charge ID.

This route simulates a payment provider. One row represents one charge. Use the same key when retrying an uncertain attempt.

CREATE TABLE lab_charges (
    request_key VARCHAR(80) PRIMARY KEY,
    amount_paise INT NOT NULL,
    charge_id VARCHAR(36) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode
Route::post('/charges', function (Request $request) {
    $data = $request->validate(['amount_paise' => ['required', 'integer', 'min:1']]);
    $key = $request->header('Idempotency-Key');
    abort_unless(is_string($key) && Str::isUuid($key), 400, 'Send a UUID idempotency key.');

    try {
        DB::table('lab_charges')->insert([
            'request_key' => $key,
            'amount_paise' => $data['amount_paise'],
            'charge_id' => (string) Str::uuid(),
        ]);
    } catch (UniqueConstraintViolationException) {
        // The existing charge is the result of the earlier request.
    }

    $charge = DB::table('lab_charges')->where('request_key', $key)->firstOrFail();
    abort_if((int) $charge->amount_paise !== (int) $data['amount_paise'], 409, 'Key already used for a different amount.');

    return response()->json(['charge_id' => $charge->charge_id, 'amount_paise' => $charge->amount_paise]);
});
Enter fullscreen mode Exit fullscreen mode
for attempt in 1 2; do
  curl -s http://127.0.0.1:8000/lab/charges \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: 955e3882-7bf2-4e8a-919f-37eb749efc5d' \
    -d '{"amount_paise":70000}'
  echo
done
Enter fullscreen mode Exit fullscreen mode

Both calls return the same charge ID. The primary key enforces uniqueness during concurrent inserts. A real payment integration persists the attempt key on your side, forwards it to the provider, and scopes lookup and replay by authenticated customer or merchant.

5. Unique constraint — duplicate email signup

Problem. Two people sign up with the same email at nearly the same time. Both requests could pass an availability check before either saves.

Solution. Put a unique constraint on the email column. The database accepts one insert and rejects the duplicate, and the app returns a clear message.

Laravel's standard users migration includes a unique index on email. This example uses it directly.

Route::post('/register', function (Request $request) {
    $data = $request->validate([
        'name' => ['required', 'string', 'max:100'],
        'email' => ['required', 'email', 'max:254'],
        'password' => ['required', 'string', 'min:12'],
    ]);

    try {
        $user = \App\Models\User::create([
            'name' => $data['name'],
            'email' => strtolower(trim($data['email'])),
            'password' => Hash::make($data['password']),
        ]);
    } catch (UniqueConstraintViolationException) {
        return response()->json(['message' => 'Email already registered'], 409);
    }

    return response()->json(['user_id' => $user->id], 201);
});
Enter fullscreen mode Exit fullscreen mode
for attempt in 1 2; do
  curl -i http://127.0.0.1:8000/lab/register \
    -H 'Content-Type: application/json' \
    -d '{"name":"Asha","email":"asha@example.test","password":"demo-pass-12345"}'
done
Enter fullscreen mode Exit fullscreen mode

Expected: 201 then 409. If two requests pass validation together, the database still lets only one insert that normalized email.

6. Transaction — order and line items save together

Problem. Checkout saves an order, but adding its second item fails. The database could be left with an incomplete order.

Solution. Put the order and its items in one transaction. Save them together when every write succeeds. If any write fails, roll them all back.

CREATE TABLE lab_orders (id VARCHAR(36) PRIMARY KEY, total_paise INT NOT NULL);
CREATE TABLE lab_order_items (
    order_id VARCHAR(36) NOT NULL REFERENCES lab_orders(id),
    sku VARCHAR(50) NOT NULL,
    PRIMARY KEY (order_id, sku)
);
Enter fullscreen mode Exit fullscreen mode
Route::post('/orders', function (Request $request) {
    $data = $request->validate([
        'skus' => ['required', 'array', 'min:1'],
        'skus.*' => ['required', 'string', 'max:50'],
    ]);

    $id = (string) Str::uuid();

    try {
        DB::transaction(function () use ($id, $data) {
            DB::table('lab_orders')->insert([
                'id' => $id,
                'total_paise' => 70000 * count($data['skus']),
            ]);
            foreach ($data['skus'] as $sku) {
                DB::table('lab_order_items')->insert(['order_id' => $id, 'sku' => $sku]);
            }
        });
    } catch (UniqueConstraintViolationException) {
        return response()->json(['message' => 'Duplicate SKU. The order was rolled back.'], 409);
    }

    return response()->json(['order_id' => $id], 201);
});
Enter fullscreen mode Exit fullscreen mode
curl -i http://127.0.0.1:8000/lab/orders -H 'Content-Type: application/json' -d '{"skus":["BIKE","HELMET"]}'
curl -i http://127.0.0.1:8000/lab/orders -H 'Content-Type: application/json' -d '{"skus":["BIKE","BIKE"]}'
Enter fullscreen mode Exit fullscreen mode

The second request fails on its second item. Query both tables to see that only the first order and its two items remain.

7. Transactional outbox — order and event survive a crash

Problem. The app saves an order and crashes before telling the warehouse about it. The warehouse never learns that it needs to pack the order.

Solution. Save the order and an outbox event in the same transaction. A worker can deliver the event later. The receiver uses the event ID to avoid processing it twice.

Reuse lab_orders from #6.

CREATE TABLE lab_outbox (
    id VARCHAR(36) PRIMARY KEY,
    order_id VARCHAR(36) NOT NULL,
    published_at TIMESTAMP NULL
);
CREATE TABLE lab_consumer_inbox (
    event_id VARCHAR(36) PRIMARY KEY,
    order_id VARCHAR(36) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode
Route::post('/outbox-orders', function () {
    $orderId = (string) Str::uuid();
    $eventId = (string) Str::uuid();

    DB::transaction(function () use ($orderId, $eventId) {
        DB::table('lab_orders')->insert(['id' => $orderId, 'total_paise' => 70000]);
        DB::table('lab_outbox')->insert(['id' => $eventId, 'order_id' => $orderId]);
    });

    return response()->json(['order_id' => $orderId, 'event_id' => $eventId], 201);
});
Enter fullscreen mode Exit fullscreen mode

Append to routes/console.php. The consumer inbox stands in for the receiver's durable write. In a broker-backed system the relay publishes to the broker, waits for its acknowledgment, then marks the row sent. The consumer deduplicates in its own transaction.

\Illuminate\Support\Facades\Artisan::command('lab:relay {--simulate-crash}', function () {
    foreach (\Illuminate\Support\Facades\DB::table('lab_outbox')->whereNull('published_at')->get() as $event) {
        try {
            \Illuminate\Support\Facades\DB::table('lab_consumer_inbox')->insert([
                'event_id' => $event->id,
                'order_id' => $event->order_id,
            ]);
        } catch (\Illuminate\Database\UniqueConstraintViolationException) {
            // This consumer already applied the event.
        }

        if ($this->option('simulate-crash')) {
            $this->warn('Stopped after delivery, before recording the acknowledgment.');
            return;
        }

        \Illuminate\Support\Facades\DB::table('lab_outbox')->where('id', $event->id)
            ->update(['published_at' => now()]);
        $this->info("Acknowledged {$event->id}");
    }
});
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://127.0.0.1:8000/lab/outbox-orders
php artisan lab:relay --simulate-crash
php artisan lab:relay
Enter fullscreen mode Exit fullscreen mode

The relay tries delivery again. The inbox keeps one row per event and the outbox gets a published timestamp.

8. Saga — release inventory when payment is declined

Problem. The inventory service reserves stock, but the payment service declines the charge. The stock stays held unless someone releases it.

Solution. Run checkout as a series of steps. If payment is declined after stock is reserved, run a follow-up step that releases the stock. That follow-up is called compensation.

This lab uses tables as stand-ins for independently owned services. Each step commits separately. Run requests for a given saga sequentially in the lab. A deployed orchestrator needs single-owner execution or conditional state transitions and independently idempotent service endpoints.

CREATE TABLE lab_sagas (id VARCHAR(36) PRIMARY KEY, status VARCHAR(20) NOT NULL);
CREATE TABLE lab_reservations (id VARCHAR(36) PRIMARY KEY, status VARCHAR(20) NOT NULL);
CREATE TABLE lab_saga_payments (id VARCHAR(36) PRIMARY KEY, outcome VARCHAR(20) NOT NULL);
CREATE TABLE lab_shipments (id VARCHAR(36) PRIMARY KEY);
Enter fullscreen mode Exit fullscreen mode
Route::post('/sagas/{id}/run', function (Request $request, string $id) {
    abort_unless(Str::isUuid($id), 422);
    $data = $request->validate(['payment' => ['required', 'in:approved,declined']]);

    DB::table('lab_sagas')->insertOrIgnore(['id' => $id, 'status' => 'pending']);
    $saga = DB::table('lab_sagas')->where('id', $id)->firstOrFail();

    if ($saga->status !== 'pending') {
        return response()->json(['status' => $saga->status]);
    }

    DB::table('lab_reservations')->insertOrIgnore(['id' => $id, 'status' => 'held']);
    DB::table('lab_saga_payments')->insertOrIgnore(['id' => $id, 'outcome' => $data['payment']]);
    $outcome = DB::table('lab_saga_payments')->where('id', $id)->value('outcome');

    if ($outcome === 'declined') {
        DB::table('lab_reservations')->where('id', $id)->update(['status' => 'released']);
        $status = 'cancelled';
    } else {
        DB::table('lab_shipments')->insertOrIgnore(['id' => $id]);
        $status = 'shipped';
    }

    DB::table('lab_sagas')->where('id', $id)->update(['status' => $status]);
    return response()->json(['status' => $status]);
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/sagas/f55e3882-7bf2-4e8a-919f-37eb749efc5d/run \
  -H 'Content-Type: application/json' -d '{"payment":"declined"}'
Enter fullscreen mode Exit fullscreen mode

Expected: cancelled, inventory released, and zero shipment rows for this ID. Use a different UUID and approved to see a shipment. A real payment timeout means the outcome is unknown. Resolve it before compensating.

9. Queue — send confirmation after responding

Problem. Checkout makes the customer wait while the email service sends a confirmation.

Solution. Put the confirmation email on a queue and return the order ID. A worker sends the email in the background.

Create app/Jobs/SendOrderConfirmation.php. Set MAIL_MAILER=log for local testing.

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $email, public string $orderId)
    {
    }

    public function handle(): void
    {
        Mail::raw("Order {$this->orderId} is confirmed.", function ($message) {
            $message->to($this->email)->subject('Order confirmed');
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
Route::post('/queue-orders', function (Request $request) {
    $data = $request->validate(['email' => ['required', 'email']]);
    $id = (string) Str::uuid();

    DB::transaction(function () use ($id, $data) {
        DB::table('lab_orders')->insert(['id' => $id, 'total_paise' => 70000]);
        SendOrderConfirmation::dispatch($data['email'], $id)->afterCommit();
    });

    return response()->json(['order_id' => $id], 201);
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/queue-orders -H 'Content-Type: application/json' -d '{"email":"learner@example.test"}'
php artisan queue:work --stop-when-empty
tail -n 30 storage/logs/laravel.log
Enter fullscreen mode Exit fullscreen mode

The route responds before a worker sends the email. If email dispatch must survive an app crash between the commit and the dispatch, record the intent inside the transaction as in #7. Laravel queue documentation

10. Backoff with jitter — retry temporary errors

Problem. A catalog API is busy for a moment. If hundreds of clients retry immediately, they can overwhelm it again.

Solution. Retry temporary failures a limited number of times. Increase the delay between attempts and add a small random wait so clients retry at different times.

Http::fakeSequence() creates a repeatable failure without a second server. Replace the fake with your dependency's URL for a real integration.

Route::get('/retry', function () {
    $url = 'https://catalog.example.test/items';
    Http::fakeSequence($url)
        ->push(['error' => 'busy'], 503)
        ->push(['error' => 'busy'], 502)
        ->push(['items' => ['helmet']], 200);

    $delays = [];

    for ($attempt = 1; $attempt <= 3; $attempt++) {
        try {
            $response = Http::timeout(2)->get($url);
            if (!in_array($response->status(), [502, 503, 504], true) || $attempt === 3) {
                return response()->json(['attempts' => $attempt, 'delays_ms' => $delays, 'upstream' => $response->json()], $response->status());
            }
        } catch (ConnectionException) {
            if ($attempt === 3) {
                return response()->json(['attempts' => $attempt, 'message' => 'Upstream unavailable'], 503);
            }
        }

        $delayMs = 100 * (2 ** ($attempt - 1)) + random_int(0, 100);
        $delays[] = $delayMs;
        usleep($delayMs * 1000);
    }
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/retry
Enter fullscreen mode Exit fullscreen mode

Expected: three attempts, two delays, and the item list. A 400 returns immediately. Retry a write only when the operation can safely repeat.

11. Circuit breaker — stop calling a failing dependency

Problem. The recommendations service stays down. Every homepage request keeps calling it and waiting for a timeout.

Solution. After several failures, temporarily skip the call and show the homepage without recommendations. After a short wait, try one request to see whether the service has recovered.

This lab simulates the recommendations API. Cache stores failures across requests. A lock permits one probe at a time after the cooldown.

Route::get('/recommendations', function (Request $request) {
    $key = 'lab-recommendations-breaker';
    $fallback = fn () => response()->json(['items' => [], 'source' => 'fallback']);

    if (Cache::get($key . ':open-until', 0) > time()) {
        return $fallback();
    }

    $result = Cache::lock($key . ':probe', 5)->get(function () use ($key, $request, $fallback) {
        $openUntil = Cache::get($key . ':open-until', 0);
        if ($openUntil > time()) {
            return $fallback();
        }

        // Replace this simulation with a dependency call bounded by a timeout.
        if ($request->boolean('down')) {
            $failures = (int) Cache::get($key . ':failures', 0) + 1;
            Cache::put($key . ':failures', $failures, 120);
            if ($failures >= 3 || $openUntil > 0) {
                Cache::put($key . ':open-until', time() + 10, 120);
            }

            return $fallback();
        }

        Cache::forget($key . ':failures');
        Cache::forget($key . ':open-until');
        return response()->json(['items' => ['helmet'], 'source' => 'upstream']);
    });

    return $result === false ? $fallback() : $result;
});
Enter fullscreen mode Exit fullscreen mode
for i in 1 2 3; do curl -s 'http://127.0.0.1:8000/lab/recommendations?down=1'; echo; done
curl -s http://127.0.0.1:8000/lab/recommendations
sleep 11
curl -s http://127.0.0.1:8000/lab/recommendations
Enter fullscreen mode Exit fullscreen mode

The first healthy request gets fallback because the circuit is open. After ten seconds, the next request probes and closes it. This compact implementation serializes calls even when the circuit is closed. A production breaker typically allows normal concurrency while closed and gates probes while half-open.

12. Dead-letter handling — quarantine a repeatedly failing job

Problem. A PDF job keeps failing because its file is missing. Retrying the same job forever wastes worker time.

Solution. Give the job a retry limit. Move it to failed-job storage when it runs out of attempts. Once the missing file is available, replay the job.

Create app/Jobs/IngestPdf.php.

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

class IngestPdf implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 1;

    public function __construct(public string $path)
    {
    }

    public function handle(): void
    {
        $disk = Storage::disk('local');
        if (!$disk->exists($this->path)) {
            throw new RuntimeException('The PDF has not arrived.');
        }

        Log::info('PDF received for ingestion', ['path' => $this->path]);
    }
}
Enter fullscreen mode Exit fullscreen mode
Route::post('/ingest', function () {
    IngestPdf::dispatch('lab/demo.pdf');
    return response()->json(['status' => 'queued'], 202);
});
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://127.0.0.1:8000/lab/ingest
php artisan queue:work --sleep=1
# In another terminal, after three failures:
php artisan queue:failed
# Place an actual test PDF at storage/app/private/lab/demo.pdf first.
php artisan queue:retry FAILED_JOB_UUID
Enter fullscreen mode Exit fullscreen mode

Laravel's failed-jobs table records the exhausted job. Once the missing file is supplied, the replay succeeds and writes a log entry. This lab demonstrates failure isolation and replay. The job logs receipt of the file as its simulated ingestion step. Laravel queue documentation

13. Cache-aside — reuse reference data

Problem. Every form asks the database for the same country list, even though that list rarely changes.

Solution. Look in the cache first. If the list is missing, load it from the database and cache it. Clear the cached copy after an update so the next request loads the new list.

CREATE TABLE lab_countries (code VARCHAR(2) PRIMARY KEY, name VARCHAR(80) NOT NULL);
INSERT INTO lab_countries VALUES ('IN', 'India'), ('JP', 'Japan');
Enter fullscreen mode Exit fullscreen mode
Route::get('/countries', function () {
    $loadedFromDatabase = false;

    $countries = Cache::remember('lab:countries', 3600, function () use (&$loadedFromDatabase) {
        $loadedFromDatabase = true;
        return DB::table('lab_countries')->orderBy('name')->get();
    });

    return response()->json([
        'source' => $loadedFromDatabase ? 'database' : 'cache',
        'countries' => $countries,
    ]);
});

Route::patch('/countries/{code}', function (Request $request, string $code) {
    $data = $request->validate(['name' => ['required', 'string', 'max:80']]);
    abort_unless(DB::table('lab_countries')->where('code', $code)->exists(), 404);
    DB::table('lab_countries')->where('code', $code)->update(['name' => $data['name']]);
    Cache::forget('lab:countries');

    return response()->noContent();
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/countries
curl http://127.0.0.1:8000/lab/countries
curl -X PATCH http://127.0.0.1:8000/lab/countries/JP \
  -H 'Content-Type: application/json' -d '{"name":"Japan (updated)"}'
curl http://127.0.0.1:8000/lab/countries
Enter fullscreen mode Exit fullscreen mode

Watch the source change from database to cache, then back to database with the updated value. Cache-aside permits brief staleness under concurrent reads and writes. It fits reference lists where that trade-off is acceptable. Laravel cache documentation

14. Composite index — find recent bookings efficiently

Problem. A user wants to see their latest 20 bookings. As the bookings table grows, the database may scan and sort far more rows than it needs.

Solution. Create an index on user ID and booking date. The database can find that user’s bookings and read the newest ones in order.

Run the entire exercise in SQLite. The recursive CTE creates enough sample data to make the query plan easier to compare.

CREATE TABLE lab_bookings (
    id INTEGER PRIMARY KEY,
    user_id INT NOT NULL,
    created_at DATETIME NOT NULL
);

WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 10000
)
INSERT INTO lab_bookings (id, user_id, created_at)
SELECT n, n % 100, datetime('2025-01-01', '+' || n || ' seconds')
FROM numbers;

EXPLAIN QUERY PLAN
SELECT id, created_at FROM lab_bookings
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;

CREATE INDEX lab_bookings_user_created ON lab_bookings (user_id, created_at);

EXPLAIN QUERY PLAN
SELECT id, created_at FROM lab_bookings
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
Enter fullscreen mode Exit fullscreen mode
Route::get('/users/{id}/bookings', function (int $id) {
    return response()->json(DB::table('lab_bookings')->where('user_id', $id)
        ->orderByDesc('created_at')->limit(20)->get(['id', 'created_at']));
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/users/42/bookings
Enter fullscreen mode Exit fullscreen mode

The first plan can scan and sort. The second can seek to one user's section of the index and read in date order. For MySQL, load equivalent sample rows and compare with EXPLAIN SELECT ....

15. Cursor pagination — new posts arrive while you scroll

Problem. A user reads page one of a feed. New posts arrive, shifting the rows, so page two can show a post they already saw.

Solution. Use the last post’s date and ID as a bookmark. Start the next page after that exact post, even if newer posts were added above it.

CREATE TABLE lab_posts (id INT PRIMARY KEY, body VARCHAR(200), created_at TIMESTAMP NOT NULL);
CREATE INDEX lab_posts_date_id ON lab_posts (created_at, id);

INSERT INTO lab_posts VALUES
    (1, 'Oldest', '2025-01-01 10:00:00'),
    (2, 'Second', '2025-01-01 10:01:00'),
    (3, 'Third', '2025-01-01 10:01:00'),
    (4, 'Newest', '2025-01-01 10:02:00');
Enter fullscreen mode Exit fullscreen mode
Route::get('/posts', function () {
    return response()->json(DB::table('lab_posts')
        ->orderByDesc('created_at')->orderByDesc('id')->cursorPaginate(2));
});
Enter fullscreen mode Exit fullscreen mode
curl http://127.0.0.1:8000/lab/posts
Enter fullscreen mode Exit fullscreen mode

Page one returns IDs 4 and 3. Copy its next_page_url, insert a newer post with ID 5, then request the copied URL. Page two still returns IDs 2 and 1. The cursor anchors the page after ID 3, including ID 2 even though both share the same timestamp.

16. CDN — cache a product image near visitors

Problem. Visitors in different regions keep downloading the same product photo from one server. A distant visitor may wait longer for the file.

Solution. Serve the photo through a CDN so visitors can download a cached copy from a nearby location. Give a replacement photo a new versioned URL.

Put a real image at public/products/bike-42-v1.webp. For a deployed CDN, point its origin at your public web server and configure an edge rule to cache successful GET requests under /products/. Here is an Apache configuration for that origin.

<Directory "/var/www/system-design-lab/public/products">
    Require all granted
    <FilesMatch "-v[0-9]+\.webp$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
</Directory>
Enter fullscreen mode Exit fullscreen mode

Enable Apache's headers module and reload Apache after validating the config. Add 'cdn_url' => env('CDN_URL', env('APP_URL', 'http://127.0.0.1:8000')), to the array in config/app.php. Set CDN_URL to your CDN base URL when testing through a real edge.

Route::view('/product', 'lab-product');
Enter fullscreen mode Exit fullscreen mode

Create resources/views/lab-product.blade.php.

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Bike rental</title></head>
<body>
    <h1>Bike 42</h1>
    <img src="{{ rtrim(config('app.cdn_url'), '/') }}/products/bike-42-v1.webp" alt="Bike 42">
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Open /lab/product. Request the CDN image URL twice and inspect headers with curl -I. On a configured edge, the second request can be a cache hit. Header names depend on the CDN. Upload a replacement as bike-42-v2.webp and change the view to fetch that new URL.

17. Stateless app instances — share sessions behind a load balancer

Problem. Server A creates a user’s login session. The next request goes to server B, which cannot read sessions stored on A’s disk.

Solution. Store sessions in shared Redis and let a load balancer send requests to either server. Both servers can read the same login session.

Use the same Laravel app and APP_KEY for two instances. Start Redis and install the PHP Redis extension, or install Predis and set REDIS_CLIENT=predis.

SESSION_DRIVER=redis
SESSION_CONNECTION=default
REDIS_HOST=127.0.0.1
Enter fullscreen mode Exit fullscreen mode
Route::get('/visits', function (Request $request) {
    $visits = $request->session()->get('lab_visits', 0) + 1;
    $request->session()->put('lab_visits', $visits);

    return response()->json(['visits' => $visits, 'server_pid' => getmypid()]);
});
Enter fullscreen mode Exit fullscreen mode
# Separate terminals
php artisan serve --port=8000
php artisan serve --port=8001
Enter fullscreen mode Exit fullscreen mode

Optional Nginx load balancer, inside the http context:

upstream laravel_lab {
    server 127.0.0.1:8000;
    server 127.0.0.1:8001;
}
server {
    listen 8080;
    location / {
        proxy_set_header Host $http_host;
        proxy_pass http://laravel_lab;
    }
}
Enter fullscreen mode Exit fullscreen mode
curl -c lab-cookies.txt http://127.0.0.1:8000/lab/visits
curl -b lab-cookies.txt -c lab-cookies.txt http://127.0.0.1:8001/lab/visits
Enter fullscreen mode Exit fullscreen mode

The PID changes and the count goes from 1 to 2. With Nginx you can also repeat both calls on port 8080 to see requests distributed across the upstream servers.

18. Signed direct upload — send a PDF straight to object storage

Problem. A browser uploads a large PDF to Laravel, and Laravel forwards it to storage. The app server spends time handling the same file transfer.

Solution. Give the browser a short-lived signed URL so it can upload the PDF directly to storage. Laravel checks the stored object after the upload completes.

Install the S3 filesystem driver with composer require league/flysystem-aws-s3-v3 "^3.0". Configure a private test bucket using the standard Laravel AWS_* environment variables. Its CORS policy must permit PUT from your local app origin and the signed headers. The browser uses the authenticated session from #1.

Route::middleware('auth')->group(function () {
    Route::view('/upload-page', 'lab-upload');

    Route::post('/uploads', function (Request $request) {
        $path = 'incoming/' . $request->user()->id . '/' . Str::uuid() . '.pdf';
        $request->session()->put('lab_upload_path', $path);

        $upload = Storage::disk('s3')->temporaryUploadUrl(
            $path,
            now()->addMinutes(5),
            ['ContentType' => 'application/pdf']
        );

        return response()->json(['url' => $upload['url'], 'headers' => $upload['headers']]);
    });

    Route::post('/uploads/complete', function (Request $request) {
        $path = $request->session()->get('lab_upload_path');
        abort_unless(is_string($path) && str_starts_with($path, 'incoming/' . $request->user()->id . '/'), 404);

        $disk = Storage::disk('s3');
        abort_unless($disk->exists($path), 422, 'Object missing.');
        abort_if($disk->size($path) > 10 * 1024 * 1024, 422, 'PDF exceeds 10 MB.');

        $stream = $disk->readStream($path);
        abort_unless(is_resource($stream), 422, 'Could not read object.');
        try {
            $signature = fread($stream, 5);
        } finally {
            fclose($stream);
        }
        abort_unless($signature === '%PDF-', 422, 'Object does not start with a PDF signature.');

        $request->session()->forget('lab_upload_path');
        return response()->json(['status' => 'received', 'path' => $path]);
    });
});
Enter fullscreen mode Exit fullscreen mode

Create resources/views/lab-upload.blade.php.

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Direct PDF upload</title></head>
<body>
    <input id="pdf" type="file" accept="application/pdf">
    <button id="send">Upload</button>
    <pre id="result"></pre>

    <script>
    const result = document.querySelector('#result');

    async function checkedFetch(url, options) {
        const response = await fetch(url, options);
        if (!response.ok) throw new Error(`Request failed: ${response.status}`);
        return response;
    }

    document.querySelector('#send').onclick = async () => {
        try {
            const file = document.querySelector('#pdf').files[0];
            if (!file) throw new Error('Select a PDF first.');

            const init = await checkedFetch('/lab/uploads', {
                method: 'POST', headers: { Accept: 'application/json' }
            });
            const { url, headers } = await init.json();
            const putHeaders = new Headers();
            for (const [name, values] of Object.entries(headers)) {
                if (name.toLowerCase() === 'host') continue;
                putHeaders.set(name, Array.isArray(values) ? values.join(', ') : values);
            }
            putHeaders.set('Content-Type', 'application/pdf');

            await checkedFetch(url, { method: 'PUT', headers: putHeaders, body: file });
            const complete = await checkedFetch('/lab/uploads/complete', {
                method: 'POST', headers: { Accept: 'application/json' }
            });
            result.textContent = JSON.stringify(await complete.json(), null, 2);
        } catch (error) {
            result.textContent = error.message;
        }
    };
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Sign in through the #1 route from the browser, then open /lab/upload-page. From the browser console on the app origin, after any login cooldown has cleared:

await fetch('/lab/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  body: JSON.stringify({ email: 'learner@example.test', password: 'demo-password' })
}).then(response => response.json());
location.href = '/lab/upload-page';
Enter fullscreen mode Exit fullscreen mode

DevTools shows two small POSTs to Laravel and a PUT carrying the file to storage. The signature check is a quick rejection test. Full ingestion needs a PDF parser and must avoid accepting an object that can still be overwritten with the same signed URL. Use an immutable object version or copy the accepted upload into a protected processing location before ingestion.

19. WebSockets — receive chat messages without refreshing

Problem. A chat message is saved, but the other tab only sees it after refreshing.

Solution. Open a WebSocket connection and push new messages as they arrive. On reconnect, load saved messages and merge them by ID to recover anything missed.

Run php artisan install:broadcasting --reverb and install the frontend dependencies it prompts for. Set BROADCAST_CONNECTION=reverb and the local Reverb environment values. Run php artisan reverb:start and npm run dev. Use the Echo initialization created by the installer and verify that resources/js/app.js imports it. This demo gives each signed-in user their own private channel so you can test it with two tabs.

CREATE TABLE lab_messages (
    id VARCHAR(36) PRIMARY KEY,
    user_id INT NOT NULL,
    body VARCHAR(500) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Append to routes/channels.php.

\Illuminate\Support\Facades\Broadcast::channel('lab.chat.{id}', function ($user, int $id) {
    return (int) $user->id === $id;
});
Enter fullscreen mode Exit fullscreen mode

Create app/Events/MessageSent.php.

<?php

namespace App\Events;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class MessageSent implements ShouldBroadcastNow
{
    public function __construct(public int $userId, public array $message)
    {
    }

    public function broadcastOn(): PrivateChannel
    {
        return new PrivateChannel('lab.chat.' . $this->userId);
    }
}
Enter fullscreen mode Exit fullscreen mode
Route::middleware('auth')->group(function () {
    Route::view('/chat', 'lab-chat');

    Route::get('/messages', function (Request $request) {
        return response()->json(DB::table('lab_messages')
            ->where('user_id', $request->user()->id)->get(['id', 'body']));
    });

    Route::post('/messages', function (Request $request) {
        $data = $request->validate(['body' => ['required', 'string', 'max:500']]);
        $userId = (int) $request->user()->id;
        $message = ['id' => (string) Str::uuid(), 'body' => $data['body']];

        DB::table('lab_messages')->insert($message + ['user_id' => $userId]);
        event(new MessageSent($userId, $message));

        return response()->json($message, 201);
    });
});
Enter fullscreen mode Exit fullscreen mode

Create resources/views/lab-chat.blade.php.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>Private chat demo</title>
    @vite('resources/js/app.js')
</head>
<body data-user-id="{{ auth()->id() }}">
    <form id="chat"><input id="body" required maxlength="500"><button>Send</button></form>
    <ul id="messages"></ul>
    <p id="status" role="status"></p>
    <script type="module">
    const seen = new Set();
    const status = document.querySelector('#status');

    function render(message) {
        if (seen.has(message.id)) return;
        seen.add(message.id);
        const item = document.createElement('li');
        item.textContent = message.body;
        document.querySelector('#messages').append(item);
    }

    async function refresh() {
        const response = await fetch('/lab/messages', { headers: { Accept: 'application/json' } });
        if (!response.ok) throw new Error('Could not load messages.');
        (await response.json()).forEach(render);
    }

    window.Echo.private(`lab.chat.${document.body.dataset.userId}`)
        .listen('MessageSent', ({ message }) => render(message))
        .subscribed(() => refresh().catch(error => { status.textContent = error.message; }));

    document.querySelector('#chat').onsubmit = async event => {
        event.preventDefault();
        const input = document.querySelector('#body');
        try {
            const response = await fetch('/lab/messages', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
                body: JSON.stringify({ body: input.value })
            });
            if (!response.ok) throw new Error('Send failed.');
            render(await response.json());
            input.value = '';
        } catch (error) {
            status.textContent = error.message;
        }
    };
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Use the browser login command from #18 and open /lab/chat in two tabs. Send from one tab and watch it appear in the other. On subscription or resubscription, the client fetches persisted messages and merges by ID. This small lab reloads the whole message list. A real chat can fetch only rows newer than a stable cursor. Laravel broadcasting documentation

20. Distributed tracing — find which service caused the delay

Problem. Checkout takes three seconds. The gateway sees the total time, while inventory and payments each see only their own requests.

Solution. Pass the same trace ID from the gateway to downstream services. Record how long each step takes so you can follow one checkout and see where it slowed down.

This local example propagates a W3C-style trace header and logs spans manually so you can follow the IDs. Use two Laravel server processes. The process on 8000 plays the gateway and the one on 8001 plays inventory.

Route::get('/traced-checkout', function () {
    $traceId = bin2hex(random_bytes(16));
    $gatewaySpanId = bin2hex(random_bytes(8));
    $started = hrtime(true);

    try {
        return Http::withHeaders([
            'traceparent' => "00-{$traceId}-{$gatewaySpanId}-01",
        ])->timeout(2)->get('http://127.0.0.1:8001/lab/traced-inventory')
            ->throw()->json();
    } finally {
        logger()->info('checkout span', [
            'trace_id' => $traceId,
            'span_id' => $gatewaySpanId,
            'duration_ms' => round((hrtime(true) - $started) / 1_000_000),
        ]);
    }
});

Route::get('/traced-inventory', function (Request $request) {
    $header = $request->header('traceparent', '');
    abort_unless(preg_match('/^00-([a-f0-9]{32})-([a-f0-9]{16})-01$/D', $header, $matches) === 1, 400);
    [, $traceId, $parentSpanId] = $matches;
    $spanId = bin2hex(random_bytes(8));
    $started = hrtime(true);

    // Simulate work inside the inventory service.
    usleep(250_000);

    logger()->info('inventory span', [
        'trace_id' => $traceId,
        'span_id' => $spanId,
        'parent_span_id' => $parentSpanId,
        'duration_ms' => round((hrtime(true) - $started) / 1_000_000),
    ]);

    return response()->json(['available' => true, 'trace_id' => $traceId]);
});
Enter fullscreen mode Exit fullscreen mode
# Start the inventory server in another terminal.
php artisan serve --port=8001

# Call the gateway from a separate terminal.
curl http://127.0.0.1:8000/lab/traced-checkout
tail -n 20 storage/logs/laravel.log
Enter fullscreen mode Exit fullscreen mode

Both log lines share one trace ID. The inventory span's parent ID matches the gateway span ID. Inventory takes about 250 ms, so you can compare it with total checkout time. A deployed system typically uses OpenTelemetry instrumentation and a trace collector to handle context propagation, sampling, and span export.

Practice checks

Try this Expected result
Hit the login endpoint six times with a wrong password 401 five times, then 429
Book the same seat twice One 201 and one 409
Submit two edits with the same version First saves, second gets 409
Repeat the same payment attempt key Both responses contain the same charge ID
Insert a duplicate order item The entire order rolls back
Run the outbox relay twice after simulating a crash One consumer row for the event
Switch server instances while reusing the session cookie The session counter continues
Follow the trace IDs across gateway and inventory Both spans belong to one request

Top comments (0)