Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 067 / 100

Clean architecture in Laravel: best practices explained

Structure for large Laravel apps.

Expona Admin9 min read

Introduction

Most Laravel applications start simple and stay that way—until they don't. When you're shipping features at pace in a 50k-line codebase, the default MVC structure begins to crumble under the weight of business logic scattered across models, controllers, and service classes. Clean architecture in Laravel: best practices explained is about intentionally structuring your code so that growth doesn't become chaos, dependencies flow in the right direction, and your team can reason about where things live.

We're not talking about following architectural dogma for its own sake. We're talking about practical patterns that have proven themselves in production e-commerce platforms, SaaS dashboards, and trading systems where a single mistake in data flow can cascade into a customer-facing bug.

Domain folders — Group by business area instead of type

The default Laravel structure organizes code by technical type: controllers here, models there, migrations somewhere else. This works until your app/Models directory holds forty unrelated classes and you're hunting through app/Http/Controllers wondering which controller handles payments.

Domain-driven organization groups code around business concepts. Instead of spreading order-related logic across multiple technical folders, you group all order concerns—the model, the actions that operate on orders, DTOs for data transfer, and even order-specific requests—into a single domain folder.

php
app/
├── Domains/
│   ├── Orders/
│   │   ├── Actions/
│   │   │   ├── CreateOrderAction.php
│   │   │   └── CancelOrderAction.php
│   │   ├── DTOs/
│   │   │   └── CreateOrderDTO.php
│   │   ├── Requests/
│   │   │   └── CreateOrderRequest.php
│   │   ├── Models/
│   │   │   └── Order.php
│   │   └── Queries/
│   │       └── FetchOrdersQuery.php
│   ├── Payments/
│   │   ├── Actions/
│   │   │   ├── ProcessPaymentAction.php
│   │   │   └── RefundPaymentAction.php
│   │   ├── DTOs/
│   │   │   └── PaymentDTO.php
│   │   ├── Models/
│   │   │   └── Payment.php
│   │   └── Gateways/
│   │       ├── StripeGateway.php
│   │       └── PaymentGatewayInterface.php
│   └── Inventory/
│       ├── Actions/
│       │   └── ReserveStockAction.php
│       ├── Models/
│       │   └── InventoryItem.php
│       └── Repositories/
│           └── InventoryRepository.php
├── Http/
│   └── Controllers/
│       ├── OrderController.php
│       └── PaymentController.php
├── Providers/
└── Console/

This structure makes it immediately clear where order logic lives. When you need to modify how orders are created, you look in Domains/Orders. When onboarding a new team member, they can explore a single domain folder and understand that feature end-to-end without jumping between Models, Controllers, and scattered service classes.

In a real platform we've worked on, this organization reduced onboarding time from three days to eight hours. Engineers could find the CreateOrder flow, understand the payment validation logic, and trace through to the database query without navigating across five different directories.

Actions and DTOs — Plain classes for use cases and data

An Action is a single-responsibility class that performs one piece of business logic. It takes in data, applies rules, and returns a result. It knows nothing about HTTP, routes, or Laravel's request/response cycle. DTOs (Data Transfer Objects) are plain classes that carry data between layers without behavior.

Where controllers handle HTTP concerns, Actions handle business concerns. This separation means your core logic lives outside the web layer and can be tested, reused, or invoked from a command, queue job, or API without duplication.

php
namespace App\Domains\Orders\DTOs;

final class CreateOrderDTO
{
    public function __construct(
        public int $customerId,
        public array $items,
        public string $shippingAddress,
        public string $paymentMethod,
    ) {}
}
php
namespace App\Domains\Orders\Actions;

use App\Domains\Orders\DTOs\CreateOrderDTO;
use App\Domains\Orders\Models\Order;
use App\Domains\Payments\Actions\ProcessPaymentAction;
use App\Domains\Inventory\Actions\ReserveStockAction;

final class CreateOrderAction
{
    public function __construct(
        private ProcessPaymentAction $processPayment,
        private ReserveStockAction $reserveStock,
    ) {}

    public function execute(CreateOrderDTO $dto): Order
    {
        // Validate stock availability
        $this->reserveStock->execute($dto->items);

        // Process payment
        $payment = $this->processPayment->execute(
            customerId: $dto->customerId,
            amount: $this->calculateTotal($dto->items),
            method: $dto->paymentMethod,
        );

        // Create order record
        $order = Order::create([
            'customer_id' => $dto->customerId,
            'shipping_address' => $dto->shippingAddress,
            'payment_id' => $payment->id,
            'status' => 'confirmed',
        ]);

        $order->items()->attach($dto->items);

        return $order;
    }

    private function calculateTotal(array $items): float
    {
        return collect($items)->sum(fn($item) => $item['price'] * $item['quantity']);
    }
}
php
namespace App\Http\Controllers;

use App\Domains\Orders\Actions\CreateOrderAction;
use App\Domains\Orders\Requests\CreateOrderRequest;
use App\Domains\Orders\DTOs\CreateOrderDTO;

final class OrderController extends Controller
{
    public function __construct(private CreateOrderAction $createOrder) {}

    public function store(CreateOrderRequest $request)
    {
        $dto = new CreateOrderDTO(
            customerId: auth()->id(),
            items: $request->validated('items'),
            shippingAddress: $request->validated('shipping_address'),
            paymentMethod: $request->validated('payment_method'),
        );

        $order = $this->createOrder->execute($dto);

        return response()->json(['order_id' => $order->id], 201);
    }
}

The CreateOrderAction orchestrates the business flow. It doesn't know it's being called from a controller. The same action can be invoked from a command, a queue job, or an event listener. The DTO is just a container—no methods, no logic, just properties. This makes it trivial to serialize for queue jobs or API payloads.

