Skip to content
Backend Architecture9 min read

The Laravel Monolith That Doesn't Rot

Most Laravel projects do not fail because they are monoliths. They fail because everything can reach everything, and after two years nobody can predict what a change will break.

CP

Cenedy Udoy Palma

Backend Developer & AI Engineer

I maintain a Laravel backend for a logistics and order-management platform that has been in production for years and has grown well past its original scope. It is still a single deployable. It is also still pleasant to work in, and those two facts are related.

The default Laravel skeleton organises code by technical type: all models together, all controllers together, all jobs together. That is fine at twenty files. At four hundred, `app/Models` is an undifferentiated pile and the only way to understand a feature is to grep for it.

Organise by domain, not by file type

The single highest-leverage change is grouping code by what it does rather than what it is:

text
app/
  Modules/
    Ordering/
      Domain/          Order.php, OrderStatus.php, OrderPlaced.php
      Application/     PlaceOrder.php, CancelOrder.php
      Http/            OrderController.php, PlaceOrderRequest.php
      Infrastructure/  EloquentOrderRepository.php
      Providers/       OrderingServiceProvider.php
    Billing/
    Delivery/
    Identity/

Now a feature is a directory. Onboarding a developer onto billing means pointing at one folder. Deleting a discontinued feature means deleting one folder and its migrations, instead of an archaeology expedition through six shared directories.

The rule that makes it work

Directory structure alone changes nothing. Without an enforced rule, someone will import `Billing\Domain\Invoice` directly into an ordering controller within a month and you are back to a pile with extra steps.

The rule is: a module may only be reached through its public surface.

  • Anything in `Domain` or `Infrastructure` is private to its module. Other modules may not name those classes.
  • Cross-module reads go through an explicit interface published by the owning module.
  • Cross-module writes go through domain events, never direct method calls.

That third point is the one that pays off most. Consider order placement needing to create an invoice. The tempting version:

php
// Ordering now depends on Billing's internals, forever.
public function place(PlaceOrderRequest $request): Order
{
    $order = $this->orders->create($request->validated());
    (new InvoiceService())->createForOrder($order);   // don't
    (new NotificationService())->sendConfirmation($order);
    return $order;
}

Every downstream concern bolted here makes ordering slower, more fragile, and harder to test. A failure in notification rolls back a perfectly valid order. Instead, ordering announces what happened and stops caring:

php
public function place(PlaceOrderRequest $request): Order
{
    $order = DB::transaction(
        fn () => $this->orders->create($request->validated())
    );

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

    return $order;
}
php
// Billing subscribes on its own terms, in its own module.
class CreateInvoiceForOrder implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        $this->invoices->createForOrder($event->orderId);
    }
}

Ordering no longer knows billing exists. Adding a third consumer — analytics, a warehouse webhook — touches zero existing code. And because the listener is queued, a billing outage delays invoices instead of rejecting orders.

Enforce boundaries in CI or they will not hold

Conventions that are not checked decay. Deptrac is a static analyser that fails the build when a module reaches somewhere it should not:

yaml
deptrac:
  paths: ["./app"]
  layers:
    - name: Ordering
      collectors:
        - { type: directory, value: app/Modules/Ordering/.* }
    - name: Billing
      collectors:
        - { type: directory, value: app/Modules/Billing/.* }
    - name: Shared
      collectors:
        - { type: directory, value: app/Shared/.* }
  ruleset:
    Ordering: [Shared]      # may not touch Billing
    Billing: [Shared]       # may not touch Ordering

One CI step, and the boundary stops being a document nobody reads. When a pull request genuinely needs a new dependency, the build fails and the team has an explicit conversation about it — which is precisely when that conversation is cheap.

Keep controllers boring

Business logic in controllers is untestable without booting HTTP and unreusable from a console command or queue job. Controllers should translate a request into a call and a result into a response — nothing else.

php
class OrderController extends Controller
{
    public function __construct(private PlaceOrder $placeOrder) {}

    public function store(PlaceOrderRequest $request): JsonResponse
    {
        $order = $this->placeOrder->handle(
            OrderData::from($request->validated())
        );

        return OrderResource::make($order)
            ->response()
            ->setStatusCode(201);
    }
}

The action class is a plain object with one public method. It can be unit tested in milliseconds, called from a command, or reused by a webhook handler. Validation stays in the form request, serialisation stays in the resource, and each piece has exactly one reason to change.

When to actually split into services

A modular monolith is not a stepping stone you are obligated to leave. It is a destination that happens to make leaving cheap if you ever need to.

Extract a module into its own service when it has a genuinely different scaling profile, a different release cadence driven by a different team, or a hard compliance boundary. Do not extract because of file count. Splitting a tangled monolith produces a distributed tangle, and now every latent coupling bug is also a network partition bug.

The good news is that a module already communicating through interfaces and events is most of the way there. The extraction becomes swapping an in-process event dispatcher for a queue and an interface implementation for an HTTP client. That is a week of work rather than a quarter.

LaravelPHPArchitectureModular Monolith

Keep reading