The Thin Controller Principle
By day 11 in your Laravel journey, you've likely built a few controllers. But if your controllers are stuffed with business logic, database queries, and validation rules, you're working against the framework's architecture. Laravel controllers done right: best practices explained centers on one fundamental principle: keep controllers thin. A thin controller acts as a traffic conductor, receiving requests, delegating work, and returning responses. Everything else belongs elsewhere.
We've seen production codebases where a single controller method runs 200+ lines of validation, data transformation, and side effects. When the next developer needs to understand the flow, debug an issue, or write tests, they're swimming through spaghetti. The cost compounds over weeks and months. Thin controllers prevent that pain.
Validate with Form Requests
The first step to a thin controller is removing validation logic from the controller method itself. Laravel's Form Request classes exist for exactly this reason. Instead of scattering validation rules through your controllers, centralize them in dedicated, reusable classes.
Consider a typical scenario: creating a blog post. Without Form Requests, you might write something like this:
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|unique:posts,slug',
'content' => 'required|string',
'category_id' => 'required|exists:categories,id',
'status' => 'in:draft,published',
]);
$post = Post::create($validated);
return redirect('/posts/' . $post->id);
}Now, with a Form Request, the controller becomes a one-liner:
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return redirect('/posts/' . $post->id);
}And the Form Request itself lives in app/Http/Requests/StorePostRequest.php:
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'slug' => 'required|string|unique:posts,slug',
'content' => 'required|string',
'category_id' => 'required|exists:categories,id',
'status' => 'in:draft,published',
];
}
public function messages(): array
{
return [
'slug.unique' => 'This slug is already taken. Choose another.',
];
}
}Form Requests also handle authorization via the authorize() method, so your controller doesn't need to check permissions either. They're testable in isolation, reusable across multiple endpoints, and they document what a request should look like before it reaches your business logic.
Delegate to Services or Actions
Once validation passes, the controller should hand off to a service or action class. This is where the actual work happens—creating records, calling external APIs, sending emails, updating cache. Your controller never touches these concerns directly.
Let's extend the blog post example. After validation, you need to create the post, generate a thumbnail, notify subscribers, and log an audit event. In a bloated controller, you'd chain all of that. In a well-architected controller, you delegate to a service:
public function store(StorePostRequest $request)
{
$post = $this->postService->create($request->validated());
return redirect('/posts/' . $post->id);
}The PostService owns the complexity:
class PostService
{
public function __construct(
private ThumbnailGenerator $thumbnails,
private SubscriberNotifier $notifier,
private AuditLog $audit,
) {}
public function create(array $data): Post
{
$post = Post::create($data);
$this->thumbnails->generate($post);
$this->notifier->notifyNewPost($post);
$this->audit->log('post.created', $post);
return $post;
}
}Alternatively, some teams prefer Action classes for single, focused operations. An action is lighter than a service and often encapsulates one discrete business process:
class CreatePostAction
{
public function __construct(
private ThumbnailGenerator $thumbnails,
private SubscriberNotifier $notifier,
) {}
public function execute(array $data): Post
{
$post = Post::create($data);
$this->thumbnails->generate($post);
$this->notifier->notifyNewPost($post);
return $post;
}
}
// In controller
public function store(StorePostRequest $request)
{
$post = app(CreatePostAction::class)->execute($request->validated());
return redirect('/posts/' . $post->id);
}Both patterns work. Choose based on your team's preference and the scope of work. Services are stateful and often manage multiple related operations; actions are atomic and procedural. Either way, the controller stays lean and testable.
Return API Resources
When building APIs, always return data through Resource classes instead of raw Eloquent models. This decouples your data layer from your API response format, giving you control over what fields to expose and how to transform them.
Without a Resource, you might return raw models:
public function show(Post $post)
{
return response()->json($post);
}This exposes everything—including timestamps, soft delete columns, and potentially sensitive data. If you need to modify the response format later, you have to touch the model or every controller that returns it. With a Resource, you're explicit and flexible:
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->content,
'category' => new CategoryResource($this->category),
'author' => [
'id' => $this->author->id,
'name' => $this->author->name,
],
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
// In controller
public function show(Post $post)
{
return new PostResource($post);
}
public function index()
{
return PostResource::collection(Post::paginate());
}Resources also let you conditionally include fields based on request context, apply transformations, and nest related data cleanly. Your controller becomes a simple bridge: fetch the data, wrap it in a Resource, return it. The formatting logic lives in a dedicated, testable class.
Single-Action Controllers for Complex Endpoints
Some endpoints are genuinely complex and warrant their own class. Instead of cramming everything into one method of a multi-action controller, create a single-action controller. This is especially useful for workflows with multiple steps, conditional logic, or state management.
Laravel supports single-action controllers via the __invoke() magic method:
class ExportPostsToCsvAction
{
public function __invoke(ExportPostsRequest $request)
{
$posts = Post::whereIn('status', $request->validated('statuses'))
->get();
$csv = $this->buildCsv($posts);
return response()->download(
storage_path('exports/' . $csv),
'posts.csv'
);
}
private function buildCsv(Collection $posts): string
{
// Complex CSV generation logic
}
}Register it in routes like any controller:
Route::post('/posts/export', ExportPostsToCsvAction::class);This approach shines when an endpoint's logic is substantial or rarely used elsewhere. It keeps that logic out of your main controller file and makes it independently testable. If you later need to call this logic from a command or queue job, you can inject dependencies and reuse the action class directly.
Common Mistakes
Putting all logic in the controller. Controllers aren't business logic containers. They orchestrate. Too many developers treat the controller as the home for everything, then wonder why tests are painful and refactoring is risky.
Creating overly broad services. If a service has 15 public methods doing unrelated things, it's probably doing too much. Services should have a clear, single responsibility. A PostService manages posts; a BillingService handles charges. Don't mix them.
Forgetting to test services separately. If you only test via controller integration tests, you miss edge cases and can't verify business logic in isolation. Write unit tests for your services and actions. They run fast and catch bugs early.
Returning models directly from API endpoints. Skipping Resources means you lose control over response format and accidentally expose fields you shouldn't. Always wrap API responses in Resources.
Validating in multiple places. If you validate in the controller and the service, you're duplicating effort and creating maintenance burden. Validate once, at the entry point, via Form Requests.
Key Takeaways
- Use Form Requests to centralize validation and authorization outside your controller.
- Delegate business logic to Service or Action classes; let controllers be orchestrators, not executors.
- Wrap all API responses in Resource classes to control data exposure and transformation.
- For genuinely complex endpoints, create single-action controllers using
__invoke(). - Test services and actions separately from controllers; unit tests catch bugs faster than integration tests.
- A thin controller is easy to read, modify, and test. It's the mark of a mature codebase.






Comments
Loading comments…