In one sentence

An action is a class that does exactly one business task (like “publish a post”), and a DTO is a small typed object that carries the data that task needs.

📦A DTO is a labelled shipping box: everything inside has a name and a type, so nothing gets lost. The action is the one worker whose only job is to open that kind of box and do one thing with it.

Why it matters

Small, named, typed pieces

🔎

Find logic by name

A folder of PublishPost, RefundOrder, InviteTeamMember reads like a list of everything your app can do.

🛡️

No more mystery arrays

A DTO replaces array $data (“which keys does it have?”) with typed properties your editor can autocomplete and check.

🧩

Compose and reuse

Controllers, jobs, commands and other actions can all call the same action with the same DTO.

The big picture

Data flows in a typed box

📨Form Requestvalidated input
📦DTOPostData (readonly)
⚡ActionPublishPost::handle()
📝Resulta Post model
The request is turned into a DTO once. From there on, every layer works with typed properties, not loose arrays.
Action
A class with one public method (by convention handle() or execute()) that performs one use case.
DTO
Data Transfer Object: a class with only typed properties and no behaviour, used to move data between layers.
readonly
A PHP 8.2 class modifier: every property can be set once (in the constructor) and never changed after.
Constructor promotion
Declaring properties directly in the constructor's parameters, so you don't write them twice.
Step by step

From array soup to action + DTO

  1. The “before”: a fat controller with an untyped array

    Every line below depends on array keys that nothing checks. A typo like $data['tittle'] fails only at runtime.

    
              public function store(StorePostRequest $request)
              {
                  $data = $request->validated();
    
                  $post = $request->user()->posts()->create([
                      'title' => $data['title'],
                      'slug' => Str::slug($data['title']),
                      'body' => $data['body'],
                      'category_id' => $data['category_id'],
                      'published_at' => ($data['publish_now'] ?? false) ? now() : null,
                  ]);
                  $post->tags()->sync($data['tags'] ?? []);
    
                  return redirect()->route('posts.show', $post);
              }
            
  2. Create a readonly DTO with constructor promotion

    The whole class is readonly, and each property is declared once, in the constructor. A small named constructor builds it from a Form Request.

    
              <?php
    
              namespace App\Data;
    
              use App\Http\Requests\StorePostRequest;
    
              final readonly class PostData
              {
                  public function __construct(
                      public string $title,
                      public string $body,
                      public int $categoryId,
                      public bool $publishNow = false,
                      public array $tagIds = [],
                  ) {}
    
                  public static function fromRequest(StorePostRequest $request): self
                  {
                      return new self(
                          title: $request->validated('title'),
                          body: $request->validated('body'),
                          categoryId: (int) $request->validated('category_id'),
                          publishNow: $request->boolean('publish_now'),
                          tagIds: $request->validated('tags', []),
                      );
                  }
              }
            

    Try $data->title = 'x' and PHP throws an Error: readonly properties can't change once set. That makes DTOs safe to pass around.

  3. Create a single-action class

    One class, one public method. Its name is a verb phrase describing the use case.

    
              <?php
    
              namespace App\Actions;
    
              use App\Data\PostData;
              use App\Models\Post;
              use App\Models\User;
              use Illuminate\Support\Facades\DB;
              use Illuminate\Support\Str;
    
              class PublishPost
              {
                  public function handle(User $author, PostData $data): Post
                  {
                      return DB::transaction(function () use ($author, $data) {
                          $post = $author->posts()->create([
                              'title' => $data->title,
                              'slug' => Str::slug($data->title),
                              'body' => $data->body,
                              'category_id' => $data->categoryId,
                              'published_at' => $data->publishNow ? now() : null,
                          ]);
                          $post->tags()->sync($data->tagIds);
    
                          return $post;
                      });
                  }
              }
            

    Some teams call the method execute(), others use __invoke(). The name matters less than using one convention everywhere.

  4. Wire it into a thin controller

    
              public function store(StorePostRequest $request, PublishPost $publish)
              {
                  $post = $publish->handle($request->user(), PostData::fromRequest($request));
    
                  return redirect()->route('posts.show', $post);
              }
            

    The container injects PublishPost (and anything it needs in its own constructor). The controller is back to “validate, delegate, respond”.

  5. Reuse it from a job or another action

    Because the action only needs a User and a PostData, anything can call it: an import job, a seeder, or a bigger action.

    
              public function handle(PublishPost $publish): void
              {
                  foreach ($this->rows as $row) {
                      $publish->handle($this->author, new PostData(
                          title: $row['title'],
                          body: $row['body'],
                          categoryId: (int) $row['category_id'],
                      ));
                  }
              }
            
    ℹ️ Packages exist, but you don't need them

    Packages like spatie/laravel-data and lorisleiva/laravel-actions add extras (auto-validation, running actions as jobs). Plain PHP classes, as shown here, are enough to get all the core benefits.

Common mistakes

Avoid this, do that

❌ Avoid

          class PostActions
          {
              public function create() { /* ... */ }
              public function update() { /* ... */ }
              public function delete() { /* ... */ }
          }
        

An “action” with many methods is just a service with a different name, and it loses the one-class-one-job clarity.

✅ Do

          app/Actions/Posts/CreatePost.php
          app/Actions/Posts/UpdatePost.php
          app/Actions/Posts/DeletePost.php
        

One file per use case, each with a single handle().

❌ Avoid

          class PostData
          {
              public $title;
              public $body;

              public function save() { /* ... */ }
          }
        

Untyped, mutable properties plus behaviour: this isn't a DTO anymore, it's a half-model.

✅ Do

          final readonly class PostData
          {
              public function __construct(
                  public string $title,
                  public string $body,
              ) {}
          }
        

Typed, immutable, no behaviour. Logic belongs in the action.

💡 Pro tip: don't wrap everything

A DTO for a single string $email is overkill. Use DTOs when data has several fields or passes through several layers. Likewise, a trivial $post->delete() doesn't need a DeletePost action unless it also has side effects.

🏋️ Try it yourself
  1. Create final readonly class CommentData with promoted string $body and int $postId, plus a fromRequest() method.
  2. Create app/Actions/AddComment.php with handle(User $user, CommentData $data): Comment.
  3. Refactor CommentController@store to a 3-line method that uses both.
  4. In php artisan tinker, create a CommentData and try to change $data->body. Read the error message.
  5. Write a test that calls app(AddComment::class)->handle(...) and asserts the comment exists in the database.
Recap

Key takeaways

Check yourself

Mini quiz