You join a Laravel project that's been alive for three years. You open app/Http/Controllers and see 142 files. Some are RESTful, some are invokable, some have suffix Controller and some don't. app/Models has 87 models, none in subfolders. There's a Helpers.php file at 4,200 lines. Three different teams have shipped features into this codebase and you can tell which one shipped what by guessing the folder taste.

Nothing in there is wrong, exactly. The framework let it happen because the framework is supposed to let it happen — Laravel's defaults are deliberately permissive, optimised for the first six months of a project, not the next six years. The job of making the codebase scale belongs to the team. And that job is mostly about one decision repeated over and over: where does the next feature go?

The Default Layout Is A Starter Kit, Not An Architecture

Out of the box, Laravel 11/12 gives you a flatter skeleton than older versions — Sanctum scaffolding now lives behind php artisan install:api, bootstrap/app.php is minimal, middleware lives in a chained builder. That's a good base for a small project. It's not a structure for a large one.

What changes when an app grows isn't the framework. It's the answer to "if I'm adding a new feature this week, where do the files live?" In a small project that question has an obvious answer because there are only twelve files. In a large project, every folder has thirty cousins and the wrong choice creates a lookup tax for everyone who joins later.

The fix is to commit to a small set of boundaries and apply them ruthlessly.

Group By Domain, Not By Type

The single biggest improvement most large Laravel codebases can make is to stop organising by type (Controllers/, Services/, Models/) and start organising by domain (Billing/, Catalog/, Orders/).

A folder layout that holds up:

Text
app/
  Billing/
    Actions/
      ApplyCoupon.php
      ChargeSubscription.php
    Http/
      Controllers/
        SubscriptionController.php
      Requests/
        StoreSubscriptionRequest.php
      Resources/
        SubscriptionResource.php
    Jobs/
      ChargeSubscriptionJob.php
    Models/
      Subscription.php
      Invoice.php
    Policies/
      SubscriptionPolicy.php
    Events/
      SubscriptionCharged.php
  Catalog/
    Actions/
    Http/
    Models/
  Orders/
    Actions/
    Http/
    Models/
  Shared/
    Casts/
    Enums/
    Support/

The win is that everything that changes together lives together. Open app/Billing/ and you can read the entire billing subsystem without bouncing between four top-level folders. The Sanctum-shaped default is fine for the first 30 features. By feature 100 you want this.

You can do this without a single package. Move files, update namespaces, run composer dump-autoload. Laravel doesn't care where your classes live as long as composer.json's psr-4 map points at them.

JSON
"autoload": {
    "psr-4": {
        "App\\": "app/"
    }
}

That single rule is enough — anything under app/ autoloads on its real namespace.

Controllers Are Coordinators, Actions Do The Work

A pattern that holds up across team sizes: single-purpose invokable controllers + Action classes for the use case.

PHP
// app/Billing/Http/Controllers/ChargeSubscriptionController.php
namespace App\Billing\Http\Controllers;

use App\Billing\Actions\ChargeSubscription;
use App\Billing\Http\Requests\ChargeSubscriptionRequest;
use App\Billing\Http\Resources\InvoiceResource;
use App\Billing\Models\Subscription;

final class ChargeSubscriptionController
{
    public function __invoke(
        ChargeSubscriptionRequest $request,
        Subscription $subscription,
        ChargeSubscription $action,
    ): InvoiceResource {
        $invoice = $action->execute($subscription, $request->validated());

        return InvoiceResource::make($invoice);
    }
}
PHP
// app/Billing/Actions/ChargeSubscription.php
namespace App\Billing\Actions;

use App\Billing\Events\SubscriptionCharged;
use App\Billing\Models\Invoice;
use App\Billing\Models\Subscription;
use App\Billing\Services\StripeGateway;
use Illuminate\Support\Facades\DB;

final class ChargeSubscription
{
    public function __construct(private readonly StripeGateway $gateway) {}

    public function execute(Subscription $subscription, array $payload): Invoice
    {
        return DB::transaction(function () use ($subscription, $payload) {
            $charge = $this->gateway->charge($subscription, $payload['amount_cents']);

            $invoice = Invoice::create([
                'subscription_id' => $subscription->id,
                'amount_cents'    => $charge->amount,
                'stripe_charge'   => $charge->id,
            ]);

            SubscriptionCharged::dispatch($invoice);

            return $invoice;
        });
    }
}

Three responsibilities, three files. The Form Request validates and authorizes at the edge. The controller is one method long and does nothing but wire dependencies. The Action owns the transaction, the writes, and the event fan-out. Six months from now when you need to charge a subscription from an Artisan command, a webhook handler, or a scheduled task, you call ChargeSubscription::execute() and skip HTTP entirely. No copy-paste.

The mistake teams make is using Actions and Services and Repositories and Managers without deciding what each is for. Pick one. In most teams I've worked with, Actions alone are enough — a single class with one verb method per use case. A "Service" with twelve unrelated methods is just a fat controller in a different folder.

Diagram of a domain-grouped Laravel codebase: top-level app/ split into Billing/, Catalog/, Orders/, Shared/ — each with its own Actions/, Http/, Models/, Policies/, Jobs/, Events/. Arrows show a request entering through a Form Request, hitting a controller, calling an Action, writing inside a transaction, and dispatching an event that queued listeners pick up.
Group by domain — everything that changes together lives together

Models Stay Models, Not Service Classes

Eloquent models earn their keep as data objects with relationships, casts, and a few query scopes. They lose their keep the moment they grow ->sendWelcomeEmail(), ->refundAndNotify(), ->reconcileWith($otherModel) methods. That's Action work, not model work.

