Technical

How I Structure Laravel Features for Growth

When a Laravel feature starts developing real business rules, I prefer to separate what the application does from how Laravel delivers and stores it. This structure gives each responsibility a clear home without abandoning the framework’s strengths.

A practical approach I use to organize Laravel features with domain models, use-case actions, DTOs, repositories, and infrastructure adapters.

Back to blog

How I Structure Laravel Features for Growth

Laravel makes it easy to build features quickly. A controller, an Eloquent model, and a Form Request can take a feature surprisingly far.

However, when a feature begins accumulating business rules, authorization requirements, external integrations, and multiple workflows, I find that the conventional structure can become harder to navigate.

For those features, I use a feature-first, layered structure:

app/Features/Orders/
├── Application/
├── Domain/
├── Http/
├── Infrastructure/
└── OrderServiceProvider.php

This is not a structure I apply automatically to every CRUD screen. I use it when the feature has enough behavior to benefit from clear boundaries.

Organize by feature first

Laravel projects are commonly organized by technical type:

app/
├── Http/Controllers/
├── Models/
├── Policies/
└── Services/

That structure works, but a single feature may become scattered across several directories.

For an order feature, I prefer keeping its related classes together:

app/Features/Orders/
├── Application/
│   ├── DTO/
│   └── UseCases/
├── Domain/
├── Http/
│   ├── Policies/
│   ├── Requests/
│   └── Resources/
├── Infrastructure/
│   └── Persistence/
│       └── Eloquent/
└── OrderServiceProvider.php

This makes the feature easier to find, understand, test, and eventually extract if its boundaries grow.

Give every layer one responsibility

I divide the feature into four main layers:

Layer Responsibility
Domain Business concepts and rules
Application Use cases and data transfer objects
HTTP Requests, authorization, controllers, and responses
Infrastructure Database access and external integrations

The dependency direction looks like this:

HTTP → Application → Domain
          ↓
    Repository port
          ↑
Infrastructure adapter

The Domain does not know about controllers, HTTP requests, or Eloquent. Infrastructure is allowed to depend on the Domain because it implements the technical details required by it.

Keep business behavior in the domain

I use a domain entity when the feature has meaningful state or business rules.

For example:

final class Order
{
    public function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        private OrderStatus $status,
        private Money $total,
    ) {}

    public function confirm(): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderCannotBeConfirmed;
        }

        $this->status = OrderStatus::Confirmed;
    }
}

Instead of changing an order's status directly, the application calls:

$order->confirm();

That method protects the rule that only pending orders can be confirmed.

The domain entity does not extend Laravel's Eloquent model. It represents the business concept, not a database row.

Use value objects for meaningful primitives

When an integer or string has a specific business meaning, I may represent it with a value object:

final readonly class OrderId
{
    private function __construct(private int $value) {}

    public static function fromInt(int $value): self
    {
        if ($value < 1) {
            throw new InvalidArgumentException(
                'Order IDs must be positive.',
            );
        }

        return new self($value);
    }

    public function value(): int
    {
        return $this->value;
    }
}

This prevents unrelated integers from being passed accidentally and keeps validation close to the concept it protects.

Not every primitive needs a value object. I add one when it provides meaningful validation, behavior, or type safety.

Define persistence through a repository port

The application needs to retrieve and store orders, but the Domain should not know that I am using Eloquent.

I define that requirement with an interface:

interface OrderRepository
{
    public function findForCustomer(
        CustomerId $customerId,
        OrderId $orderId,
    ): ?Order;

    public function create(
        CustomerId $customerId,
        CreateOrderDTO $data,
    ): Order;

    public function save(Order $order): Order;

    public function delete(Order $order): void;
}

I call this OrderRepository because it represents access to stored order entities.

I would not name it OrderService. A repository handles persistence boundaries, while a service or action represents an operation.

In Ports and Adapters terminology:

OrderRepository         → port
EloquentOrderRepository → adapter

Represent use cases as actionsabat

I create a focused action for each application operation:

final readonly class ConfirmOrderAction
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function execute(
        CustomerId $customerId,
        OrderId $orderId,
    ): OrderDTO {
        $order = $this->orders->findForCustomer(
            $customerId,
            $orderId,
        ) ?? throw OrderNotFound::for($orderId);

        $order->confirm();

        return OrderDTO::fromDomain(
            $this->orders->save($order),
        );
    }
}

This action coordinates one use case:

  1. Find the customer's order.
  2. Throw a domain-specific exception if it does not exist.
  3. Ask the domain entity to confirm itself.
  4. Save the updated entity.
  5. Return an output DTO.

I use names that clearly describe the operation:

  • CreateOrderAction
  • ListCustomerOrdersAction
  • FindCustomerOrderAction
  • ConfirmOrderAction
  • CancelOrderAction

I also choose one invocation convention—such as execute()—and use it consistently.

Use DTOs at application boundaries

I use DTOs to carry typed input and output through the Application layer.

An input DTO can represent validated data:

final readonly class CreateOrderDTO
{
    /**
     * @param list<CreateOrderItemDTO> $items
     */
    public function __construct(
        public array $items,
        public string $shippingAddress,
    ) {}
}

An output DTO exposes the application result:

final readonly class OrderDTO
{
    public function __construct(
        public int $id,
        public string $status,
        public int $totalInCents,
    ) {}

    public static function fromDomain(Order $order): self
    {
        return new self(
            id: $order->id()->value(),
            status: $order->status()->value,
            totalInCents: $order->total()->inCents(),
        );
    }
}

DTOs are simple, readonly data carriers. I avoid putting persistence, authorization, or substantial business behavior inside them.

Keep Eloquent in Infrastructure

The Eloquent model and repository implementation belong to the Infrastructure layer:

Infrastructure/
└── Persistence/
    └── Eloquent/
        ├── EloquentOrderRepository.php
        └── OrderRecord.php

OrderRecord represents the database:

final class OrderRecord extends Model
{
    protected $table = 'orders';

    protected $fillable = [
        'customer_id',
        'status',
        'total_in_cents',
    ];

    protected function casts(): array
    {
        return [
            'total_in_cents' => 'integer',
        ];
    }
}

EloquentOrderRepository implements the domain port:

final class EloquentOrderRepository implements OrderRepository
{
    public function findForCustomer(
        CustomerId $customerId,
        OrderId $orderId,
    ): ?Order {
        $record = OrderRecord::query()
            ->where('customer_id', $customerId->value())
            ->find($orderId->value());

        return $record instanceof OrderRecord
            ? $this->toDomain($record)
            : null;
    }

    private function toDomain(OrderRecord $record): Order
    {
        return new Order(
            OrderId::fromInt((int) $record->getKey()),
            CustomerId::fromInt((int) $record->customer_id),
            OrderStatus::from((string) $record->status),
            Money::fromCents((int) $record->total_in_cents),
        );
    }
}

The repository translates between the persistence record and the domain entity.

These two objects do not need to have the same shape:

Order       → business representation
OrderRecord → database representation

The record follows database requirements. The entity follows business requirements.

Keep controllers focused on HTTP

I use controllers to coordinate the HTTP boundary:

public function store(
    StoreOrderRequest $request,
    CreateOrderAction $createOrder,
): RedirectResponse {
    $customerId = CustomerId::fromInt(
        $request->user()->id,
    );

    $createOrder->execute(
        $customerId,
        CreateOrderDTO::fromArray($request->validated()),
    );

    return to_route('orders.index');
}

The controller should:

  1. Obtain the authenticated user.
  2. Authorize the operation.
  3. Receive validated input.
  4. Build the application DTO.
  5. Call the appropriate action.
  6. Return an HTTP response.

I avoid putting queries and business rules directly in the controller.

Use resources to control frontend data

A Laravel resource converts an output DTO into the exact shape expected by the client:

/** @mixin OrderDTO */
final class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        /** @var OrderDTO $order */
        $order = $this->resource;

        return [
            'id' => $order->id,
            'status' => $order->status,
            'total_in_cents' => $order->totalInCents,
        ];
    }
}

The resource gives me one place to:

  • Rename properties
  • Format dates and monetary values
  • Exclude private information
  • Maintain a stable frontend contract

I keep database access and business decisions out of resources.

Connect everything through the service container

A feature service provider connects the repository port to its adapter:

final class OrderServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            OrderRepository::class,
            EloquentOrderRepository::class,
        );
    }
}

The application requests an OrderRepository, and Laravel supplies the Eloquent implementation.

During tests, I can provide an in-memory implementation of the same interface.

Test each boundary

This structure gives me several useful testing levels.

Domain tests

I test business rules without Laravel or a database:

it('confirms a pending order', function () {
    $order = pendingOrder();

    $order->confirm();

    expect($order->status())->toBe(OrderStatus::Confirmed);
});

Application tests

I test actions with an in-memory repository:

ConfirmOrderAction
    ↓
OrderRepository
    ↓
InMemoryOrderRepository

This verifies the use case without requiring Eloquent.

Feature tests

I use Laravel feature tests to verify:

  • Authentication
  • Authorization
  • Validation
  • Database persistence
  • HTTP redirects and responses
  • Inertia or JSON output
  • Customer and tenant isolation

Architecture tests

I also protect the dependency direction:

arch('order domain stays framework free')
    ->expect('App\\Features\\Orders\\Domain')
    ->not->toUse(['Illuminate', 'Inertia', 'App\\Models']);

arch('order application stays framework free')
    ->expect('App\\Features\\Orders\\Application')
    ->not->toUse(['Illuminate', 'Inertia', 'App\\Models', 'Eloquent']);

These tests prevent framework concerns from gradually leaking back into the core layers.

When I use this architecture

I consider this structure when a feature has:

  • Important business rules
  • Several related use cases
  • Complex authorization or ownership
  • External integrations
  • Persistence-specific complexity
  • Multiple delivery mechanisms
  • A need for fast, isolated tests

For a basic administration screen with straightforward CRUD operations, standard Laravel controllers and Eloquent models may be completely sufficient.

The goal is not to create more classes. The goal is to make important boundaries visible.

Final thoughts

The principle I follow is simple:

Domain entities own business behavior.
Actions coordinate use cases.
DTOs carry application data.
Repository ports describe persistence needs.
Infrastructure adapters implement those needs.
Controllers and resources manage HTTP.

Laravel and Eloquent remain important parts of the application. I simply keep them at the boundaries where they are most useful.

This approach gives me a framework-friendly architecture while keeping the core business logic easier to understand, test, and change.