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.
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.
Data flows in a typed box
- Action
- A class with one public method (by convention
handle()orexecute()) 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.
From array soup to action + DTO
-
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); } -
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 anError: readonly properties can't change once set. That makes DTOs safe to pass around. -
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. -
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”. -
Reuse it from a job or another action
Because the action only needs a
Userand aPostData, 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 themPackages like
spatie/laravel-dataandlorisleiva/laravel-actionsadd extras (auto-validation, running actions as jobs). Plain PHP classes, as shown here, are enough to get all the core benefits.
Avoid this, do that
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.
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().
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.
final readonly class PostData
{
public function __construct(
public string $title,
public string $body,
) {}
}
Typed, immutable, no behaviour. Logic belongs in the action.
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.
- Create
final readonly class CommentDatawith promotedstring $bodyandint $postId, plus afromRequest()method. - Create
app/Actions/AddComment.phpwithhandle(User $user, CommentData $data): Comment. - Refactor
CommentController@storeto a 3-line method that uses both. - In
php artisan tinker, create aCommentDataand try to change$data->body. Read the error message. - Write a test that calls
app(AddComment::class)->handle(...)and asserts the comment exists in the database.
Key takeaways
- An action is one class for one use case, with a single
handle()orexecute()method. - A DTO is a typed, behaviour-free data carrier. Make it
final readonlyand use constructor promotion. - Build DTOs once at the edge (for example
PostData::fromRequest()), then pass them inward. - Actions are container-resolved, so they can inject their own dependencies.
- Controllers, jobs, commands and other actions can all reuse the same action.
- Pick one naming convention and don't wrap trivial one-liners just for the sake of it.