Web Development 5-8 minutes

Laravel 13 Architecture: When to Use Actions, Services, and DTOs (with Examples)

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Laravel 13 Architecture: When to Use Actions, Services, and DTOs (with Examples)
Image generated with AI

Your Laravel controller started with ten lines and now it validates, charges, emails, and formats responses: it has 150 lines and every change is scary. Actions, Services, and DTOs are how the Laravel community organizes that logic, and this guide gives clear rules for choosing each piece.

We see it with a step-by-step refactor of an e-commerce checkout, from fat controller to thin controller.

The Problem: Fat Controllers

Every Laravel project starts clean: small controllers, models without surprises. Then features arrive, and the store method that validated three fields ends up doing everything. That fat controller is not an aesthetic problem: it is where bugs are born, where tests get hard, and where the fear of touching working code comes from.

Signs Your Controller Is Asking for a Refactor

The symptoms are clear: the method passes fifty lines, mixes validation with business logic and response formatting, repeats code that already exists in another controller, or depends on external services that are hard to fake in tests. When an operation is duplicated across two entry points, the controller has already stopped being the right place.

Why "Put It All in the Model" Does Not Work Either

The natural temptation is to move the logic into the Eloquent model and call it a day. The model then becomes a drawer full of responsibilities that are not its own: sending emails, calling the payment gateway, or generating PDFs. The model describes data and the rules of that data; business operations deserve another home.

What Business Logic Needs: Its Own Testable Place

Business logic needs its own place, with a clear name and no HTTP dependencies. When an operation lives in a standalone class, you can run it from a controller, from a queued job, or from a command, and test it without spinning up a web request. That is exactly what Actions, Services, and DTOs are about.

The Three Pieces: Actions, Services, and DTOs

These are three different tools that usually get confused. The short definition: an Action is one operation, a Service is a cohesive group of operations, and a DTO is a data contract that travels between layers.

Action: One Operation, One Invokable Class

An Action encapsulates a single business operation. The most common convention is an invokable class: the container resolves it and you call it with function syntax. A minimal example:

<?php

namespace App\Actions\Order;

use App\Models\Order;

class MarkOrderAsPaid
{
    public function __invoke(Order $order): void
    {
        $order->update(['status' => 'paid', 'paid_at' => now()]);
    }
}

Service: A Group of Operations Around a Domain

A Service groups several related operations around the same domain or integration. The typical example is the payment provider: charging, refunding, and checking a charge live together because they share configuration and state.

<?php

namespace App\Services;

class PaymentService
{
    public function charge(int $amountCents, string $token): array { /* ... */ }

    public function refund(string $chargeId): array { /* ... */ }
}

DTO: The Data Contract Between Layers

A DTO (Data Transfer Object) carries data between layers with an explicit contract: typed properties, no business logic, and usually immutable. When a request, an external API, and a queue need the same data package, the DTO is the piece that normalizes them.

When to Use Each: Decision Rules

The golden question is not "Actions or Services?" but "what problem am I solving?". These three rules cover ninety percent of the cases.

Use an Action When the Operation Is Reused from Several Entry Points

If the same operation is triggered by a controller, a queued job, and a command, that logic belongs in an Action. You write the operation once and every entry point calls it; Laravel even supports the one-class-per-action idea with its Single Action Controllers via make:controller --invokable, which fits the same approach.

Use a Service for Integrations and Stateful Logic

When you work with an external integration or a domain with several operations that share configuration, the Service is the natural home: PaymentService with charge and refund, MailService with its methods, or an invoicing service that groups everything related. The Service holds the injected dependency once and exposes its operations.

Use a DTO When Data Crosses Layers or Comes from Multiple Sources

Use a DTO when you want an Action or a Service to receive a typed data package instead of a loose array, or when the same data set arrives from the request, from an external API, or from a queue. The DTO turns three different formats into a single contract.

In Practice: A Step-by-Step Checkout Refactor

Theory makes sense when you see the code. Let us take a generic e-commerce checkout and move it from fat controller to clean structure.

Before: The 150-Line Controller

The starting point: the store method validates the cart, creates the order, charges the gateway, sends the email, fires an event, logs, and returns JSON. Everything inside the controller, with inline validation and dependencies resolved through facades. It works, but every new change breaks something.

Step 1: Keep Validation in Its Form Request

The first move is to take validation out of the controller and into a Form Request. The controller receives an already-validated object and saves ten lines of rules; the full topic of validation with Form Requests has its own article on this blog if you want to go deeper.

Step 2: Model the Data with a DTO

The operation needs the cart, the customer, and the payment method. Instead of passing three loose arguments or an array, you define a DTO that groups the payload:

<?php

namespace App\Data;

final readonly class CheckoutData
{
    public function __construct(
        public array $items,
        public int $customerId,
        public string $paymentToken,
    ) {}
}

If you prefer built-in validation and Data::from(), spatie/laravel-data gets you the same DTO with less code, as you will see later.

Step 3: Move the Main Operation into an Action

The operation that creates the order, charges, and fires the event moves into an invokable Action. The controller no longer knows how charging works: it just calls it.

<?php

namespace App\Actions\Order;

