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:
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.
"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.
// 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);
}
}
// 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.
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:
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.
// 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/:
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.
// 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.
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:
- One verb per Action.
ChargeSubscription, notSubscriptionService::charge(). The class name is the use case. - Form Requests for input shape, Policies for authorization. Not in the controller, not in the Action — at the edge.
- Events for facts, jobs for work.
SubscriptionChargedis a fact.ChargeSubscriptionJobis the work that produces it. Don't queue events; queue the listeners that respond to them. - Resources for output shape. Eloquent models do not get serialized to JSON directly in production code. There's always an
InvoiceResourcebetween the model and the wire. 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.



