skip to content

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?

level: seniorimportance: should knowfreq 55%

answer

  1. controller = HTTP adapter only
  2. type-hinted form request for input
  3. policy for who may book
  4. CreateRental action class via the container
  5. queued mail after the booking commits

basics

~20 s

Keep 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 s

I 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
<?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

for a junior

Recognise that controllers should call other classes rather than hold business rules, and name form requests as the place for validation.

for a middle

Lay out which Laravel feature takes each concern and write the slim store method with an injected action class.

for a senior

Place the transaction boundary and after-commit side effects correctly, avoid proxy services, and justify where extraction stops.

for a principal

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.