use App\Data\CheckoutData;
use App\Models\Order;

class PlaceOrder
{
    public function __invoke(CheckoutData $data): Order
    {
        // creates the order, charges, and fires the OrderPlaced event
    }
}

Step 4: Group What Is Left into a Service

If the flow makes several calls to the gateway (charge, and optionally refund later), those cohesive operations belong in PaymentService. The Action uses the Service, the Service uses the gateway, and each piece has a single responsibility.

After: The Controller as HTTP Glue

The final controller is reduced to its real role: receive the request, delegate, and respond.

<?php

namespace App\Http\Controllers;

use App\Actions\Order\PlaceOrder;
use App\Data\CheckoutData;
use App\Http\Requests\CheckoutRequest;

class CheckoutController extends Controller
{
    public function __invoke(CheckoutRequest $request, PlaceOrder $placeOrder)
    {
        $order = $placeOrder(new CheckoutData(
            items: $request->validated('items'),
            customerId: $request->user()->id,
            paymentToken: $request->validated('payment_token'),
        ));

        return response()->json($order, 201);
    }
}

The controller validates with the Form Request, builds the DTO, calls the Action, and responds. Nothing else. The logic ended up in named, testable, reusable classes.

What Laravel Does for Free: The Container

The best part of this style is that it needs no manual registration: the Laravel service container resolves dependencies for you.

Auto-Wiring: Constructor Type-Hints Resolve Dependencies

If you type-hint a class in a constructor or in the signature of a controller method, the container instantiates it on its own with its dependencies, without registering it anywhere. That is why PlaceOrder appears directly in the method signature: Laravel builds it, injects what it needs, and hands it to you ready to use.

Interfaces and Bindings in a ServiceProvider

Auto-wiring works when the dependency is a concrete class. If you program against an interface, for example to swap payment gateways, you register the binding in a ServiceProvider with $this->app->bind(...) or singleton, and the container resolves the interface with the implementation you choose.

Laravel 13 and PHP Attributes: Convention over Configuration

Laravel 13, released in March 2026 with PHP 8.3 as the minimum version, keeps pushing PHP Attributes as a declarative configuration mechanism. That same spirit applies to your code: readonly classes and native PHP attributes make Actions, Services, and DTOs easier to write, with less ceremony and more intent. The Laravel 13 new features roundup on this blog covers the rest of the release.

DTOs in 2026: Modern PHP and spatie/laravel-data

DTOs are not new, but modern PHP made them trivial, and Laravel 13 takes advantage of that without needing packages in most cases.

readonly Class and Constructor Promotion: A DTO with Zero Dependencies

With constructor property promotion and readonly classes, a native DTO is written in six lines and is truly immutable: typed properties that cannot change after construction. For internal contracts between your own layers, it is the simplest option with zero dependencies.

spatie/laravel-data: Built-In Validation and Data::from()

When the DTO has to validate its payload or be created from several sources, spatie/laravel-data (v4) is the usual choice: the Data class validates the data before construction and lets you create it with Data::from() from a validated request, an array, or a model. It adds a dependency, but in exchange it integrates validation, transformation, and factories.

One DTO for Several Sources: Request, External API, and Queue

The case where the DTO shines is data arriving through several paths: the same checkout is triggered by a user on the web, by an external API, or by a retried queued job. Each source delivers the payload in a different format; the DTO normalizes it into a single contract and the Action neither knows nor cares where it came from.

Common Mistakes and When NOT to Apply These Patterns

These patterns solve a real problem, but applied badly they create another one. The classic anti-patterns are easy to recognize.

The God Service and the Action That Calls Everything

A Service with forty methods from unrelated domains is a fat controller under another name, and an Action that chains ten operations stops being a single operation. If the class cannot fit in one sentence ("this charges", "this refunds"), it is doing too much.

Abstracting a Trivial CRUD: You Do Not Need It

A simple CRUD with no business logic does not need layers: the controller with its Form Request and the model are enough. Creating Actions and Services for a standard create is over-engineering that adds files without adding clarity.

The Practical Rule for Small Teams: Extract When It Hurts

The golden rule for medium projects and small teams: start simple and extract the first class when it hurts. When a controller passes fifty lines, when an operation is duplicated, or when a test needs to fake an external call, that is the moment to create the Action, the Service, or the DTO. Not before.

How to Test Actions and Services

This structure pays its debt in tests. An Action is tested like a normal class: you instantiate it with fake dependencies and assert the result, without touching HTTP. If it charges through PaymentService, you mock the service with a facade or a test double; if it fires an event, you use Event::fake(). The result: fast tests that describe business logic, not web traffic. The Pest testing guide on this blog and the events and queues guides for Laravel 13 give you the rest of the context.

Conclusion

Actions, Services, and DTOs are neither mandatory nor mutually exclusive: they are the vocabulary for deciding where each piece of business logic lives in Laravel 13. Use an Action for the reusable operation, a Service for the cohesive group, and a DTO for the data contract, and let the container do the auto-wiring heavy lifting. Your next fat-controller refactor will start by moving the first responsibility into a named class, and the rest of the flow will follow on its own.

Categories