Beyond MVC: Building Modern Laravel Applications with Domain-Driven Design and Vertical Slice Architecture

When I first started building Laravel applications, MVC felt like the natural way to organize everything. Controllers handled requests, Models managed data, Views rendered responses. It was straightforward, well-documented, and honestly, it works great for most of the projects.

But after years of working on complex projects with intricate business rules, I found myself wondering how enterprise-level architecture could be applied to Laravel projects. Not because MVC was broken, but because the application had outgrown the typical CRUD scenarios where MVC shines.

This post walks through an architecture for Laravel using Domain-Driven Design (DDD) combined with Vertical Slice Architecture (VSA). While I agree that it’s a shift from the traditional Laravel way that may sound uncomfortable and complicated, I believe it offers a more intuitive way to organize complex applications and, handle complexity, and think about our business logic.

When MVC Starts Feeling Tight

Let me be clear: there’s absolutely nothing wrong with Laravel’s MVC structure. For straightforward business applications, it’s often the perfect choice. But in a scenario where our application is more than just CRUD operations, our business logic starts to get scattered across various parts of the application: Controllers, models, observers, HTTP requests, service classes, all over different namespaces.

  • How do you find where a specific business rule is implemented?
  • How do you understand the flow of a complex operation?
  • You have to trace through multiple files, often jumping between different layers of the application.

You got it! Instead of creating value, you’re becoming a detective, piecing together clues from different parts of the codebase. This is where you will start to feel the limitations of MVC.

A Different Approach: DDD + Vertical Slice Architecture

Instead of fighting against the growing complexity, we can embrace a different organizational strategy that combines two complementary approaches:

Domain-Driven Design (DDD) helps us model our business domain more intentionally:

  • Entities: Objects with identity that encapsulate business rules
  • Value Objects: Immutable concepts that describe things precisely
  • Domain Services: Business operations that don’t naturally belong to a single entity

There are a lot more on DDD, but I recommend you to not follow the book to the letter. Instead, focus on the principles and adapt them to your context. For instance, with Vertical Slice Architecture, I don’t use repositories unless there’s a clear need for them.

Vertical Slice Architecture (VSA) organizes code by business capability rather than technical layer:

  • Each feature contains everything it needs to function
  • Minimal dependencies between features
  • Clear ownership and boundaries

The result feels more like organizing a library by subject rather than by format (all the novels together, all the textbooks together). Sometimes you want all the information about a topic in one place.

VSA is about organizing code by business capabilities, not technical roles. Each feature lives in its own folder. You will notice that you can still have Controllers, Requests, Handlers, and so on, but they are organized in the same directory (except that Domain layer, application layer and infrastructure layer are separated).

Read more in Jimmy Bogard’s Vertical Slice Architecture article.

How It Actually Looks

Laravel defaults to have your code in the app/ directory. I like to change it to src/, but for the brevity of this post, I’ll use app/ as the root directory. Inside this directory, we can organize our code into three main layers:

Here’s our project structure:

app/
├── App/           # Application Layer - e.g. HTTP concerns
├── Domain/        # Business Logic Layer - core rules
└── Infra/         # Infrastructure Layer - technical details, database access, etc.

IMPORTANT: You will likely have to register Laravel stuff manually, like policies, events, models, etc. A great idea is to use a service provider for each layer, so you can register everything related to a slice’s application layer in that provider, and only register one class in the bootstrap/providers.php file.

The Application Layer: Where Features Live

Instead of organizing by technical role (Controllers, Requests, Actions, etc.), we organize by what features the application provides. Each feature lives in its own folder, containing everything it needs to function:

App/
├── ServiceOrder/
│   ├── CreateServiceOrder/
│   │   ├── CreateServiceOrderController.php
│   │   ├── StoreServiceOrderController.php
│   │   ├── StoreServiceOrderHandler.php
│   │   └── StoreServiceOrderRequest.php
│   ├── EditServiceOrder/
│   └── ListServiceOrders/
├── BusinessUnit/
│   ├── CreateBusinessUnit/
│   ├── EditBusinessUnit/
│   └── ListBusinessUnits/
└── Users/
    ├── User/
    └── Role/

When someone asks “How do we create service orders?”, everything lives in one folder. Here’s what a typical slice looks like:

// StoreServiceOrderController.php
class StoreServiceOrderController extends Controller
{
    public function __invoke(StoreServiceOrderRequest $request, StoreServiceOrderHandler $handler): RedirectResponse {
        $handler->handle(
            $request->input('business_unit_id'),
            $request->input('priority'), 
            $request->input('description')
        );
        
        return redirect()
            ->route('service-orders.index')
            ->with('success', 'Service order created successfully');
    }
}

