---
title: 'Laravel Action Classes, Services and DTOs | DevSense'
description: 'How to slim down fat controllers and models in Laravel: Action classes, a service layer, readonly DTOs, module boundaries, transactions and events — and when all of it is overengineering.'
faq:
    - { question: 'What is the difference between an Action class and a service in Laravel?', answer: 'An Action describes a single business use case (place an order, invite a team member) and exposes one public method. A service groups operations around a resource or an integration (a payment gateway, a price calculator) and knows nothing about the use case as a whole. Actions call services, never the other way around.' }
    - { question: 'Why use DTOs when there is $request->validated() and plain arrays?', answer: "An array doesn't tell you which keys it holds or what their types are: a typo in a key surfaces only at runtime, the IDE can't help, and static analysis is powerless. A readonly DTO pins down the contract — the type of every field, whether it is required, and immutability. The same Action can then be called from a controller, a console command, a queued job and a test." }
    - { question: 'Can Eloquent models be passed between modules?', answer: "Within a module, yes. Between modules it is better to pass DTOs or identifiers: a model drags along its relations, lazy queries and the ability to modify someone else's data via save(). A DTO boundary makes dependencies explicit and lets you change a module's table schema without breaking its neighbours." }
    - { question: 'When are Action classes and DTOs overengineering?', answer: 'For CRUD without business rules, admin panels and prototypes. If a five-line controller validates the request and saves a model, extracting an Action and a DTO adds three files and not a single rule. The layers pay off once a use case has invariants, multiple entry points or side effects.' }
published: '2026-10-03'
---
# Action Classes, Services and DTOs in Laravel: Slimming Down Controllers and Models

Almost every Laravel project goes through the same phase. `OrderController@store` grows to two hundred lines: validation, price calculation with a promo code, stock reservation, charging the customer, an email to the customer, a webhook to the CRM. Then an API for the mobile app shows up, and those two hundred lines get copied into a second controller. Then comes a console command that imports orders from a marketplace — copy number three. Six months later, a rounding bug has been fixed in one of the copies but not in the other two.

Meanwhile the `Order` model keeps growing: it has `send()`, `refund()` and `syncWithCrm()` methods, event handlers and a couple of HTTP calls. The only way to test it is through HTTP, and every change is scary. This article is about splitting that code into layers: **Action classes** for use cases, **services** for reusable operations, and **DTOs** for the data passed between them. It is also about knowing where to stop, so that a simple CRUD doesn't turn into an enterprise monolith with forty interfaces.

**Related guides:** [Design Anti-Patterns](design-antipatterns) · [GoF Structural Patterns](structural-design-patterns) · [Resilient Integrations](resilient-external-integrations) · [Laravel Queues in Production](laravel-queues-production)

## Contents

