A Laravel car-rental app's RentalController::store validates input, checks availability, prices the booking, charges the card and emails the customer; how would you slim it?
answer
- controller = HTTP adapter only
- type-hinted form request for input
- policy for who may book
- CreateRental action class via the container
- queued mail after the booking commits
basics
~20 sKeep only HTTP work in the controller: a form request validates, a policy authorizes, an injected CreateRental action class runs availability, pricing and payment in one transaction, a queued notification emails, and store returns a redirect.
solid answer
~40 sI would make `store` an **HTTP adapter**: take input, call the application, shape the response. Validation moves to a type-hinted **form request**, and the "may this user book" check to a **policy**. The booking workflow (availability, pricing, charging) moves into a plain `CreateRental` action class that the container injects into `store`; it receives validated data, not the `Request`, runs the database writes in one transaction, and can be reused from an Artisan command, a job or a test. The confirmation email becomes a **queued** mail or notification sent after the booking is committed. What remains in `store` is about five lines. Endpoints that are verbs rather than CRUD, like returning a car, become invokable controllers that call their own action classes.
code
php · 34 lines<?php
namespace App\Actions;
use App\Models\Car;
use App\Models\Rental;
use App\Models\User;
use App\Notifications\RentalConfirmed;
use App\Services\Pricing;
use Illuminate\Support\Facades\DB;
class CreateRental
{
public function __construct(private Pricing $pricing)
{
}
public function handle(User $user, array $data): Rental
{
$rental = DB::transaction(function () use ($user, $data) {
$car = Car::lockForUpdate()->findOrFail($data['car_id']);
$car->ensureAvailable($data['from'], $data['to']);
return $user->rentals()->create([
...$data,
'total' => $this->pricing->quote($car, $data['from'], $data['to']),
]);
});
$user->notify(new RentalConfirmed($rental));
return $rental;
}
}go deeper
Recognise that controllers should call other classes rather than hold business rules, and name form requests as the place for validation.
Lay out which Laravel feature takes each concern and write the slim store method with an injected action class.
Place the transaction boundary and after-commit side effects correctly, avoid proxy services, and justify where extraction stops.
Define the team's application-layer convention (actions, services, invokable controllers) and how it is enforced in review and architecture tests.
## What is wrong with the fat version A **fat controller** is an action that does the application's work itself instead of delegating it. The `store` method here mixes five responsibilities: 1. reading and validating HTTP input; 2. deciding whether the user may book; 3. the business rules: is the car free, what does it cost; 4. talking to a payment provider; 5. side effects: sending an email. The costs are concrete. The booking logic cannot be reused from an Artisan command that imports partner bookings or from a queued job; tests must go through HTTP to exercise pricing; a slow mail server makes the booking request slow; and every change to pricing touches a controller. ## What each piece moves to | Responsibility | Laravel home | Controller keeps | |---|---|---| | Input rules | A **form request** type-hinted in `store` (`make:request StoreRentalRequest`) | Nothing, validation runs before the action body | | Permission to book | A **policy** method, enforced by the authorization attribute or middleware | Nothing, or one authorization line | | Availability, pricing, charging | A plain **action class**, `CreateRental`, injected by the container | One call | | Confirmation email | A **queued** mailable or notification | Nothing, the action dispatches it | | Response | Redirect, view or JSON resource | The return statement | ## The action class An **action class** is an ordinary PHP class with one public method, `handle()` or `__invoke()`. Laravel has no special base class for it; `php artisan make:class Actions/CreateRental` scaffolds an empty class. Because the controller type-hints it, the **service container** builds it and its own dependencies (a pricing service, a payment gateway client). Rules that keep the split honest: - Pass **validated data** (an array or a small data object), never the `Request`; the action must run where there is no HTTP request. - Put the **transaction boundary** in the action, so availability check and insert commit together. - Dispatch side effects **after commit**, so a rolled-back booking never emails a customer. - Return a domain result (the `Rental` model) and let the controller decide how to present it. ## The slim controller ```php public function store(StoreRentalRequest $request, CreateRental $createRental): RedirectResponse { $rental = $createRental->handle($request->user(), $request->validated()); return to_route('rentals.show', $rental); } ``` Non-CRUD verbs, such as returning or extending a rental, get their own **single-action controllers** (`ReturnRental`, `ExtendRental`) that call `CloseRental` or `ExtendRental` actions, instead of piling extra public methods onto `RentalController`. ## Mistakes when slimming - **Proxy services**: a `RentalService` whose methods each wrap one Eloquent call adds indirection and no behaviour. Extract where there is a workflow, not everywhere. - **Logic in the base controller or traits**: the skeleton's base `Controller` is empty on purpose; shared helpers there couple every controller to them. - **Passing `$request` down** turns the action into a controller in disguise. - **Moving validation into the action** duplicates the form request and loses its automatic error response. - **Sending mail synchronously** inside the transaction ties the request latency and the commit to the mail server. ## A migration path for an existing fat controller 1. Write a feature test for the current `store` behaviour first, so the refactor has a safety net. 2. Move validation rules into a form request and type-hint it; the test should still pass. 3. Extract the booking steps into `CreateRental` unchanged, then inject it into `store`. 4. Move the email to a queued notification dispatched after the transaction. 5. Split non-CRUD endpoints into invokable controllers one at a time, each with its own route and test. Each step is a small, reviewable change, and none of them alters the HTTP contract that clients see. ## How an interviewer judges the answer - Does the candidate name **Laravel's** homes for each concern (form request, policy, container-injected class, queued mail) rather than generic layers? - Do they place the **transaction** and **side effects** correctly? - Do they stop before over-engineering, keeping simple CRUD actions simple?
- Why should CreateRental receive an array instead of the Request object?The action must run from an Artisan command, a queued job or a unit test, where there is no HTTP request. Passing validated data makes its inputs explicit, keeps validation in one place, and stops the action from reading unvalidated fields.
- When is extracting an action class not worth it?When the controller action is a single Eloquent call, such as listing cars or deleting a rental with no side effects. Wrapping that in a service or action adds a file and an indirection without behaviour; extract when there is a workflow, reuse or logic worth testing on its own.
saying these in an interview costs you the question
- A thin controller means moving all the code into the base Controller class.
- The action class should receive the Request so it can read whatever it needs.
- Sending the confirmation email inside the transaction is fine because it is quick.
- Every Eloquent call deserves its own service method to keep controllers thin.
- Validation belongs inside the action so every caller is checked the same way.