Software Teams

Laravel Best Practices for Large Applications: Architecture That Scales

Proven Laravel architecture patterns for large applications covering domain separation, service layers, and package structure.

When the Default Structure Stops Working

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.

Domain-Oriented Directory Structure

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.

Action Classes Over Fat Models

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.

Query Builder Classes

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().

Data Transfer Objects for Complex Operations

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.

Configuration and Environment Management

Large applications accumulate configuration values. Keep them organized:

  • Use dedicated config files per domain instead of stuffing everything into config/app.php
  • Never use env() outside of config files. The config cache breaks direct env() calls
  • Create typed config value objects for complex configuration sets

Performance Patterns That Matter at Scale

Eager 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.

Testing Strategy for Large Applications

  • Unit tests for domain logic (actions, calculations, value objects). These should run without touching the database.
  • Integration tests for repository and query builder classes that need a real database.
  • Feature tests for HTTP endpoints, testing the full request lifecycle.
  • Architecture tests (using Pest's arch plugin or PHPArkitect) to enforce boundaries between domains.

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.

The Pragmatic Approach

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.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call