* [Symptoms of a fat controller and a fat model](#symptoms)
* [The layer map: who is responsible for what](#layers)
* [DTOs: a contract instead of an array](#dto)
* [Action classes: one use case, one class](#actions)
* [The service layer: when you actually need it](#services)
* [The controller after refactoring](#thin-controller)
* [One piece of logic, many entry points: commands, jobs, tests](#reuse)
* [What stays in the model](#models)
* [Module boundaries: DTOs between contexts](#modules)
* [When it is overengineering](#when-not)
* [How to refactor incrementally](#migration)
* [Common Mistakes](#common-mistakes)
* [Checklist](#checklist)
* [Self-Test Quiz](#self-test-quiz)

---

<a id="symptoms"></a>
## Symptoms of a fat controller and a fat model

The problem isn't the line count — it's that different reasons for change are mixed in one place. Signs that it's time to split the code up:

* **Logic is copied between entry points.** The web controller, the API controller and the console command all do "almost the same thing".
* **Testing a use case requires an HTTP request.** To check a discount calculation you have to log in, build a form and parse the redirect.
* **It's unclear where the transaction boundary is.** The order was saved, the stock reservation failed — and the database is left with an order that has no reservation.
* **Side effects happen before the commit.** The "order placed" email went out, but the transaction was rolled back.
* **The model knows about everything.** `Order` sends emails, talks to the CRM and calculates taxes. Every task means editing the same file, and Git conflicts become the norm.
* **Arrays of unknown shape.** Is it `$data['items'][0]['qty']` or `$data['items'][0]['quantity']`? Only the debugger knows.

---

<a id="layers"></a>
## The layer map: who is responsible for what

| Layer | Responsible for | Must not |
|------|-------------|-----------|
| **Controller** | Accepting the HTTP request, calling the use case, returning the response | Calculate prices or call external APIs |
| **FormRequest** | Authorizing and validating input, building the DTO | Persist data |
| **DTO** | Carrying typed data between layers | Contain business logic or access the database |
| **Action** | A single business use case, the transaction boundary, the order of steps | Know about HTTP, the session or redirects |
| **Service** | A reusable operation or integration (pricing, payments, inventory) | Orchestrate the whole use case |
| **Model** | Data, relations, casts, scopes, simple derived values | Send emails or make HTTP requests |
| **Job / Event** | Deferred and asynchronous side effects | Return a result to the request |

Dependencies point in one direction only: controller → Action → services and models. A service never calls an Action, and a model knows about neither.

---

<a id="dto"></a>
## DTOs: a contract instead of an array

A DTO (Data Transfer Object) is an object without behaviour that carries data across a layer boundary. In modern PHP it takes a handful of lines thanks to readonly classes ([PHP 8.2](../php/8.2)) and constructor property promotion:

```php
// app/Domain/Orders/Data/OrderLineData.php
namespace App\Domain\Orders\Data;

final readonly class OrderLineData
{
    public function __construct(
        public int $productId,
        public int $quantity,
    ) {}
}
```

```php
// app/Domain/Orders/Data/PlaceOrderData.php
namespace App\Domain\Orders\Data;

use App\Domain\Orders\Enums\DeliveryMethod;

final readonly class PlaceOrderData
{
    /**
     * @param  list<OrderLineData>  $lines
     */
    public function __construct(
        public int $customerId,
        public array $lines,
        public DeliveryMethod $delivery,
        public ?string $promoCode = null,
    ) {}
}
```

What this gives you compared to an array:

* **Types.** `quantity` is always an `int`, and `delivery` is an enum value rather than a `"courrier"` string with a typo.
* **Immutability.** No step of the use case can "quietly" tweak the data for the next one.
* **IDE hints and static analysis.** PHPStan/Psalm catch access to a non-existent field before anything runs.
* **Independence from the source.** The Action doesn't know whether the data came from a form, a JSON API, a CSV import or a test.

Validation stays in the `FormRequest` — that's its job. The DTO is built from data that has already been validated. It's convenient to keep the construction right next to the rules:

```php
// app/Http/Requests/PlaceOrderRequest.php
public function rules(): array
{
    return [
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.product_id' => ['required', 'integer', 'exists:products,id'],
        'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
        'delivery' => ['required', Rule::enum(DeliveryMethod::class)],
        'promo_code' => ['nullable', 'string', 'max:32'],
    ];
}

public function toData(): PlaceOrderData
{
    $validated = $this->validated();

    return new PlaceOrderData(
        customerId: $this->user()->id,
        lines: array_map(
            fn (array $line) => new OrderLineData((int) $line['product_id'], (int) $line['quantity']),
            $validated['lines'],
        ),
        delivery: DeliveryMethod::from($validated['delivery']),
        promoCode: $validated['promo_code'] ?? null,
    );
}
```

> [!NOTE]
> **Do you need a package?** For a dozen DTOs, hand-written readonly classes are enough. Packages like `spatie/laravel-data` add automatic construction from the request, attribute-based validation and JSON transformation. That's handy when you have hundreds of DTOs that double as API resources, but it ties your domain layer to the package. Start with plain classes.

Store money in DTOs as an integer in minor units (cents) or as a `Money` object, not as a `float`: in PHP, `0.1 + 0.2` is not equal to `0.3`.

---

<a id="actions"></a>
## Action classes: one use case, one class

An Action is a class with a single public method that runs one business use case from start to finish. Its name is a verb from the domain language: `PlaceOrder`, `CancelSubscription`, `InviteTeamMember`.

```php
// app/Domain/Orders/Actions/PlaceOrder.php
namespace App\Domain\Orders\Actions;

use App\Domain\Orders\Data\PlaceOrderData;
use App\Domain\Orders\Events\OrderPlaced;
use App\Domain\Orders\Services\PriceCalculator;
use App\Domain\Orders\Services\StockReservations;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(
        private PriceCalculator $prices,
        private StockReservations $stock,
    ) {}

    public function handle(PlaceOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $quote = $this->prices->quote($data->lines, $data->promoCode);

            $order = Order::create([
                'customer_id' => $data->customerId,
                'delivery' => $data->delivery,
                'total_cents' => $quote->totalCents,
                'discount_cents' => $quote->discountCents,
            ]);

            $order->lines()->createMany($quote->linesForStorage());

            // Throws when stock is insufficient, which rolls back the whole order.
            $this->stock->reserve($order);

            // The event implements ShouldDispatchAfterCommit,
            // so listeners never see an order that was rolled back.
            OrderPlaced::dispatch($order->id);

            return $order;
        });
    }
}
```

Key decisions in this code:

* **The transaction boundary lives in the Action.** The use case knows which steps must run atomically. The controller doesn't have to think about it, and neither do the services.
* **Dependencies come through the constructor.** Laravel's container resolves `PriceCalculator` and `StockReservations` on its own, and they're easy to swap out in tests.
* **Side effects after the commit.** An event implementing `ShouldDispatchAfterCommit` is dispatched only once the transaction is committed. For queued jobs, `ShouldQueueAfterCommit` or the connection's `after_commit` option does the same.
* **It returns a result, not an HTTP response.** The Action doesn't know what comes next — a redirect, JSON or a line in the console.

```php
// app/Domain/Orders/Events/OrderPlaced.php
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

final class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(public int $orderId) {}
}
```

Whether you call the method `handle()`, `execute()` or `__invoke()` is a team convention. What matters is that there's exactly one and it's named the same way across the whole project. `__invoke()` lets you pass an Action around as a callable; `handle()` reads better when called explicitly.

> [!NOTE]
> **An Action can call another Action** if it's part of the use case: `PlaceOrder` may call `ApplyLoyaltyPoints`. But watch your transactions: a nested `DB::transaction()` creates a savepoint, and rolling back the inner one does not roll back the outer one if the exception is caught.

---

<a id="services"></a>
## The service layer: when you actually need it

A service is a class built around a single responsibility used by several use cases: price calculation, inventory, a payment gateway, PDF generation. It doesn't know why it was called.

```php
// app/Domain/Payments/Contracts/PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amountCents, string $currency, string $idempotencyKey): PaymentResult;

    public function refund(string $paymentId, int $amountCents): RefundResult;
}
```

```php
// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
}
```

An interface is justified when there's more than one implementation (the real gateway and a fake for tests, two providers in different countries) or when it marks a boundary with the outside world. For an internal `PriceCalculator` with a single implementation, an interface is just an extra file: a concrete class is just as easy to swap in a test via `$this->mock()` or `$this->app->instance()`.

The main danger of a service layer is the **God Service**. An `OrderService` with `create`, `cancel`, `refund`, `export`, `notify` and `recalculate` methods and forty constructor dependencies is the same fat controller, just in a different folder. If a service has more than five to seven public methods that don't share any data, it's several Action classes glued together.

---

<a id="thin-controller"></a>
## The controller after refactoring

```php
// app/Http/Controllers/OrderController.php
public function store(PlaceOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
    try {
        $order = $placeOrder->handle($request->toData());
    } catch (InsufficientStock $e) {
        return back()->withErrors(['lines' => $e->getMessage()])->withInput();
    }

    return to_route('orders.show', $order);
}
```

The controller translates HTTP into a use case call and the use case result back into HTTP. The `InsufficientStock` domain exception becomes a form error. In an API controller the same exception becomes a `422` response, while the ordering logic stays untouched.

---

<a id="reuse"></a>
## One piece of logic, many entry points: commands, jobs, tests

The same Action is called unchanged from the import console command:

```php
// app/Console/Commands/ImportMarketplaceOrders.php
public function handle(MarketplaceClient $client, PlaceOrder $placeOrder): int
{
    foreach ($client->newOrders() as $external) {
        $placeOrder->handle(MarketplaceOrderMapper::toData($external));
    }

    return self::SUCCESS;
}
```

And it's tested without HTTP, sessions or CSRF:

```php
public function test_placing_an_order_reserves_stock(): void
{
    $customer = User::factory()->create();
    $product = Product::factory()->create(['stock' => 10, 'price_cents' => 1500]);

    $order = app(PlaceOrder::class)->handle(new PlaceOrderData(
        customerId: $customer->id,
        lines: [new OrderLineData($product->id, 2)],
        delivery: DeliveryMethod::Courier,
    ));

    $this->assertSame(3000, $order->total_cents);
    $this->assertSame(8, $product->fresh()->stock);
}

public function test_order_is_rolled_back_when_stock_is_insufficient(): void
{
    $product = Product::factory()->create(['stock' => 1]);

    $this->expectException(InsufficientStock::class);

    try {
        app(PlaceOrder::class)->handle(new PlaceOrderData(
            customerId: User::factory()->create()->id,
            lines: [new OrderLineData($product->id, 5)],
            delivery: DeliveryMethod::Pickup,
        ));
    } finally {
        $this->assertDatabaseCount('orders', 0);
    }
}
```

Controller tests then become thin: they check validation, authorization and that the response is correct. Business rules are tested once — at the Action level.

---

<a id="models"></a>
## What stays in the model

The goal isn't an "anemic model" without a single method. An Eloquent model is a good home for everything that describes **the data itself**:

* relations (`lines()`, `customer()`), casts (`'delivery' => DeliveryMethod::class`), scopes (`scopePaid()`);
* simple derived values without side effects: `isPaid()`, `canBeCancelledBy(User $user)`, a `total` accessor built from `total_cents`;
* invariants of a single entity that don't need other services.

What doesn't belong in a model: sending emails, HTTP requests, working with queues, complex logic in observers. Observers are particularly treacherous: they fire during imports, in seeders and in tests, and they're invisible from the use case code. If a side effect is part of the use case, it belongs in the Action.

---

<a id="modules"></a>
## Module boundaries: DTOs between contexts

As a project grows, it pays to group code not by technical layers (`Controllers`, `Models`, `Services`) but by domain areas:

```
app/
├── Domain/
│   ├── Orders/
│   │   ├── Actions/        PlaceOrder, CancelOrder
│   │   ├── Data/           PlaceOrderData, OrderLineData
│   │   ├── Enums/          DeliveryMethod, OrderStatus
│   │   ├── Events/         OrderPlaced
│   │   └── Services/       PriceCalculator, StockReservations
│   ├── Billing/
│   │   ├── Actions/        ChargeOrder, IssueRefund
│   │   ├── Contracts/      PaymentGateway
│   │   └── Data/           PaymentResult
│   └── Catalog/
├── Http/                   controllers and form requests stay framework-shaped
└── Models/
```

The rule for boundaries: **the `Billing` module does not accept the `Order` model from the `Orders` module.** It receives a DTO or an identifier:

```php
// Inside the Orders module: a listener translates the event into a Billing call.
final class ChargePlacedOrder implements ShouldQueue
{
    public function handle(OrderPlaced $event, ChargeOrder $charge, OrderSummaryQuery $orders): void
    {
        $summary = $orders->summary($event->orderId); // returns a DTO, not a model

        $charge->handle(new ChargeRequestData(
            reference: "order-{$summary->id}",
            amountCents: $summary->totalCents,
            currency: $summary->currency,
        ));
    }
}
```

Why not a model:

* a model lets another module call `$order->update()` — and nobody will ever know who changed the status;
* lazy relations turn `$order->customer->address` into queries the `Billing` module has no idea about;
* the `orders` table schema becomes a public API — you can't change it without checking every module.

It's the same principle as when [moving from a monolith to microservices](monolith-to-microservices-architecture): first explicit boundaries inside a single application, and only then — over the network, if it's ever needed at all.

---

<a id="when-not"></a>
## When it is overengineering

Layers cost time: more files, more jumping around the code, more conventions. They don't pay off when:

* **It's CRUD without rules.** A profile edit form that validates and saves five fields doesn't need an `UpdateProfileAction` and an `UpdateProfileData`. `$user->update($request->validated())` is perfectly fine code.
* **It's an admin panel built on Filament or Nova.** The framework already imposes its own structure, and Action classes on top of it often duplicate its resources.
* **It's a prototype.** While you're still searching for the product, speed of change matters more than architecture. Refactor into layers once the use case has settled.
* **The use case has a single entry point and no side effects.** There's nothing to reuse and nothing to isolate.

Good signals that it's time: a second entry point into the same use case, the first "the email went out but the transaction rolled back", or the first test that can't be written without HTTP.

---

<a id="migration"></a>
## How to refactor incrementally

Rewriting the whole project "to Actions" is unnecessary and dangerous. An approach that works:

1. **New use cases go straight into the new style.** This sets an example without risking the old code.
2. **Write a characterization test before extracting.** A feature test pins down the controller's current behaviour, even if it's odd. It catches regressions during the move.
3. **Extract one use case at a time.** The controller method body moves into the Action's `handle()` almost unchanged, and the controller starts calling it. The test must stay green.
4. **Only then improve.** Introduce a DTO instead of an array, move side effects after the commit, extract services from repeated chunks.
5. **Delete the copies.** The second and third controllers start calling the same Action, and the duplicates go away.

Each step is a separate small PR that can be reverted.

---

<a id="common-mistakes"></a>
## Common Mistakes

**1. The Action accepts a `Request`.**
The use case is tied to HTTP again, and you can't call it from a command or a job without a fake request. Pass a DTO.

**2. DTOs with business logic.**
A `calculateTotal()` method on a DTO is a service hiding inside a data object. A DTO only carries data.

**3. Mutable DTOs.**
Public non-readonly properties let one step change the data for another. Use `readonly`.

**4. A God Service instead of a fat controller.**
A two-thousand-line `OrderService` is the same problem in a new folder. Use cases go into Action classes; services stay narrow.

**5. Side effects inside the transaction.**
Emails, webhooks and queued jobs dispatched before the commit fire even on rollback. Use `ShouldDispatchAfterCommit`, `ShouldQueueAfterCommit` or `afterCommit()`.

**6. An interface for every class.**
A `PriceCalculatorInterface` with a single implementation and no external boundary is a pointless layer of indirection. Laravel can swap concrete classes in tests.

**7. Layers for the sake of layers.**
An `UpdateUserNameAction` with an `UpdateUserNameData` for a single `$user->update()` line is ceremony, not architecture.

---

<a id="checklist"></a>
## Checklist

1. The controller only translates HTTP into a use case call and the result back into HTTP.
2. Validation and authorization live in the `FormRequest`, along with building the DTO.
3. One business use case means one Action class with one public method.
4. The transaction boundary is defined in the Action.
5. Side effects (emails, webhooks, jobs) are dispatched after the commit.
6. DTOs are `final readonly`, typed and logic-free; money is never a `float`.
7. Services are narrow; interfaces only for external boundaries and multiple implementations.
8. Modules exchange DTOs or identifiers, not models.
9. Business rules are covered by Action-level tests, without HTTP.

---

## Summary

Action classes, services and DTOs aren't about pretty folders. They're about giving every business use case one home, one transaction boundary and one set of tests. Start from the pain — duplication, side effects before the commit, code you can't test — and introduce exactly as many layers as it takes to remove it. Let simple CRUD stay simple.

---

<a id="self-test-quiz"></a>
## Self-Test Quiz

### Question 1: Where should the transaction boundary be defined when placing an order?
- A) In the controller, around the Action call.
- B) In the Action class, which knows which steps of the use case must run atomically.
- C) In each service separately — one transaction per call.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
The use case knows that the order, its lines and the stock reservation must be saved together. The controller shouldn't know such details, and separate transactions in services don't make the whole use case atomic.
</details>

### Question 2: Inside `DB::transaction()` an "Order placed" email is sent, and then the stock reservation fails with an exception. What happens without any extra measures?
- A) The email won't go out, because the transaction was rolled back.
- B) The email goes out (or the job that sends it lands in the queue), even though the order isn't in the database.
- C) Laravel automatically cancels the email via a rollback event.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
A transaction rollback only affects the database. External side effects must be dispatched after the commit: via `ShouldDispatchAfterCommit` on events, and `ShouldQueueAfterCommit` or `afterCommit()` on jobs.
</details>

### Question 3: The `Billing` module needs to charge for an order. What is best to pass to it from the `Orders` module?
- A) The entire `Order` model, so that `Billing` can load the relations it needs itself.
- B) A DTO with the amount, currency and order reference, or the order ID.
- C) The `$request->all()` array from the original request.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
A DTO pins down which data the module needs and prevents it from modifying other modules' records or firing hidden queries through relations. The `orders` table schema stops being a public API for other modules.
</details>