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.
app/Modules/
Ordering/ order lifecycle, state transitions
Scheduling/ pickup slots, capacity, routing
Billing/ pricing rules, invoices
Identity/ accounts, roles, permissions
Notifications/ email and SMS dispatchModules 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.
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.
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






Published by Cenedy Udoy Palma.