// StoreServiceOrderHandler.php  
final readonly class StoreServiceOrderHandler
{
    public function handle(string $businessUnitId, int $priority, string $description): ServiceOrderId
    {
        $entity = ServiceOrder::register(
            businessUnitId: new BusinessUnitId($businessUnitId),
            priority: Priority::from($priority),
            description: $description,
        );
        
        ServiceOrderModel::create([
            'id' => $entity->id,
            'business_unit_id' => $entity->businessUnitId->value,
            'priority' => $entity->priority->value,
            'status' => $entity->status->value,
            'description' => $entity->description,
        ]);

        return $entity->id;
    }
}

The controller stays focused on HTTP concerns while the handler orchestrates the actual business operation. If you’re going to pass the request object, $request->validated() or explicit parameter is up to you decide.

In a real application, I’d start with a simple array if it’s just a few parameters, but as soon as the operation grows, having explicit parameters (or even a DTO) makes sense. I know it’s verbose, but it demonstrates intention clearly, as PHP arrays have no type safety for its properties.

Now, you may be thinking: I’ll have lots of repetitions with this structure! And you’re right, but that’s where the power of vertical slices comes in. Each slice is self-contained, so you can work on one feature without worrying about how it affects others. This reduces the cognitive load when navigating the codebase. Of course, we still have opportunities for shared logic; it’s just not the default.

Vertical Slice Architecture doesn’t mean no shared code. It requires constant evaluation of what should be really shared. Keeping an eye on the boundaries of each slice is crucial to avoid unnecessary coupling and refactor it when you’re sure it’s a good idea.

The Domain Layer: Where Business Rules Live

This is where we model what our application actually does, independent of web frameworks or databases:

Domain/
├── ServiceOrder/
│   ├── ServiceOrder.php              # Core business entity
│   ├── ServiceOrderId.php            # Strongly-typed identifier
│   └── ValueObjects/
│       ├── Priority.php
│       └── ServiceOrderStatus.php
├── BusinessUnit/
│   ├── BusinessUnit.php
│   ├── BusinessUnitId.php
│   └── ValueObjects/
│       └── BusinessUnitDocument.php
└── Shared/
    ├── Entities/
    ├── ValueObjects/
    └── Contracts/

Entities

Our domain entities aren’t just data containers. They understand business rules:

// ServiceOrder.php (Domain Entity)
class ServiceOrder extends Entity
{
    private(set) ServiceOrderId $id;
    
    public Priority $priority {
        // Business rules are enforced right here (or in a method if you don't like PHP property hooks, or the logic is too big)
        if ($this->status?->isCompleted()) {
            throw new DomainException('Cannot change priority of completed orders');
        }
        
        $this->priority = $value;
    };
    
    public ServiceOrderStatus $status {
        set {
            if (! $this->status?->allowsModification()) {
                throw new DomainException('Cannot change status of this order');
            }
            
            $this->status = $value;
        }
    };
    
    public string $description {
        set {
            Assert::lengthBetween($value, 1, 255);
            $this->description = $value;
        }
    }
}

Rich Value Objects

Instead of primitive obsession, we use value objects that understand their own rules:

It’s about business rules

It’s all about encapsulating the business logic within the domain entities, making them rich and expressive, independent of any technical concerns.

I definitely recommend having as least as possible dependencies code in the domain layer. I’d say utilities like Collection or brick/money are acceptable, as they provide useful functionality without tying us to a specific framework, but the core business logic should be framework-agnostic. No database access, no HTTP requests, no file handling.

I’m not going into details about Domain Driven Design here, I highly recommend you look for books. I also suggest that you read more on What is Domain Driven Design, by Belisar Hoxholli, at the Kirschbaum Development blog!

// Email.php - A value object with behavior
class Email
{
    private string $value {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email address');
        }
        
        $this->value = $value;
    }

    public function __construct(string $value) {}
}

This means throughout our codebase, we can confidently validate input, compare values, and transform them without scattered logic.

The Infrastructure Layer: Technical Implementation Details

This layer handles the technical aspects like databases, external APIs, framework integrations:

