Laravel Project Structure for Large Teams

Scaling a Laravel application from a side project to a production system maintained by dozens of developers is one of the most common challenges in PHP development. When teams grow, a haphazard folder layout becomes a serious bottleneck — merge conflicts multiply, onboarding takes weeks, and business logic gets buried inside controllers. A well-defined Laravel project structure for large teams eliminates these pain points by enforcing clear boundaries, predictable conventions, and modular design from day one. This guide walks through a battle-tested architecture that keeps large codebases manageable, testable, and easy to extend.
Table of Contents
- Why Default Laravel Structure Falls Short at Scale
- The Domain-Driven Folder Layout
- Implementing the Repository Pattern
- Action Classes: Keeping Controllers Thin
- Data Transfer Objects for Type Safety
- Module-Based Organisation for Very Large Codebases
- Testing Strategy for Large Laravel Teams
- Team Conventions and Coding Standards
- Conclusion
Why Default Laravel Structure Falls Short at Scale
Laravel ships with an opinionated but minimal file structure. For small projects this is a strength — you can be productive in minutes. For a team of ten or more developers shipping features simultaneously, the default layout creates friction. Controllers balloon to thousands of lines, models carry business logic, and there is no enforced separation between infrastructure code and domain code.
Before you restructure, it helps to understand what you are moving away from. The official Laravel getting-started workflow is excellent for learning, but production teams need additional conventions layered on top. The goal is not to fight the framework but to extend it in ways that Laravel itself supports through service providers, repository patterns, and domain namespacing.
The Domain-Driven Folder Layout
The most effective pattern for large Laravel teams is a domain-driven directory structure. Instead of grouping files by their technical type (all controllers together, all models together), you group them by the business domain they belong to. This mirrors how product teams are organised and makes it trivial to understand which files are relevant to a given feature.
A typical domain-driven layout looks like this:
app/
├── Console/
├── Exceptions/
├── Http/
│ ├── Controllers/ # Thin controllers only
│ ├── Middleware/
│ └── Requests/ # Form Request classes
├── Domain/
│ ├── Billing/
│ │ ├── Actions/
│ │ ├── DataTransferObjects/
│ │ ├── Models/
│ │ ├── Repositories/
│ │ └── Services/
│ ├── Orders/
│ │ ├── Actions/
│ │ ├── DataTransferObjects/
│ │ ├── Models/
│ │ ├── Repositories/
│ │ └── Services/
│ └── Users/
│ ├── Actions/
│ ├── DataTransferObjects/
│ ├── Models/
│ ├── Repositories/
│ └── Services/
├── Infrastructure/
│ ├── Repositories/ # Eloquent implementations
│ ├── Services/ # Third-party integrations
│ └── Providers/
└── Support/ # Shared helpers, traits, value objects
This pattern is not invented here — it draws from Domain-Driven Design principles that have been adapted by the Laravel community. When you also apply the SOLID principles to each domain class, you get code that is genuinely open for extension without modification of existing behaviour.
Implementing the Repository Pattern
One of the first decisions large teams face is how to keep Eloquent from leaking into business logic. The repository pattern solves this by defining an interface in the domain layer and an Eloquent implementation in the infrastructure layer. Controllers and services depend on the interface, not the concrete class. This makes switching data sources trivial and keeps your unit tests fast.
Defining the Contract
<?php
namespace App\Domain\Orders\Repositories;
use App\Domain\Orders\Models\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface OrderRepositoryInterface
{
public function findById(int $id): ?Order;
public function findByUser(int $userId, int $perPage = 15): LengthAwarePaginator;
public function create(array $data): Order;
public function update(Order $order, array $data): Order;
public function delete(Order $order): bool;
}
The Eloquent Implementation
<?php
namespace App\Infrastructure\Repositories;
use App\Domain\Orders\Models\Order;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function findById(int $id): ?Order
{
return Order::find($id);
}
public function findByUser(int $userId, int $perPage = 15): LengthAwarePaginator
{
return Order::where('user_id', $userId)
->latest()
->paginate($perPage);
}
public function create(array $data): Order
{
return Order::create($data);
}
public function update(Order $order, array $data): Order
{
$order->update($data);
return $order->fresh();
}
public function delete(Order $order): bool
{
return $order->delete();
}
}
Bind the interface to the implementation inside a dedicated service provider so Laravel’s IoC container handles injection automatically:
<?php
namespace App\Infrastructure\Providers;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use App\Infrastructure\Repositories\EloquentOrderRepository;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
OrderRepositoryInterface::class,
EloquentOrderRepository::class
);
}
}
Action Classes: Keeping Controllers Thin
A controller’s job is to accept an HTTP request, delegate to the appropriate business logic, and return a response. Nothing else. When controllers handle validation, database operations, mail dispatch, and event firing simultaneously, they become the most conflicted files in the repository — and the hardest to test.
Action classes encapsulate a single use case. They are plain PHP classes with one public method and zero dependencies on the HTTP layer, making them trivially unit-testable. This approach is also a natural fit when you consider how Laravel compares to frameworks like Symfony, where commands and handlers serve a similar purpose.
<?php
namespace App\Domain\Orders\Actions;
use App\Domain\Orders\DataTransferObjects\CreateOrderDTO;
use App\Domain\Orders\Models\Order;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use App\Domain\Billing\Services\InvoiceService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use App\Domain\Orders\Events\OrderCreated;
class CreateOrderAction
{
public function __construct(
private readonly OrderRepositoryInterface $orders,
private readonly InvoiceService $invoiceService,
) {}
public function execute(CreateOrderDTO $dto): Order
{
return DB::transaction(function () use ($dto) {
$order = $this->orders->create([
'user_id' => $dto->userId,
'total' => $dto->total,
'status' => 'pending',
]);
$this->invoiceService->generate($order);
Event::dispatch(new OrderCreated($order));
return $order;
});
}
}
The controller becomes a one-liner:
<?php
namespace App\Http\Controllers\Api;
use App\Domain\Orders\Actions\CreateOrderAction;
use App\Domain\Orders\DataTransferObjects\CreateOrderDTO;
use App\Http\Requests\CreateOrderRequest;
use Illuminate\Http\JsonResponse;
class OrderController
{
public function store(
CreateOrderRequest $request,
CreateOrderAction $action
): JsonResponse {
$order = $action->execute(CreateOrderDTO::fromRequest($request));
return response()->json($order, 201);
}
}
Data Transfer Objects for Type Safety
Passing raw arrays between layers is a code smell that large teams pay for in bugs and debugging time. Data Transfer Objects (DTOs) are simple, immutable value objects that carry data between layers with full type safety. PHP 8 readonly properties make DTOs effortless:
<?php
namespace App\Domain\Orders\DataTransferObjects;
use App\Http\Requests\CreateOrderRequest;
final class CreateOrderDTO
{
public function __construct(
public readonly int $userId,
public readonly float $total,
public readonly array $items,
) {}
public static function fromRequest(CreateOrderRequest $request): self
{
return new self(
userId: $request->user()->id,
total: $request->validated('total'),
items: $request->validated('items'),
);
}
}
If your team works across multiple applications, consider whether a microservices vs monolithic architecture decision would affect how you share these DTOs. For a monorepo, they live inside the domain. For distributed services, you might extract them into a shared package.
Module-Based Organisation for Very Large Codebases
When a domain directory grows beyond a handful of sub-domains, some teams move to a module-based structure using Laravel modules packages. Each module is a self-contained mini-application with its own routes, migrations, views, controllers, and service providers. This is the closest you can get to a Laravel project structure for large teams without splitting into microservices.
The nwidart/laravel-modules package is the community standard for this approach. Each module registers itself via a dedicated service provider and can be enabled or disabled independently:
Modules/
├── Billing/
│ ├── Config/
│ ├── Database/
│ │ ├── Factories/
│ │ ├── Migrations/
│ │ └── Seeders/
│ ├── Http/
│ │ ├── Controllers/
│ │ └── Requests/
│ ├── Models/
│ ├── Providers/
│ │ ├── BillingServiceProvider.php
│ │ └── RouteServiceProvider.php
│ ├── Routes/
│ │ ├── api.php
│ │ └── web.php
│ └── Tests/
├── Orders/
└── Users/
Module Service Provider Example
<?php
namespace Modules\Billing\Providers;
use Illuminate\Support\ServiceProvider;
class BillingServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
$this->loadRoutesFrom(__DIR__ . '/../Routes/api.php');
$this->loadViewsFrom(__DIR__ . '/../Resources/views', 'billing');
}
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
}
Testing Strategy for Large Laravel Teams
A clean project structure pays dividends most visibly in the test suite. When action classes have no HTTP dependencies and repositories are interface-driven, unit tests require zero database setup and run in milliseconds. Feature tests cover the HTTP layer end to end. The two layers of testing combined give teams confidence to refactor without fear.
<?php
namespace Tests\Unit\Domain\Orders\Actions;
use App\Domain\Orders\Actions\CreateOrderAction;
use App\Domain\Orders\DataTransferObjects\CreateOrderDTO;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use App\Domain\Billing\Services\InvoiceService;
use App\Domain\Orders\Models\Order;
use Mockery;
use Tests\TestCase;
class CreateOrderActionTest extends TestCase
{
public function test_it_creates_an_order_and_generates_invoice(): void
{
$dto = new CreateOrderDTO(
userId: 1,
total: 99.99,
items: [['product_id' => 1, 'qty' => 2]],
);
$order = new Order(['id' => 1, 'total' => 99.99]);
$repo = Mockery::mock(OrderRepositoryInterface::class);
$repo->shouldReceive('create')->once()->andReturn($order);
$invoiceService = Mockery::mock(InvoiceService::class);
$invoiceService->shouldReceive('generate')->once()->with($order);
$action = new CreateOrderAction($repo, $invoiceService);
$result = $action->execute($dto);
$this->assertSame($order, $result);
}
}
For PHP-based teams maintaining large projects, the same modular thinking applies regardless of framework. If you have explored how Laravel compares with CodeIgniter and other PHP frameworks, you will notice that the repository pattern and service layers translate across them with minimal adaptation.
Team Conventions and Coding Standards
Architecture is only half the solution. The other half is agreement. Large teams benefit enormously from documented conventions enforced automatically:
- PHP CS Fixer or Laravel Pint for consistent code style on every commit
- PHPStan or Larastan at level 6 or higher to catch type errors before runtime
- Architecture tests with Pest to verify that domain classes never import from the HTTP layer
- Pre-commit hooks via Husky or CaptainHook to run linting and static analysis before any push
If your team also builds frontend applications that consume the Laravel API, consider how the API versioning strategy feeds into client development. The patterns used for API versioning in production systems apply equally well to Laravel API design, especially when multiple frontend teams depend on stable endpoints.
WireFuture’s PHP development services routinely apply these architectural patterns when building and scaling Laravel applications for enterprise clients, ensuring that codebases remain maintainable as teams grow.
Conclusion
A well-designed Laravel project structure for large teams is not a luxury — it is a prerequisite for sustainable delivery. By adopting domain-driven directories, the repository pattern, thin controllers backed by action classes, and immutable DTOs, you give every team member a clear mental model of where code lives and how it flows. Add automated enforcement through static analysis and architecture tests, and you create a codebase that grows in quality rather than technical debt as team size increases.
The investment in structure pays off within weeks: onboarding takes hours instead of days, feature branches stay small, and production incidents caused by tangled dependencies decrease significantly. Start with the domain folder layout, introduce repositories and action classes incrementally, and document the conventions your team agrees on. The result is a Laravel project structure for large teams that scales with your ambitions.
WireFuture's team spans the globe, bringing diverse perspectives and skills to the table. This global expertise means your software is designed to compete—and win—on the world stage.
No commitment required. Whether you’re a charity, business, start-up or you just have an idea – we’re happy to talk through your project.
Embrace a worry-free experience as we proactively update, secure, and optimize your software, enabling you to focus on what matters most – driving innovation and achieving your business goals.