A reasonable rule: a method on a model is fine if it's about the row's own state — $user->markEmailVerified(), $invoice->isPastDue(). It's not fine if it triggers side effects in other domains. Move that into an Action.

Query scopes are the other case where models stay productive:

PHP
public function scopeActive(Builder $query): void
{
    $query->where('status', 'active')->where('cancelled_at', null);
}

Scopes are pure read-side helpers. They don't fan out, they don't write, they don't call services. Keep them on the model and they stay testable.

Repositories Only When They Pay Rent

A repository is a class that wraps Eloquent calls in a domain interface. The argument for them is "we might swap the database one day." We don't. The argument worth listening to is "this domain has a complex query surface and the model is bloated."

If you have a model with twenty static query helpers — User::activeWithSubscription(), User::dueForRenewal(), User::stalled() — moving those into a UserQuery or UserRepository and letting the model stay focused on row-level concerns is fine. Don't reach for the pattern reflexively. Eloquent itself is a repository; wrapping it in a thinner repository is usually negative-value abstraction.

When you do introduce one, name it for what it is — UserQuery, OrderListing, InvoiceFinder. Anything ending in Repository will accumulate methods that belong elsewhere.

The Modular Monolith Is The Goal, Not Microservices

The next instinct after "group by domain" is usually "split into microservices." Resist it as long as you can.

A well-organised modular monolith — app/Billing/, app/Catalog/, app/Orders/ with explicit interfaces between them — gives you most of the benefits of separate services (clear ownership, testable boundaries, replaceable internals) without any of the operational pain (network failures, distributed transactions, deploy coordination). A monorepo with one deploy pipeline is dramatically easier to operate than five repos with five pipelines.

Spatie's laravel-data and laravel-event-sourcing packages are useful when domains genuinely need typed contracts between them. But the cheapest contract is an interface in app/Shared/Contracts/ plus an Action in each domain that implements it.

PHP
// app/Shared/Contracts/ChargesCustomers.php
interface ChargesCustomers
{
    public function charge(int $userId, int $cents, string $reason): string;
}

// app/Billing/Actions/ChargeViaStripe.php
final class ChargeViaStripe implements ChargesCustomers
{
    public function charge(int $userId, int $cents, string $reason): string
    {
        // ...
    }
}

Catalog doesn't depend on Billing's implementation; it depends on the interface. The container wires the right one. The day you split out Billing into a service, the call sites don't change.

Tests Mirror The Structure

Mirror your app/ tree under tests/:

Text
tests/
  Feature/
    Billing/
      ChargeSubscriptionTest.php
    Catalog/
    Orders/
  Unit/
    Billing/
      Actions/
        ChargeSubscriptionTest.php

Unit tests for Actions are fast — instantiate the class, mock the gateway, call the verb. Feature tests cover HTTP shape and authorization. Skip end-to-end framework tests that prove Route::post() works; that's Laravel's job, not yours.

PHP
// tests/Unit/Billing/Actions/ChargeSubscriptionTest.php
it('persists an invoice and dispatches the event', function () {
    Event::fake([SubscriptionCharged::class]);
    $subscription = Subscription::factory()->active()->create();

    $action = new ChargeSubscription(new FakeStripeGateway());
    $invoice = $action->execute($subscription, ['amount_cents' => 1999]);

    expect($invoice->amount_cents)->toBe(1999);
    Event::assertDispatched(SubscriptionCharged::class);
});

A test like this stays green across upgrades because it doesn't touch the HTTP kernel. You can refactor the controller, swap the gateway, change the route, and this test still passes — because it tests the rule, not the wiring.

Five-card convention checklist arranged around a central Laravel app icon. Card 01 One Verb Per Action — ChargeSubscription::execute, RefundInvoice::execute, CancelSubscription::execute. Card 02 Validate And Authorize At The Edge — Form Request shapes the payload, Policy answers row-level access, controller stays one method long. Card 03 Events For Facts Jobs For Work — SubscriptionCharged is the fact, ChargeSubscriptionJob is the work. Card 04 Resources For Output Shape — Model to Resource to JSON, hidden columns stay hidden. Card 05 Shared/ Is A Real Domain — app/Shared/Contracts/ChargesCustomers.php as a domain other domains depend on.
Five conventions every developer follows because the team wrote them down

A Few Conventions Worth Picking And Sticking With

The codebase you'll regret isn't the one with three folders too many. It's the one where every developer picked a different convention for the same problem.

A short list worth committing to in writing:

  1. One verb per Action. ChargeSubscription, not SubscriptionService::charge(). The class name is the use case.
  2. Form Requests for input shape, Policies for authorization. Not in the controller, not in the Action — at the edge.
  3. Events for facts, jobs for work. SubscriptionCharged is a fact. ChargeSubscriptionJob is the work that produces it. Don't queue events; queue the listeners that respond to them.
  4. Resources for output shape. Eloquent models do not get serialized to JSON directly in production code. There's always an InvoiceResource between the model and the wire.
  5. Shared/ is a real domain. Cross-cutting code (enums, casts, value objects, base middleware) lives there. It's not a junk drawer; it's a domain that other domains depend on.

These aren't the only valid choices. They are a set of choices. The point is that the team made them once and applied them consistently — which is the actual difference between a maintainable codebase and a folder full of files.

A One-Sentence Mental Model

A large Laravel codebase scales when you stop organising by file type and start organising by domain — app/Billing/, app/Orders/, app/Shared/ — with thin invokable controllers calling single-verb Actions, models that stay focused on row-level state, interfaces between domains in app/Shared/Contracts/, and a small set of conventions every developer follows because the team wrote them down.