Why your Laravel controllers should be almost empty
Controllers are traffic cops, not brains. If yours are filled with logic, validation, and database calls, you're setting yourself up for pain. Learn why thin controllers matter and how to build better Laravel architecture.
Controllers are traffic cops, not brains. If yours are filled with logic, validation, and database calls, you're setting yourself up for pain. Here's why, and how to fix it.
I've seen it countless times: developers treating controllers like Swiss Army knives, cramming every piece of functionality into these poor, overworked classes. It starts innocently enough, just a quick validation here, a small database query there. But before you know it, your controllers have become these bloated monstrosities that nobody wants to touch.
Let me tell you why this approach will bite you in the ass, and more importantly, how to build something better.
The problem: Bloated controllers
It's easy to let controllers become a dumping ground for everything: data validation, business logic, database queries, and even view formatting. At first, it feels productive, as everything's in one place. But as your app grows, so do your headaches:
- Testing becomes a nightmare: You can't easily isolate logic for unit tests.
- Reusability drops: Logic buried in controllers can't be reused elsewhere.
- Readability suffers: Controllers balloon to hundreds of lines, making them hard to reason about.
- Onboarding slows down: New team members struggle to trace what's happening.
I remember inheriting a Laravel project where the main business logic Controller was over 800 lines long. Eight hundred! It handled everything from complex validation rules to sending emails, calculating analytics, and even generating PDFs. Want to test the email logic? Good luck - you'd have to mock the entire HTTP layer just to test a simple notification.
Controllers should delegate, not dominate
A controller's job is to receive a request, delegate the work, and return a response. That's it. When controllers do more, they violate the Single Responsibility Principle and turn into bottlenecks.
Think of a controller like a restaurant host. Their job isn't to cook your food, take your payment, or wash the dishes. They greet you, seat you, and coordinate with the right people to make your dining experience happen. The moment your host starts trying to be the chef, waiter, and cashier all at once, the whole system breaks down.
Bad example:
public function store(Request $request)
{
// Validation mixed with controller logic
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string|min:100',
'category_id' => 'required|exists:categories,id',
'tags' => 'array|max:5',
'publish_at' => 'nullable|date|after:now',
]);
// Business logic scattered throughout
if ($validated['publish_at'] && !auth()->user()->canSchedulePosts()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Direct database manipulation
$post = new Post();
$post->title = $validated['title'];
$post->body = $validated['body'];
$post->category_id = $validated['category_id'];
$post->user_id = auth()->id();
$post->slug = Str::slug($validated['title']);
$post->publish_at = $validated['publish_at'] ?? now();
$post->save();
// Tag handling
if (!empty($validated['tags'])) {
$tagIds = [];
foreach ($validated['tags'] as $tagName) {
$tag = Tag::firstOrCreate(['name' => $tagName]);
$tagIds[] = $tag->id;
}
$post->tags()->sync($tagIds);
}
// Notification logic
$followers = auth()->user()->followers;
foreach ($followers as $follower) {
$follower->notify(new NewPostNotification($post));
}
// Analytics tracking
Analytics::track('post_created', [
'user_id' => auth()->id(),
'post_id' => $post->id,
'category' => $post->category->name,
]);
return response()->json($post->load('category', 'tags'), 201);
}This controller is doing way too much. It's validating, creating records, handling relationships, sending notifications, and tracking analytics. What happens when you need to create posts from a CLI command? Or when the notification logic changes? You're stuck duplicating code or creating awkward dependencies.
The better way: Thin controllers, fat services
Here's how that same functionality should look when properly organized:
public function store(CreatePostRequest $request)
{
$post = $this->postService->createPost(
$request->validated(),
auth()->user()
);
return new PostResource($post);
}That's it. Thirteen lines become four. The controller now has one job: receive the request, delegate to the service, and return the response.
But where did all that logic go? Let me show you.
Breaking it down: The right architecture
1. Form requests handle validation
Instead of cluttering your controller with validation rules, use dedicated Form Request classes:
class CreatePostRequest extends FormRequest
{
public function rules()
{
return [
'title' => 'required|string|max:255',
'body' => 'required|string|min:100',
'category_id' => 'required|exists:categories,id',
'tags' => 'array|max:5',
'publish_at' => 'nullable|date|after:now',
];
}
public function authorize()
{
if ($this->input('publish_at')) {
return $this->user()->canSchedulePosts();
}
return true;
}
}Now your validation logic is isolated, testable, and reusable. Want to change the validation rules? You know exactly where to look.
2. Services handle business logic
The meat of your application logic belongs in service classes:
class PostService
{
public function __construct(
private PostRepository $postRepository,
private TagService $tagService,
private NotificationService $notificationService,
private AnalyticsService $analyticsService
) {}
public function createPost(array $data, User $user): Post
{
$post = $this->postRepository->create([
'title' => $data['title'],
'body' => $data['body'],
'category_id' => $data['category_id'],
'user_id' => $user->id,
'slug' => Str::slug($data['title']),
'publish_at' => $data['publish_at'] ?? now(),
]);
if (!empty($data['tags'])) {
$this->tagService->attachTagsToPost($post, $data['tags']);
}
$this->notificationService->notifyFollowersOfNewPost($post);
$this->analyticsService->trackPostCreation($post);
return $post;
}
}3. Repositories handle data access
Keep your database logic separate and testable:
class PostRepository
{
public function create(array $data): Post
{
return Post::create($data);
}
public function findBySlug(string $slug): ?Post
{
return Post::with(['category', 'tags', 'user'])
->where('slug', $slug)
->first();
}
}4. Resources handle response formatting
Use API resources to control how your data is presented:
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => Str::limit($this->body, 150),
'published_at' => $this->publish_at->toISOString(),
'category' => new CategoryResource($this->whenLoaded('category')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
}Why this approach wins
When I refactored that 800-line controller I mentioned earlier, the benefits were immediate and dramatic:
- Testing became trivial. I could unit test the post creation logic without touching HTTP at all. Need to test email notifications? Just test the
NotificationService. Want to verify analytics tracking? Test theAnalyticsServicein isolation. - Code reuse skyrocketed. That same post creation logic now worked from the web interface, API endpoints, CLI commands, and background jobs. No duplication, no weird workarounds.
- Debugging became sane. When something broke, I knew exactly where to look. Email not sending? Check the notification service. Tags not attaching? Look at the tag service. No more hunting through massive controller methods.
- Onboarding new developers became painless. The architecture was self-documenting. New team members could understand the system in days instead of weeks.
Common objections (and why they're wrong)
- "This is over-engineering for small apps.": No, it's not. This structure takes maybe five extra minutes to set up but saves hours down the road. Even small apps grow, and when they do, you'll be grateful for the clean foundation.
- "It's too many files.": Would you rather have ten small, focused files or one giant, incomprehensible blob? Modern IDEs make navigating multiple files trivial. Your future self will thank you.
- "It's harder to understand.": Initially, maybe. But once you grasp the pattern, it becomes much easier to reason about. Each class has a single, clear purpose. There's no mystery about where things belong.
The next evolution: From fat services to thin actions
Here's the thing nobody tells you about the service layer approach: it works great until it doesn't. You extract all that logic from your controllers, pat yourself on the back, and then six months later you're staring at a PostService that's 400 lines long with methods like createPost(), updatePost(), publishPost(), schedulePost(), duplicatePost(), and importPostsFromWordPress().
Sound familiar? I've been there. You solve the fat controller problem only to create fat services. It's like squeezing a balloon - the problem just moves somewhere else.
When services become the problem
Let me show you what I mean. Here's a "thin" service that started simple but grew into a monster:
class PostService
{
public function createPost(array $data, User $user): Post
{
// 50 lines of post creation logic
}
public function updatePost(Post $post, array $data, User $user): Post
{
// 40 lines of update logic
}
public function publishPost(Post $post, User $user): void
{
// 30 lines of publishing logic
}
public function schedulePost(Post $post, Carbon $publishAt, User $user): void
{
// 25 lines of scheduling logic
}
public function duplicatePost(Post $post, User $user): Post
{
// 35 lines of duplication logic
}
public function importFromWordPress(array $wpData, User $user): Collection
{
// 60 lines of import logic
}
public function generateSocialMediaVariants(Post $post): Collection
{
// 45 lines of social media logic
}
// ... and it keeps growing
}This service has the same problems our fat controllers had:
- It's doing too many different things
- Testing becomes complex because you need to mock tons of dependencies
- Different team members are constantly stepping on each other's toes
- Finding specific logic is like hunting for a needle in a haystack
Enter single-purpose actions
The solution? Break each operation into its own dedicated action class. Each action has one job and does it well.
class CreatePost
{
public function __construct(
private PostRepository $postRepository,
private TagService $tagService,
private NotificationService $notificationService,
private AnalyticsService $analyticsService
) {}
public function handle(array $data, User $user): Post
{
$post = $this->postRepository->create([
'title' => $data['title'],
'body' => $data['body'],
'category_id' => $data['category_id'],
'user_id' => $user->id,
'slug' => Str::slug($data['title']),
'publish_at' => $data['publish_at'] ?? now(),
]);
if (!empty($data['tags'])) {
$this->tagService->attachTagsToPost($post, $data['tags']);
}
$this->notificationService->notifyFollowersOfNewPost($post);
$this->analyticsService->trackPostCreation($post);
return $post;
}
}Now your controller becomes even cleaner:
public function store(CreatePostRequest $request)
{
$post = app(CreatePost::class)->handle(
$request->validated(),
auth()->user()
);
return new PostResource($post);
}The action pattern in practice
Each complex operation gets its own action class. Want to publish a post? There's PublishPost. Need to duplicate a post? Use DuplicatePost. Importing from WordPress? ImportPostFromWordPress has you covered.
class PublishPost
{
public function __construct(
private PostRepository $postRepository,
private SocialMediaService $socialMediaService,
private SearchIndexService $searchService
) {}
public function handle(Post $post, User $user): void
{
if (!$user->canPublishPost($post)) {
throw new UnauthorizedException('Cannot publish this post');
}
$this->postRepository->markAsPublished($post);
$this->socialMediaService->sharePost($post);
$this->searchService->indexPost($post);
}
}
class DuplicatePost
{
public function __construct(
private PostRepository $postRepository,
private TagService $tagService
) {}
public function handle(Post $originalPost, User $user): Post
{
$duplicatedPost = $this->postRepository->create([
'title' => $originalPost->title . ' (Copy)',
'body' => $originalPost->body,
'category_id' => $originalPost->category_id,
'user_id' => $user->id,
'slug' => Str::slug($originalPost->title . ' copy ' . time()),
'status' => 'draft',
]);
$this->tagService->copyTagsToPost($originalPost, $duplicatedPost);
return $duplicatedPost;
}
}Why actions are game-changing
I'll be honest - when I first encountered the action pattern, I was skeptical. It felt like over-engineering. But after using it on several projects, I'm completely sold. Here's why:
Laser-focused testing. Each action tests exactly one thing. No more massive test files trying to cover every possible scenario of a bloated service. Need to test post duplication? Just test DuplicatePost. Simple.
Zero conflicts during development. When Sarah is working on the publishing feature and Mike is implementing WordPress import, they're not touching the same file. No more merge conflicts, no more stepping on each other's toes.
Dependency clarity. Each action explicitly declares what it needs. The CreatePost action needs repositories and services for post creation. The PublishPost action needs different dependencies. No guessing, no hidden coupling.
Reusability through composition. Need to create and immediately publish a post? Compose two actions:
public function createAndPublish(CreatePostRequest $request)
{
$post = app(CreatePost::class)->handle(
$request->validated(),
auth()->user()
);
app(PublishPost::class)->handle($post, auth()->user());
return new PostResource($post);
}Advanced action patterns
Once you're comfortable with basic actions, you can get fancy:
Queued Actions for heavy operations:
class ImportPostFromWordPress implements ShouldQueue
{
public function handle(array $wpData, User $user): Post
{
// Heavy import logic that runs in the background
}
}
// Usage
dispatch(new ImportPostFromWordPress($wpData, $user));Transactional Actions for operations that must be atomic:
class CreatePost
{
public function handle(array $data, User $user): Post
{
return DB::transaction(function () use ($data, $user) {
// All the creation logic wrapped in a transaction
});
}
}Conditional Actions using the Strategy pattern:
class ProcessPost
{
public function handle(Post $post, string $action): void
{
$actionClass = match($action) {
'publish' => PublishPost::class,
'schedule' => SchedulePost::class,
'archive' => ArchivePost::class,
default => throw new InvalidArgumentException("Unknown action: $action")
};
app($actionClass)->handle($post, auth()->user());
}
}Making the transition from services to actions
Don't try to refactor everything at once. Start by identifying the most complex methods in your services, then extract them into actions:
- Find the biggest methods - Look for methods over 20-30 lines
- Extract the most complex logic - Operations with lots of dependencies or business rules
- Start with new features - Build new functionality as actions from the start
- Gradually migrate existing code - When you need to modify a service method, consider extracting it
The beautiful thing about actions is that they're backwards compatible. You can have services that delegate to actions while you're transitioning:
class PostService
{
public function createPost(array $data, User $user): Post
{
// Delegate to the action
return app(CreatePost::class)->handle($data, $user);
}
// Other methods can stay as-is during the transition
}Making the transition
If you're working with existing bloated controllers, don't try to refactor everything at once. Start with the most problematic controllers and extract one piece at a time:
- Start with validation: Move validation rules to Form Requests
- Extract repositories: Pull database queries into repository classes
- Create services: Move business logic to service classes
- Add resources: Clean up response formatting
Each step makes your code incrementally better, and you can do it without breaking existing functionality.
The bottom line
Fat controllers are a liability. They make your code harder to test, harder to understand, and harder to maintain. By keeping controllers thin and properly delegating responsibilities, you create a codebase that's actually enjoyable to work with.
Your controllers should be boring. They should handle the HTTP stuff and get out of the way. When someone opens a controller file, they should immediately understand what's happening without scrolling through hundreds of lines of mixed concerns.
Trust me on this one. I've been down the fat controller road, and it's not pretty. Save yourself the headache and build it right from the start. Your team, your tests, and your sanity will thank you.