Stop Writing Laravel API Endpoints the Old Way — Here’s the Pattern Senior Devs Actually Use…
“Congratulations — you just wrote a controller that does everything except make chai.” That was the comment my senior left on my pull…
Stop Writing Laravel API Endpoints the Old Way — Here’s the Pattern Senior Devs Actually Use (Updated for Laravel 13)
“Congratulations — you just wrote a controller that does everything except make chai.” That was the comment my senior left on my pull request. I laughed. Then I looked at my 530-line controller and stopped laughing.
Every controller method was doing validation, authorization, business logic, database writes, sending emails, and firing events — all in one place. It worked. But the moment a product requirement changed — and in SaaS, they always change — I was touching the same function five times and breaking things I hadn’t intended to touch.
That PR comment was embarrassing. This article is everything I learned, written the way I wish someone had explained it to me — plus every improvement available to you right now if you’re on Laravel 13.
What a “fat controller” actually looks like
Let me show you a real example. This is a simplified version of what my UserController@store looked like. I have seen this pattern in almost every Laravel codebase I have ever joined:
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
'role' => 'required|in:admin,editor,viewer',
]);
if (!auth()->user()->can('create-users')) {
return response()->json(['error' => 'Forbidden'], 403);
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role'=> $request->role,
]);
Mail::to($user->email)->send(new WelcomeMail($user));
event(new UserCreated($user));
if ($request->has('team_id')) {
$user->teams()->attach($request->team_id, [
'role' => $request->role
]);
}
return new UserResource($user);
}
This works. I want to be honest about that — it works perfectly fine for small apps. But ask yourself: what happens when your product manager says “when creating a user from the admin panel, we also need to create a billing profile, but not when creating via the public API”? Suddenly you are adding if-else logic into an already messy method. What happens when you need to create a user from an Artisan command? You cannot reuse a controller method cleanly. What about unit testing just the user-creation logic, without booting the HTTP layer?
A controller’s only job is to receive an HTTP request, hand it off to something that knows what to do, and return a response. That’s it. If your controller knows how to hash a password, it’s doing too much.
The Action Pattern —
The Action pattern is deceptively simple: one class, one job, one public method called handle(). Every piece of business logic lives in its own dedicated class inside an app/Actions/ folder. The controller just calls the action. The action does not know or care whether it was called from an HTTP request, a CLI command, or a test.
Here is what the folder structure looks like in a real project:

Now let me show you what the refactored version looks like — same feature, completely different structure.
Step 1 — Move validation to a Form Request
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create-users');
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'min:8', 'confirmed'],
'role' => ['required', 'in:admin,editor,viewer']
];
}
}
Validation and authorization are now completely decoupled from the controller. You can test this class in total isolation. Laravel automatically handles the 422 response if validation fails and the 403 if authorize() returns false — your controller never even sees a bad request.
Step 2 — Write the Action class
class CreateUser
{
public function handle(string $name,string $email,string $password,string $role,?int $teamId = null): User {
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
'role' => $role,
]);
if ($teamId) {
$user->teams()->attach($teamId, ['role' => $role]);
}
event(new UserCreated($user));
return $user;
}
}
This class has no idea it was called from an HTTP request. It receives plain PHP values, does its job, and returns a User model. You can call this from a controller, an Artisan command, a seeder, a queue job, or a test — and the business logic is always identical.
Step 3 — Make the controller embarrassingly thin
class UserController extends Controller
{
public function store(StoreUserRequest $request,CreateUser $action): UserResource
{
$user = $action->handle(
name: $request->name,
email: $request->email,
password: $request->password,
role: $request->role,
teamId: $request->team_id,
);
return new UserResource($user);
}
}
That is it. ~14 lines. The controller receives a validated request, hands the data to the action, and returns an API Resource. It does not know how a user is created. It does not know what events fire. It does not send emails. It just orchestrates.
Laravel’s IoC container does the heavy lifting here. When you type-hint CreateUser $action in the controller method signature, Laravel automatically instantiates and injects it for you. No new CreateUser() anywhere. This also means you can swap it for a mock in tests trivially.
Reusing the same action from an Artisan command
This is where the pattern really proves its value. Six weeks after building the registration flow, your boss says: “Can we bulk-import users from a CSV? We are migrating from the old system.”
Old approach: copy-paste the controller logic into a command, or worse, call a URL internally. New approach:
class ImportUsersFromCsv extends Command
{
protected $signature = 'users:import {file}';
public function handle(CreateUser $action): int
{
$rows = array_map('str_getcsv', file($this->argument('file')));
foreach ($rows as $row) {
$action->handle(
name: $row[0],
email: $row[1],
password: Str::random(12),
role: $row[2] ?? 'viewer',
);
$this->info("Created: {$row[1]}");
}
return true;
}
}
Same action. Same business logic. Same events firing. Zero code duplication. This is what “reusable” actually means in practice — not reusable in theory, but reusable in the moment you need it most.
Honest caveats — this pattern is not for everything
I want to be fair here, because some articles make it sound like this pattern is the answer to all of life’s problems. It is not.
When to skip the Action pattern: if you are building a CRUD-only admin panel where each endpoint maps to exactly one Eloquent operation — like a simple User::update() with no side effects — extracting an Action class is over-engineering. The pattern earns its weight when your business logic has multiple steps, side effects (emails, events, queues), or needs to be called from more than one place.
Green lights for using Action classes: creating or updating anything with related records, operations that fire events or send notifications, any logic you want to call from CLI or queue jobs, anything you want to unit test in isolation, any operation that could vary by context (API vs admin panel vs CSV import).
When every layer knows what it is responsible for and nothing else, something shifts: you stop being afraid to change things. Adding a new endpoint takes 10 minutes because you just write a new controller method that calls an existing action. Changing business logic is one file. Debugging production is one file. Writing a test is one file.
Try it on your next PR. See how it feels.
If this article saved you from a fat controller, share it with your team. And if you have been using a different pattern to solve the same problem — or have already started using Laravel 13 Attributes in production — I would genuinely love to hear about it in the comments. Thanks :)
메타데이터
- post_id
- 52054649ee95
- slug
- stop-writing-laravel-api-endpoints-the-old-way-heres-the-pattern-senior-devs-actually-use-52054649ee95
- url
- https://medium.com/@sartaj.2009/stop-writing-laravel-api-endpoints-the-old-way-heres-the-pattern-senior-devs-actually-use-52054649ee95
- canonical_url
- https://medium.com/@sartaj.2009/stop-writing-laravel-api-endpoints-the-old-way-heres-the-pattern-senior-devs-actually-use-52054649ee95
- author_url
- https://medium.com/@sartaj.2009
- status
- ok
- fetched_at
- 2026-06-18 07:02:39