Skip to content
OpenCloseEsc
Blog

5 min read

Laravel structure for a scalable monolith

Most apps I build are monoliths, and they hold up fine. The trick is not microservices, it is keeping one Laravel app organized: thin controllers, one class per job, slow work in queues, and an eye on the database.

Everyone talks about microservices. But most apps I build are monoliths, and they work fine. A single Laravel app can serve a lot of users if you keep it organized.

The problem is not the monolith. The problem is a messy monolith.

Here is how I keep mine clean.

Start with the default structure

Laravel already gives you app/Http, app/Models, app/Providers. For a small app this is enough. Don't invent a new architecture on day one.

I made this mistake before. I built folders for a big system, then the project stayed small. All that structure was just extra work.

Start simple. Add structure when you feel pain.

Keep controllers thin

A controller has one job: take the request, call something, return a response. Nothing more.

This is what I don't want to see:

public function store(Request $request)
{
    $request->validate([...]);
 
    $order = Order::create([...]);
    foreach ($request->items as $item) {
        // stock check
        // price calculation
        // discount rules
    }
    Mail::to($order->user)->send(new OrderCreated($order));
    // sms, pdf, log...
}

Six months later nobody wants to touch this method.

This is better:

public function store(StoreOrderRequest $request, CreateOrder $createOrder)
{
    $order = $createOrder->handle($request->user(), $request->validated());
 
    return new OrderResource($order);
}

Now the controller is boring. Boring is good.

Put business logic in action classes

I like one class for one job. I call them actions:

app/Actions/Order/CreateOrder.php
app/Actions/Order/CancelOrder.php
app/Actions/Payment/ChargeWallet.php

Each class has one public method:

class CreateOrder
{
    public function __construct(
        private ChargeWallet $chargeWallet,
    ) {}
 
    public function handle(User $user, array $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $user->orders()->create([...]);
 
            $order->items()->createMany($data['items']);
            $this->chargeWallet->handle($user, $order->total);
 
            SendOrderConfirmation::dispatch($order);
 
            return $order;
        });
    }
}

Some people call these services instead. The name doesn't matter. What matters is that you pick one style and use it everywhere. Mixed styles are worse than a style you don't like.

Validate in Form Requests

Don't validate inside the controller. Make a class:

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1'],
            'address_id' => ['required', 'exists:addresses,id'],
        ];
    }
 
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }
}

Now the rules live in one place, and you can test them alone.

Group by feature when the app grows

At some point app/Actions will have 80 files and you can't find anything. That's the signal to group by feature, not by file type:

app/Domain/Order/
    Actions/
    Models/
    Events/
    DTOs/
app/Domain/Delivery/
app/Domain/Billing/

Now when you work on delivery, everything about delivery is in one folder. And if one day you really need to split the app, the lines are already drawn.

Again: do this when the app is big. Not before.

About the repository pattern

Honest opinion: most Laravel apps don't need it.

Eloquent is already a data layer. Adding a repository on top usually means one more interface, one more class, and the same query. You get files, not value.

I use it only when I really have two data sources, or when a query is complex and used in many places. Otherwise Order::where(...) is fine.

Instead of repositories, I use query scopes:

public function scopeActive($query)
{
    return $query->where('status', 'active');
}
 
public function scopeForTenant($query, int $tenantId)
{
    return $query->where('tenant_id', $tenantId);
}

Short, readable, no extra layer.

Move slow work to queues

Email, PDF, SMS, image resize, third party API calls — none of these should happen while the user waits.

SendOrderConfirmation::dispatch($order);

Rule I follow: if it can fail or it can be slow, it goes to a queue. The user should get a response fast, even when the SMS provider is down.

Use Horizon if you're on Redis. It shows you failed jobs and slow queues, and that saves a lot of guessing.

Cache the boring things

Settings, categories, currency rates, delivery zones. These change once a month but you read them on every request.

public function zones(): Collection
{
    return Cache::remember('delivery_zones', 3600, fn () => DeliveryZone::all());
}

One important part: clear the cache when the data changes. A cache you forget to clear becomes a bug report.

The database is usually the real bottleneck

Not PHP. Not Laravel. The database.

Things that helped me most:

  • Fix N+1 queries. Use with(). One page with 50 products should not run 51 queries.
  • Add indexes on foreign keys and on columns you filter or sort by.
  • Never query inside a loop. Load once, then work in memory.
  • Use pagination, not ->get() on a big table.
  • Use chunk() or lazy() for long jobs, so you don't load a million rows into memory.
  • Select only what you need when the table is wide.

Install Debugbar or Telescope in local. Open your main pages and look at the query count. The first time I did this, one page was running 300 queries. After eager loading it was 6.

Config, not hardcode

Put values in config/, read them with config(). Call env() only inside config files — after config:cache, env() returns null everywhere else. This one breaks a lot of production deploys.

Tests you will actually write

I don't chase 100% coverage. I write feature tests for the flows that lose money if they break: register, login, create order, pay, refund.

public function test_user_can_create_order(): void
{
    $user = User::factory()->create();
 
    $this->actingAs($user)
        ->postJson('/api/orders', [...])
        ->assertCreated();
 
    $this->assertDatabaseCount('orders', 1);
}

Ten good tests beat two hundred tests for getters and setters.

Keep it boring

Thin controllers. One class, one job. Validation in its own file. Slow work in queues. Watch your queries.

None of this is clever. That's the point. A boring monolith that you can still understand after a year is worth more than a smart architecture nobody wants to open.