Skip to content

Laravel service platform with a React front end

LCE — A Modular Laravel Backend for Order Fulfilment

How I structured a Laravel order-management backend around domain modules and queued events so it stayed maintainable as scope grew.

Architecture
Modular monolith
API surface
REST + resource layer
Async work
Queued domain events
Status
Live in production

Role

Backend architecture and API development

Timeline

Ongoing

Stack

Laravel, PHP, MySQL, React, Redis, Docker

Overview

LCE is a service-booking and fulfilment platform: customers schedule a pickup, the order moves through a defined lifecycle, and operations staff manage it from an internal dashboard. The backend is Laravel, the customer front end is React, and the two communicate over a versioned REST API.

The interesting engineering here is not any single feature. It is that the system kept absorbing new requirements — new service types, new pricing rules, new notification channels — without the codebase becoming the kind of thing where every change is risky.

The problem

An order lifecycle looks simple until you enumerate what actually has to happen when one is placed. Inventory reserved. Pricing calculated against the customer's plan. A confirmation sent. A pickup slot allocated. An invoice drafted. Analytics updated.

Written the obvious way, all of that lands in one controller method, and the order-placement path becomes coupled to every downstream concern in the system. It gets slower with each addition, and a failure anywhere in it — a mail provider timing out — rolls back an otherwise valid order.

Architecture

The codebase is organised by domain rather than by file type. Each module owns its models, its actions, its HTTP layer and its persistence, and exposes a deliberately narrow public surface.

text
app/Modules/
  Ordering/     order lifecycle, state transitions
  Scheduling/   pickup slots, capacity, routing
  Billing/      pricing rules, invoices
  Identity/     accounts, roles, permissions
  Notifications/ email and SMS dispatch

Modules never call into each other's internals. Cross-module work is triggered by domain events, and every listener is queued — so placing an order writes one row inside a transaction, dispatches an event, and returns.

php
public function handle(PlaceOrderData $data): Order
{
    $order = DB::transaction(function () use ($data) {
        $order = $this->orders->create($data);
        $this->slots->reserve($order->id, $data->pickupWindow);
        return $order;
    });

    OrderPlaced::dispatch($order->id);

    return $order;
}

Everything downstream — invoicing, confirmation email, analytics — subscribes to `OrderPlaced` from inside its own module. Adding a new consumer touches zero existing code, which is the property that made the system pleasant to extend.

Technical challenges

Preventing double-booked pickup slots

Capacity per time window is finite, and two customers booking the last slot simultaneously is a genuine race. Checking availability and then inserting is a classic read-modify-write bug — both requests read the same count and both succeed.

The fix was a pessimistic lock on the slot row inside the transaction, so the second request blocks until the first commits and then re-reads the true count. A database-level unique constraint backs it up, so even a code path that forgets to lock cannot corrupt capacity.

php
DB::transaction(function () use ($slotId) {
    $slot = PickupSlot::whereKey($slotId)->lockForUpdate()->firstOrFail();

    if ($slot->booked >= $slot->capacity) {
        throw new SlotUnavailable();
    }

    $slot->increment('booked');
});

Killing N+1 queries in the operations dashboard

The internal order list renders customer, service type, assigned slot and current status per row. Lazily loaded, a fifty-row page issued hundreds of queries and the dashboard felt sluggish under real data volumes.

Eager loading the required relations collapsed it to a predictable handful of queries. More usefully, enabling Laravel's `preventLazyLoading` in non-production environments turns any accidental N+1 into a thrown exception during development, so regressions surface before they ship rather than after.

Making retried jobs safe

Queued listeners retry on failure, which means a handler can run more than once. A naive invoice-creation listener retried after a transient database error produces two invoices for one order.

Every listener that produces a side effect is idempotent — keyed on the order ID, checking for an existing record before creating one. This is the kind of detail that never appears in a feature spec and always appears in a production incident.

Outcome

The system runs in production and has taken on substantial scope since launch without a rewrite. New service categories and pricing rules are additive changes inside their owning module rather than edits threaded through shared controllers.

The decision I would defend most strongly is queuing the cross-module listeners from the start. It meant order placement stayed fast and stayed reliable as the number of downstream effects grew — a property that would have been genuinely painful to retrofit later.

Screens

LCE booking flow
LCE service selection screen
LCE order summary
LCE scheduling interface
LCE account area
LCE order tracking view

Published by Cenedy Udoy Palma.

Other case studies