Infra/
├── ServiceOrder/
│   ├── ServiceOrderModel.php        # Eloquent Model
│   ├── ServiceOrderPolicy.php       # Laravel Policy
│   └── ServiceOrderQueryBuilder.php # Query Logic (I prefer it instead of repositories)
├── Support/
│   ├── Models/
│   │   ├── EntityModel.php          # Base Eloquent Model
│   │   └── EntityQueryBuilder.php   # Base Query Builder
│   └── Database/
│       └── DoltUnitOfWork.php       # Transaction Management (it deserves its own post)
└── User/
    └── User/
        ├── UserModel.php
        └── UserPolicy.php

Our Eloquent models became much simpler as they’re purely about data access:

// ServiceOrderModel.php (Infrastructure)
class ServiceOrderModel extends EntityModel
{
    protected $fillable = [
        'id',
        'priority',
        'status',
        'business_unit_id',
    ];
    
    // Just relationships and query scopes
    // Business logic lives in the domain layer
    
    // You can add methods to transform it to domain entities!
    public function toDomainEntity(): ServiceOrder
    {
        return new ServiceOrder(
            id: new ServiceOrderId($this->id),
            priority: Priority::from($this->priority),
            status: ServiceOrderStatus::from($this->status),
            businessUnitId: new BusinessUnitId($this->business_unit_id),
            description: $this->description
        );
    }
}

What We’ve Gained

Conversations About Code Got Easier

When someone asks “How do we handle service order cancellation?”, I can point to a single folder. When we need to modify how priorities work, all the related logic lives together. Code reviews became more focused because changes are naturally scoped to specific business capabilities.

Testing Became More Straightforward

Domain logic can be tested in isolation, without worrying about HTTP requests or database state. Each feature can be tested independently, and we can write unit tests that focus on business rules rather than technical details:

// Pure unit test - fast and focused
public function test_service_order_priority_can_be_changed_when_pending()
{
    // It's the Domain Entity, model is ServiceOrderModel!
    $order = ServiceOrder::create(
        ServiceOrderId::generate(),
        Priority::LOW,
        ServiceOrderStatus::PENDING
    );
    
    $order->changePriority(Priority::HIGH);
    
    $this->assertEquals($order->priority, Priority::HIGH);
}

Team Development Became Less Friction-Prone

Multiple developers can work on different features without stepping on each other’s toes. When someone is working on user management while another person implements service order reporting, they rarely conflict. That’s particularly useful if you still work directly on FTP (please, DON’T!).

Business Logic Became More Explicit

Instead of business rules hiding in scattered classes, they’re explicitly modeled in domain entities and services. When stakeholders ask “What happens when…?”, the code can answer that question directly.

The Trade-offs

More Upfront Thinking

This approach requires more consideration about where things belong. You need to set clear boundaries for domain, infrastructure, and application layer. You think in business capabilities first.

Laravel discovery

Laravel’s discovery is great, but it can become a bit more complex with this architecture. You need to register events, policies, and other framework features manually, which can feel like a step back from the convenience of Laravel’s default structure. However, I find that this trade-off is worth it for the clarity and maintainability.

You can use service providers to register everything related to a slice’s application layer, so you only need to register one class in the bootstrap/providers.php file. This keeps things organized and manageable.

I’m developing a versatile Discovery system that automatically registers everything in a given path based on interfaces, but it’s still a work in progress. If you’re interested, let me know, and I can share more details. I may write a post about it as well!

Some Code Duplication

Slices are intentionally isolated, which sometimes means duplicating small pieces of logic rather than creating shared abstractions. I believe that early abstraction is often worse than a bit of duplication.

Learning Curve

New team members need guidance on the architectural patterns. It’s not as immediately obvious as what everyone learns on Laravel projects.

Not Always Worth It

For simple CRUD applications or rapid prototypes, this architecture can feel like overkill. The sweet spot is applications with complex business rules and evolving requirements.

If You’re Considering This Approach

Start small. Pick one feature and implement it as a vertical slice. See how it feels. Focus on identifying your core business entities and their relationships before worrying about the technical implementation.

The key insight is that architecture should reflect how your team thinks about the business, not just how the framework wants us to organize files. When your application structure mirrors your business capabilities, both the code and the conversations about it become clearer.

Final Thoughts

This architectural approach isn’t a perfect solution, and MVC certainly isn’t obsolete. I will still use traditional MVC structure when it fits the project complexity. The decision comes down to matching the organizational pattern to the complexity and nature of what you’re building.

What I’ve learned is that Laravel is flexible enough to support multiple architectural styles within the same application. You can start with MVC for rapid development and evolve toward DDD + VSA as complexity demands it. That flexibility is one of the things I love most about working with Laravel.


What architectural patterns have worked well for your Laravel applications? I’d love to hear about different approaches and the trade-offs you’ve discovered.