In practice, this pattern makes testing simpler. You test CreateOrderAction without any HTTP layer, without Laravel's request object, without mocking the entire container. You pass a DTO, assert the return value, and verify side effects.

Keep Eloquent at the edge — Domain logic does not depend on HTTP

Eloquent is powerful, but it's also deeply intertwined with the framework. Models couple your domain logic to Laravel's ORM. When domain logic depends directly on Eloquent models, your core business rules become bound to a specific database abstraction. That's fine for simple cases, but it creates problems when you need to test logic, swap implementations, or work with data from multiple sources.

The pattern here is to keep Eloquent models at the application boundary—in controllers, commands, or middleware. Domain logic should work with plain PHP objects or interfaces. Repositories provide the translation layer between your domain and Eloquent.

php
namespace App\Domains\Inventory;

interface InventoryRepositoryInterface
{
    public function findByProductId(int $productId): ?InventoryItem;
    public function reserve(int $productId, int $quantity): void;
    public function release(int $productId, int $quantity): void;
}
php
namespace App\Domains\Inventory;

final class InventoryItem
{
    public function __construct(
        public int $productId,
        public int $availableQuantity,
        public int $reservedQuantity,
    ) {}

    public function canReserve(int $quantity): bool
    {
        return $this->availableQuantity >= $quantity;
    }

    public function reserve(int $quantity): void
    {
        if (!$this->canReserve($quantity)) {
            throw new InsufficientStockException('Not enough inventory');
        }
        $this->availableQuantity -= $quantity;
        $this->reservedQuantity += $quantity;
    }
}
php
namespace App\Domains\Inventory\Actions;

use App\Domains\Inventory\InventoryRepositoryInterface;
use App\Domains\Inventory\InventoryItem;

final class ReserveStockAction
{
    public function __construct(
        private InventoryRepositoryInterface $inventory
    ) {}

    public function execute(array $items): void
    {
        foreach ($items as $item) {
            $inventory = $this->inventory->findByProductId($item['product_id']);

            if (!$inventory) {
                throw new ProductNotFoundException();
            }

            $inventory->reserve($item['quantity']);
            $this->inventory->reserve($item['product_id'], $item['quantity']);
        }
    }
}
php
namespace App\Infrastructure\Repositories;

use App\Domains\Inventory\InventoryRepositoryInterface;
use App\Domains\Inventory\InventoryItem;
use App\Models\Product;

final class EloquentInventoryRepository implements InventoryRepositoryInterface
{
    public function findByProductId(int $productId): ?InventoryItem
    {
        $product = Product::findOrFail($productId);

        return new InventoryItem(
            productId: $product->id,
            availableQuantity: $product->stock - $product->reserved,
            reservedQuantity: $product->reserved,
        );
    }

    public function reserve(int $productId, int $quantity): void
    {
        Product::where('id', $productId)
            ->increment('reserved', $quantity);
    }
}

Notice that ReserveStockAction depends on an interface, not Eloquent. It works with plain InventoryItem objects. The Eloquent translation lives in EloquentInventoryRepository. If you swap database libraries or add a caching layer, you change one repository class, not dozens of action classes.

This approach also simplifies testing. You create a fake repository that returns mock data, and your action tests run in milliseconds without touching the database.

Pragmatism — Small apps are fine with default structure

Clean architecture is an investment. It costs time upfront in more files, more abstractions, more naming decisions. For a five-thousand-line application with three models and two controllers, you're over-engineering.

The signal to refactor toward domain organization is when you start having trouble finding code. When controllers reach five hundred lines. When you have three different places where "create an order" logic lives. When a new feature requires understanding five different files in five different directories. When your team starts suggesting where new code should go and there's disagreement.

For a small internal tool or a fresh project with light domain complexity, the default Laravel structure is appropriate. As the codebase grows—as it always does in production—you migrate folders and classes toward domain organization incrementally. You don't need to do it all at once.

We've seen teams successfully migrate a 30k-line codebase to clean architecture over three sprints. You create the new domain folder structure in parallel, move classes as you touch them, update imports, and delete the old directories. No big bang refactor required.

Common mistakes

  • Overcomplicating the domain folder structure. Twenty levels of nested folders make code harder to navigate, not easier. Keep it two or three levels deep: domain, then category (Actions, Models, etc.), then the class.
  • Putting HTTP validation in the DTO. DTOs should be dumb containers. Validation belongs in Requests or at the action boundary, not inside the DTO class.
  • Creating repositories for everything. You don't need a repository for every model. Use them where you need abstraction—where you might swap implementations or mock behavior. For simple CRUD models, Eloquent in the controller is fine.
  • Circular dependencies between domains. If Orders depends on Payments and Payments depends on Orders, you have a design problem. Break the cycle with an event or a separate orchestration layer.
  • Forgetting that tests should follow the same structure. If you organize production code by domain, organize tests the same way. tests/Domains/Orders/Actions/CreateOrderActionTest.php makes it obvious what you're testing.

Key takeaways

  • Organize Laravel applications by business domain, not by technical type. This makes code discovery and feature understanding faster as your codebase grows.
  • Actions encapsulate use cases; DTOs transfer data between layers. This keeps business logic independent of the HTTP layer and framework details.
  • Keep Eloquent at the application boundary. Domain logic should depend on interfaces and plain objects, not on the ORM directly. Repositories provide the translation layer.
  • Clean architecture is a spectrum, not a binary choice. Small applications don't need it; apply these patterns incrementally as complexity justifies the investment.
  • Consistency within your team matters more than which specific pattern you choose. Define the structure once, document it, and stick to it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles