Proven Laravel architecture patterns for large applications covering domain separation, service layers, and package structure.
Laravel's default directory structure works beautifully for applications with 20 or 30 models. Somewhere around 80 models and 200 routes, it starts to break. Not because Laravel is flawed, but because flat directories with hundreds of files make it genuinely hard to find things and reason about boundaries.
The answer is not to abandon Laravel. It is to organize your code around your business domain instead of around framework conventions.
The single most impactful change you can make in a large Laravel application is moving from a technical directory structure to a domain-oriented one.
Instead of this:
app/
Models/
Invoice.php
InvoiceLine.php
Customer.php
Subscription.php
Plan.php
Http/Controllers/
InvoiceController.php
CustomerController.php
SubscriptionController.php
Organize around business domains:
app/
Domain/
Billing/
Models/Invoice.php
Models/InvoiceLine.php
Actions/CreateInvoiceAction.php
Events/InvoiceFinalized.php
QueryBuilders/InvoiceQueryBuilder.php
Customers/
Models/Customer.php
Actions/RegisterCustomerAction.php
Subscriptions/
Models/Subscription.php
Models/Plan.php
Actions/StartSubscriptionAction.php
Your HTTP layer stays separate and thin:
app/
App/
Http/
Controllers/Api/
InvoiceController.php
Controllers/Web/
CustomerController.php
The key insight: your domain code should not know or care whether it is called from a web controller, a console command, or a queue job. That separation forces you to write reusable, testable business logic.
Large applications that put business logic in models end up with 2,000-line model files that nobody wants to touch. Action classes solve this cleanly.
class CreateInvoiceAction
{
public function __construct(
private readonly InvoiceNumberGenerator $numberGenerator,
private readonly TaxCalculator $taxCalculator,
) {}
public function execute(Customer $customer, array $lines): Invoice
{
$invoice = new Invoice();
$invoice->customer_id = $customer->id;
$invoice->number = $this->numberGenerator->next();
$invoice->save();
foreach ($lines as $lineData) {
$line = new InvoiceLine($lineData);
$line->tax_amount = $this->taxCalculator->calculate($line);
$invoice->lines()->save($line);
}
$invoice->updateTotals();
event(new InvoiceCreated($invoice));
return $invoice;
}
}
Each action does one thing. It is easy to test. It has explicit dependencies. When requirements change, you know exactly where to look.
Scopes are fine for simple queries. For complex filtering, sorting, and reporting queries, dedicated query builder classes keep your models clean.
class InvoiceQueryBuilder extends Builder
{
public function whereDue(): self
{
return $this->where('due_date', '<', now())
->whereNull('paid_at');
}
public function forPeriod(Carbon $start, Carbon $end): self
{
return $this->whereBetween('invoice_date', [$start, $end]);
}
public function withLineItems(): self
{
return $this->with(['lines.product', 'lines.taxRate']);
}
}
Register it on your model with newEloquentBuilder() and your queries read like plain English: Invoice::query()->whereDue()->forPeriod($start, $end)->get().
When passing data between layers, DTOs prevent the "array with 15 keys" problem:
readonly class CreateInvoiceData
{
public function __construct(
public int $customerId,
public Carbon $invoiceDate,
public Carbon $dueDate,
/** @var CreateInvoiceLineData[] */
public array $lines,
public ?string $reference = null,
) {}
public static function fromRequest(CreateInvoiceRequest $request): self
{
return new self(
customerId: $request->integer('customer_id'),
invoiceDate: $request->date('invoice_date'),
dueDate: $request->date('due_date'),
lines: array_map(
fn (array $line) => CreateInvoiceLineData::from($line),
$request->input('lines'),
),
reference: $request->input('reference'),
);
}
}
DTOs are immutable, type-safe, and self-documenting. They replace guesswork with certainty about what data is flowing through your system.
Large applications accumulate configuration values. Keep them organized:
config/app.phpenv() outside of config files. The config cache breaks direct env() callsEager loading discipline. In a large codebase, N+1 queries hide everywhere. Use Model::preventLazyLoading() in non-production environments to catch them early. In production, log lazy loading violations instead of throwing exceptions.
Database indexing strategy. Review your slow query log monthly. Index columns used in WHERE, ORDER BY, and JOIN clauses. Composite indexes should match your most common query patterns. Remove unused indexes since they slow down writes.
Queue everything that can wait. Email sending, PDF generation, webhook delivery, analytics processing. If the user does not need the result in the same request, push it to a queue.
Architecture tests are underrated in large codebases. They prevent developers from accidentally importing billing code in the authentication domain, keeping your boundaries clean as the team grows.
Adopt these patterns incrementally. You do not need to restructure your entire application in one sprint. Start with the domain you are actively working on. Extract actions from controllers. Move models into domain directories. Write architecture tests to prevent regression.
Large Laravel applications succeed not because of clever abstractions but because of consistent, boring conventions that every team member can follow.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call