Here's my entire user-management controller. Five endpoints, and the longest method is four lines:
class UserController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$users = User::query()
->latest()
->paginate(15)
->withQueryString();
return UserResource::collection($users);
}
public function store(StoreUserRequest $request): JsonResponse
{
$user = User::create($request->validated());
return response()->json(['user' => new UserResource($user)], 201);
}
public function show(User $user): JsonResponse
{
return response()->json(['user' => new UserResource($user)]);
}
public function update(UpdateUserRequest $request, User $user): JsonResponse
{
$user->update($request->validated());
return response()->json(['user' => new UserResource($user)]);
}
public function destroy(Request $request, User $user): JsonResponse
{
if ($request->user()->is($user)) {
return response()->json(['message' => 'You cannot delete your own account.'], 422);
}
$user->tokens()->delete();
$user->delete();
return response()->json(null, 204);
}
}
Nothing in there validates. Nothing shapes the response. Nothing looks up a user by ID. Three Laravel features absorb all of it.
Route-model binding: the lookup disappears
show(User $user) — no User::findOrFail($id), no 404 handling. The router resolves the {user} parameter to a model or answers 404 before the method runs. The controller never thinks about "what if the ID is garbage" because it never sees an ID.
FormRequests: the validation lives next door
store(StoreUserRequest $request) — by the time this method runs, the request is already valid. $request->validated() returns only the fields the request class allowed. The controller's contract is simple: validated data in, model out.
This split pays off hardest on update, where the rules get subtle — sometimes, unique-except-self, the email that must stay unique unless it's your email. That logic is fiddly and deserves its own file with its own tests, not three lines wedged between route handling and response building. (I wrote about those update rules separately — they're the most careful validation in the codebase.)
Resources: the response shape is a decision, not an accident
new UserResource($user) — the controller never returns a model. What goes over the wire is a deliberate selection: id, name, email, role, timestamps. The password hash never leaves the server, not because the controller remembered to hide it, but because the controller can't leak it — it doesn't build responses at all.
This is the same reason every endpoint answers {'user': ...}. The shape is decided in one place, so the frontend's TypeScript types match every endpoint without me checking five methods.
What the controller still owns
It's not empty — it owns the decisions. destroy has the one piece of real logic in the file: an admin can't delete their own account. And note the ordering: the self-check happens before $user->tokens()->delete(). Check first, mutate after — the guard is worthless if the tokens are already gone when it fires. That's a sequencing decision, and the controller is exactly the right place for it.
The test
Read any method in under ten seconds? It does one thing. When a controller method needs scrolling, ask which of the three features should have absorbed the extra work. Usually the answer is obvious — and the refactor is moving code into a file that already exists, not inventing a new pattern.
This is the architecture in my Laravel + React SaaS Starter Kit ($19 one-time) — thin controllers, FormRequest validation, API resources, and role-based access, ready to build on.
Top comments (0)