Use Actions to Keep Your Laravel Controllers Thin
Use Actions to Keep Your Laravel Controllers Thin
Controllers tend to grow fast. It always starts clean, but then requirements stack up. A few conditionals, a call to an external service, maybe a notification or a queue dispatch, and suddenly your controller is a maze.
I've run into this enough times that I started looking for a cleaner way to handle business logic. That's when I found the Laravel Action pattern. I picked it up from Nuno Maduro, and it immediately made sense. Since then, I've been using it in most of my Laravel projects, and it's become a core part of how I structure my apps.
What is the Laravel Action pattern?
An Action is a single-purpose class that handles one operation. Create a post. Update a profile. Delete a record.
You give it a clear name, like CreatePostAction
, and define a handle()
method inside. That method receives the needed input and performs the operation. No more, no less.
All the logic for that action lives in one place. Your controller just delegates to it. This keeps your controllers thin and your business logic easier to test, reason about, and reuse.
Why it’s worth using
- Keeps controllers focused – Your controller methods shrink to just handling requests and returning responses
- Reusable logic – Call the same Action from a controller, console command, or queued job
- Easier to test – You can test an Action like any other class, without touching the HTTP layer
- Clear organization –
CreatePostAction
,UpdatePostAction
,DeletePostAction
tell you exactly what they do - Safe by default – If the operation involves multiple steps, you can wrap it in a transaction to ensure consistency
- Centralized side effects – If an action triggers events or jobs, it does so inside the class itself
This pattern creates clear boundaries between different parts of your app and encourages good habits by default.
Example: Blog Post Actions
Let’s say we’re building a simple blog. You’ve got a Post
model and want to create, update, and delete posts. Here’s how that looks using Actions.
Note: You don’t need to use
DB::transaction()
unless multiple writes are happening inside the action. For example, creating a post and logging an audit trail or dispatching a sync operation to another system. Don’t wrap a single write in a transaction—it adds overhead with no benefit.
CreatePostAction
final readonly class CreatePostAction
{
public function handle(array $data): Post
{
return Post::create([
'title' => $data['title'],
'content' => $data['content'],
'user_id' => $data['user_id'],
]);
}
}
UpdatePostAction
final readonly class UpdatePostAction
{
public function handle(Post $post, array $data): Post
{
$post->update($data);
return $post->refresh();
}
}
DeletePostAction
final readonly class DeletePostAction
{
public function handle(Post $post): void
{
$post->delete();
}
}
The Controller
Your controller stays clean and easy to read:
class PostController extends Controller
{
public function store(
StorePostRequest $request,
CreatePostAction $create
): RedirectResponse {
$post = $create->handle(
$request->validated() + ['user_id' => $request->user()->id]
);
return redirect()->route('posts.show', $post);
}
public function update(
UpdatePostRequest $request,
Post $post,
UpdatePostAction $update
): RedirectResponse {
$update->handle($post, $request->validated());
return back()->with('status', 'Post updated.');
}
public function destroy(
Post $post,
DeletePostAction $delete
): RedirectResponse {
$delete->handle($post);
return redirect()->route('posts.index')->with('status', 'Post deleted.');
}
}
There’s no guesswork here. Each method delegates to an Action. The Action handles everything else. This makes it easier to read, maintain, and update later.
Important clarification
Actions are for mutations. They should be used for operations that change application state: create, update, delete, or trigger a side effect.
You should not use Actions for read-only operations like index() or show(). If you're just retrieving data, keep that logic inside the controller or move it to a dedicated query class or ViewModel. Actions are designed for writes and side effects, not reads.