Here’s a bug I look for in every checkout controller I open.
The customer’s card gets charged. The order never lands in the database. Support hears about it before you do.
Nobody writes that bug on purpose. It shows up because of where the code sits. A payment call inside a transaction, an email after it, and one exception is all it takes.
Laravel 12 won’t stop you. It was a maintenance release, mostly dependency updates and new starter kits, and it didn’t get any stricter about where your code lives. There’s still no app/Services or app/Actions folder out of the box.
So the structure is on you. This guide is how I make those calls: what belongs in a Form Request, an action, a service, or a queued listener. We’ll refactor one real checkout controller, then add the tests and CI checks that stop it all from drifting back. Everything here works on Laravel 11 too.
1. Check What Laravel Already Gives You
Before you add a single folder, look at what’s already there.
Laravel 12 kept the slim skeleton from Laravel 11. Routing, middleware, and exception handling live in bootstrap/app.php. Folders like app/Events, app/Jobs, and app/Policies only appear when you generate something that belongs in them.
There’s no make:action or make:service command, and you don’t need one. php artisan make:class Actions/Orders/PlaceOrder gives you a plain class in the right place. make:interface does the same for contracts.
Here’s the thing: most fat controllers are fat because of work the framework already has a home for.
| Code | Where it belongs | Why |
| Input shape, types, required fields | Form Request | Runs before your code. HTTP only. |
| “Is this user allowed to do this?” | Policy, called from the Form Request’s authorize() | One place to audit permissions |
| Rules that must hold everywhere (stock, allowed status changes) | Action | Form Requests don’t run for jobs, commands, or imports |
| One business operation (place order, issue refund, invite member) | Action | Reusable from web, API, jobs, and commands |
| Talking to an external system (payments, tax, shipping) | Service behind an interface | Can be faked in tests and swapped later |
| Reusable query conditions | Eloquent scope | Keeps query logic on the model |
| Formatting or converting a column | Cast or accessor | Applied every time the model is read |
| Slow side effects (email, webhooks, PDFs) | Queued listener or job | Keeps the request fast |
| Which implementation or config value to inject | Service provider, #[Bind], or contextual attributes | Wiring stays out of business code |
2. The Fat Controller, and What’s Really Wrong With It
This checkout controller shows up in a lot of Laravel apps. It works. It would pass a quick review.
public function store(Request $request)
{
$request->validate([
'items' => 'required|array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'payment_method_id' => 'required',
]);
DB::beginTransaction();
try {
$order = Order::create(['user_id' => auth()->id(), 'status' => 'pending', 'total_cents' => 0]);
$total = 0;
foreach ($request->items as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
DB::rollBack();
return back()->withErrors(['items' => "{$product->name} is out of stock"]);
}
$product->decrement('stock', $item['quantity']);
$order->lines()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'unit_price_cents' => $product->price_cents,
]);
$total += $product->price_cents * $item['quantity'];
}
$order->update(['total_cents' => $total]);
$stripe = new StripeClient(config('services.stripe.secret'));
$intent = $stripe->paymentIntents->create([
'amount' => $total,
'currency' => 'usd',
'payment_method' => $request->payment_method_id,
'confirm' => true,
'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'],
]);
$order->update(['status' => 'paid', 'payment_reference' => $intent->id]);
Mail::to(auth()->user())->send(new OrderConfirmation($order));
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return redirect()->route('orders.show', $order);
}
The problem isn’t the length. It’s what this shape hides.
Two customers can buy the last item. Both requests read stock = 1 before either one decrements it. Nothing locks those rows.
A customer can pay for an order that doesn’t exist. The Stripe call sits inside the transaction. If the email throws right after it, the transaction rolls back. The charge doesn’t.
Add locking, and every checkout waits on Stripe. Row locks stay held for as long as the payment API takes to answer.
None of it can be reused. Your API needs these rules. So does the admin “create order for customer” screen, and the CSV import. With auth()->id() and $request baked in, each one gets its own copy, and the copies drift.
You can’t test it without HTTP and a Stripe key. It also runs one query per product. Our Laravel performance guide shows how to catch automatically.
3. The Refactor, One Layer at a Time
So let’s pull that controller apart. Four pieces, one at a time.
Validation and permissions go in a Form Request
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.product_id' => ['required', 'integer', 'distinct'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'payment_method_id' => ['required', 'string'],
];
}
/** @return array<int, int> product_id => quantity */
public function itemQuantities(): array
{
return collect($this->validated('items'))
->mapWithKeys(fn (array $item) => [$item['product_id'] => $item['quantity']])
->all();
}
}
You might notice there’s no exists rule on each item. That’s on purpose. It runs one query per line. And the action has to check anyway, since a product can be deleted between validation and the transaction.
distinct is doing real work here. Without it, the same product could appear twice and get collapsed by mapWithKeys().
The business operation becomes an action
namespace App\Actions\Orders;
class PlaceOrder
{
public function __construct(
private PaymentGateway $payments,
) {}
/** @param array<int, int> $items product_id => quantity */
public function handle(User $customer, array $items, string $paymentMethodId): Order
{
// 1. Database work only. Lock the product rows so two checkouts
// can't both buy the last unit.
$order = DB::transaction(function () use ($customer, $items) {
$products = Product::whereIn('id', array_keys($items))
->lockForUpdate()
->get()
->keyBy('id');
$order = $customer->orders()->create([
'status' => OrderStatus::Pending,
'total_cents' => 0,
]);
$total = 0;
foreach ($items as $productId => $quantity) {
$product = $products->get($productId);
if (! $product || $product->stock < $quantity) {
throw new OutOfStock($productId);
}
$product->decrement('stock', $quantity);
$order->lines()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'unit_price_cents' => $product->price_cents,
]);
$total += $product->price_cents * $quantity;
}
$order->update(['total_cents' => $total]);
return $order;
});
// 2. The payment call runs after commit: no locks are held while
// waiting on the provider, and a rollback can't leave a charge behind.
$charge = $this->payments->charge(
amountCents: $order->total_cents,
paymentMethodId: $paymentMethodId,
idempotencyKey: "order-{$order->id}",
);
$order->update([
'status' => OrderStatus::Paid,
'payment_reference' => $charge->id,
]);
OrderPlaced::dispatch($order);
return $order;
}
}
Longer than the original, sure. But look at what changed.
The transaction now covers database writes only. The charge happens after commit. A rollback can’t leave money behind.
lockForUpdate() stops two people buying the last unit. The second checkout waits for the first to commit. Then it reads the real stock.
One query loads every product, instead of one per line.
The idempotency key is tied to the order. Retry it, and the customer still gets charged once. Stripe and most providers support this.
So what happens if the charge fails? The order sits there as pending, with its stock reserved. You can release it in a catch block, or with a scheduled job that expires stale pending orders. It depends on whether customers can retry payment.
We use that pending-first idea on a live e-commerce project. The order is created as pending before the payment intent. Then a webhook from the provider confirms it. The order always exists before the money moves.
One thing to watch. Don’t wrap this action in another transaction. That drops the payment call right back inside one. If a caller really needs that, split the payment step into its own action.
And OutOfStock? Nothing fancy: class OutOfStock extends RuntimeException, with the product ID in the message.
External systems sit behind a service
namespace App\Services\Payments;
use Illuminate\Container\Attributes\Bind;
#[Bind(StripePaymentGateway::class)]
interface PaymentGateway
{
/** @throws PaymentFailed */
public function charge(int $amountCents, string $paymentMethodId, string $idempotencyKey): Charge;
}
namespace App\Services\Payments;
use Illuminate\Container\Attributes\Config;
use Stripe\Exception\ApiErrorException;
use Stripe\StripeClient;
class StripePaymentGateway implements PaymentGateway
{
public function __construct(
#[Config('services.stripe.secret')] private string $secret,
) {}
public function charge(int $amountCents, string $paymentMethodId, string $idempotencyKey): Charge
{
try {
$intent = (new StripeClient($this->secret))->paymentIntents->create([
'amount' => $amountCents,
'currency' => 'usd',
'payment_method' => $paymentMethodId,
'confirm' => true,
'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'],
], ['idempotency_key' => $idempotencyKey]);
} catch (ApiErrorException $e) {
throw new PaymentFailed($e->getMessage(), previous: $e);
}
if ($intent->status !== 'succeeded') {
throw new PaymentFailed("Payment not completed: {$intent->status}");
}
return new Charge(id: $intent->id);
}
}
Charge is a tiny value object: final readonly class Charge { public function __construct(public string $id) {} }. Small, but it matters. The rest of your app never touches Stripe’s response types.
Two container features handle the wiring. #[Bind] on the interface tells Laravel which implementation to inject, so you don’t need a service provider entry. It’s in current Laravel 12 releases; on older versions, use $this->app->bind() in AppServiceProvider. #[Config] pulls the secret straight from config.
Both keep setup code out of your business logic.
Side effects run after commit
class OrderPlaced implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->user)->send(new OrderConfirmation($event->order));
}
}
PlaceOrder dispatches the event outside its transaction already, so ShouldDispatchAfterCommit can look pointless. It isn’t.
Actions get reused. Someday, someone calls this one from inside a transaction. With that interface, the event still waits for the commit. Without it, a queued listener can run before the order exists and blow up with ModelNotFoundException.
Prefer to handle it per listener? Implement ShouldQueueAfterCommit on the listener instead. Or set after_commit to true on the queue connection in config/queue.php, and it applies to everything.
What’s left in the controller
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
try {
$order = $placeOrder->handle(
$request->user(),
$request->itemQuantities(),
$request->validated('payment_method_id'),
);
} catch (OutOfStock $e) {
return back()->withErrors(['items' => $e->getMessage()]);
} catch (PaymentFailed) {
return back()->withErrors(['payment' => 'Your payment was declined. Please try another card.']);
}
return to_route('orders.show', $order);
}
}
That’s it. The controller does HTTP and nothing else. Read the request, call the action, turn the result or the exception into a response.
Now your API controller, an artisan command, and an import job can all call the same PlaceOrder and get the same rules.
4. Services vs Actions: A Rule for Code Review
People mix these up, and I get why. Both are “a class with some logic in it.” The difference is what the class stands for.
| Action | Service | |
| Represents | One business operation | A capability many operations use |
| Named as | A verb: PlaceOrder, RefundPayment, InviteMember | A noun: PaymentGateway, TaxCalculator, PdfRenderer |
| Public methods | One (handle() or __invoke()) | Several related ones |
| Called from | Controllers, jobs, commands, listeners | Actions, and other services |
| Usually wraps | Your own business rules | Something outside your app |
| Needs an interface? | Rarely | When you need to fake or replace it |
Keep the arrows pointing one way. Entry points call actions. Actions call services and models. Services never call actions.
Can an action call another action? Sure, one level deep. If the chain goes deeper, that top action has turned into a workflow, and it’s usually clearer as one action with private steps.
5. Four Rules That Keep Actions Reusable
An action is only reusable if it doesn’t secretly depend on the web. These four rules keep it that way.
1. No request(), session(), or auth() inside an action.
auth()->user() returns null in a queued job. Same in an artisan command.
That’s a bug you only meet in production. Pass the user in as an argument.
2. Return results, throw exceptions.
An action returns a model or a value object. When something’s wrong, it throws. Never a redirect, never a JSON response.
Why? It has no idea who’s calling it.
3. Dependencies in the constructor, data in the method.
Inject services and gateways once. Pass the customer, items, and payment method per call.
Needing more than four or five arguments? Pass a small readonly data object instead.
4. Rules that must always hold live in the action.
Form Requests only run for HTTP requests. Put the stock check in StoreOrderRequest, and your CSV import will happily oversell.
My rule is simple. The Form Request checks the shape. The action checks the rules.
6. Test the Action, Not the Controller
Here’s the payoff. With no HTTP in the way, you can test the business rules directly, using a fake gateway:
// tests/Fakes/FakePaymentGateway.php
class FakePaymentGateway implements PaymentGateway
{
public array $charges = [];
public function charge(int $amountCents, string $paymentMethodId, string $idempotencyKey): Charge
{
$this->charges[] = compact('amountCents', 'paymentMethodId', 'idempotencyKey');
return new Charge(id: 'ch_fake_'.count($this->charges));
}
}
class PlaceOrderTest extends TestCase
{
use RefreshDatabase;
private FakePaymentGateway $payments;
protected function setUp(): void
{
parent::setUp();
$this->payments = new FakePaymentGateway();
$this->instance(PaymentGateway::class, $this->payments);
}
public function test_it_places_an_order_and_reserves_stock(): void
{
Event::fake([OrderPlaced::class]);
$customer = User::factory()->create();
$product = Product::factory()->create(['stock' => 5, 'price_cents' => 2500]);
$order = app(PlaceOrder::class)->handle($customer, [$product->id => 2], 'pm_card_visa');
$this->assertSame(OrderStatus::Paid, $order->status);
$this->assertSame(5000, $order->total_cents);
$this->assertSame(3, $product->fresh()->stock);
$this->assertCount(1, $this->payments->charges);
Event::assertDispatched(OrderPlaced::class);
}
public function test_it_does_not_charge_when_stock_is_short(): void
{
$customer = User::factory()->create();
$product = Product::factory()->create(['stock' => 1]);
try {
app(PlaceOrder::class)->handle($customer, [$product->id => 2], 'pm_card_visa');
$this->fail('Expected OutOfStock to be thrown.');
} catch (OutOfStock) {
// Expected. The assertions below check nothing was left behind.
}
$this->assertSame([], $this->payments->charges);
$this->assertSame(1, $product->fresh()->stock);
$this->assertDatabaseCount('orders', 0);
}
}
Why try/catch instead of expectException() in that second test? Because expectException() ends the test at the exception, and everything after it never runs. Those assertions are the whole point: no charge, no stock change, no half-written order.
Keep one feature test per endpoint for what’s left in the controller: validation errors, authorization, redirects.
7. Folders: Start Flat, Group by Domain as It Grows
Take Laravel’s defaults, add two folders, and group by domain from day one:
app/
├── Actions/
│ └── Orders/
│ └── PlaceOrder.php
├── Events/
│ └── OrderPlaced.php
├── Exceptions/
│ └── OutOfStock.php
├── Http/
│ ├── Controllers/
│ │ └── OrderController.php
│ └── Requests/
│ └── StoreOrderRequest.php
├── Listeners/
│ └── SendOrderConfirmation.php
├── Models/
├── Policies/
│ └── OrderPolicy.php
└── Services/
└── Payments/
├── Charge.php
├── PaymentFailed.php
├── PaymentGateway.php
└── StripePaymentGateway.php
Why not a flat Actions/ folder? Because one day it holds 80 files. Grouping by domain costs you nothing today.
Thinking about full domain modules later, like app/Domain/Orders/? Two things will bite you.
First, event discovery only scans app/Listeners. Point Laravel at the rest in bootstrap/app.php: ->withEvents(discover: [__DIR__.’/../app/Domain/*/Listeners’]).
Second, moving models is bigger than it looks. Polymorphic relationships store the class name in the database, unless you set up a morph map. Factories need newFactory() overrides too.
I’d leave models in app/Models unless you’re committed to the full move.
8. Enforce the Boundaries in CI
Structure drifts one “quick fix” at a time. Nobody means to undo it.
If you use Pest, architecture tests catch it for you:
// tests/ArchTest.php
arch('controllers do not query the database directly')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Support\Facades\DB');
arch('actions do not depend on the HTTP layer')
->expect('App\Actions')
->not->toUse([
'Illuminate\Http\Request',
'Illuminate\Support\Facades\Auth',
'Illuminate\Support\Facades\Session',
]);
arch('services do not call actions')
->expect('App\Services')
->not->toUse('App\Actions');
On PHPUnit? Or just want to see how bad an existing codebase is? These searches find the worst spots fast:
# Largest controllers first (the top line is the total)
find app/Http/Controllers -name '*.php' -exec wc -l {} + | sort -rn | head -11
# Controllers that query the database directly
grep -rnE "DB::|::where\(|::find\(|::query\(\)" app/Http/Controllers
# Actions and services reaching for the request, session, or logged-in user
grep -rnE "request\(\)|session\(\)|auth\(\)|Auth::" app/Actions app/Services
# Mail and HTTP calls near transactions (check each hit by hand)
grep -rn -A25 "DB::transaction\|beginTransaction" app | grep -E "Mail::|Http::|->notify\(|Client\("
That last one is a rough filter, not proof. Look at every hit anyway. It’s where the “charged but no order” bug from section 2 hides.
9. Patterns That Look Clean but Aren’t
I’ve seen all four of these sold as “clean architecture.” They usually make things worse.
The 30-method OrderService. Move every controller method into one service class, and what do you have? A fat controller in a new file. If those methods don’t share dependencies or state, they’re separate actions.
A repository interface for every model. Eloquent is already a data-access layer. A repository that wraps it one-to-one adds a file per model and lets you swap nothing.
It does earn its keep when the data really comes from more than one place, like an external API plus your database. As a default, though, it rarely does.
Calling app(Something::class) inside methods. It hides what a class depends on, and it makes testing harder. Use constructor injection. Save app() for places the container can’t reach, like closures in config.
Folder-first refactors. Creating app/Services and shuffling code into it, with no tests, makes nothing safer. Start with the endpoint that moves money or inventory. Write the action test first, then move the code.
10. When You Need a Plan, Not Just New Folders
Some of these I treat as a warning sign rather than a to-do item:
- Money or inventory changes happen in controllers with no tests.
- The same logic lives in web, API, and admin controllers, and the copies no longer match.
- Queued jobs fail now and then with ModelNotFoundException. That’s usually a dispatch before commit.
- External API calls happen inside DB::transaction().
- Framework upgrades stall, because nobody can say what a change will touch. (If that’s you, our Laravel 12 breaking changes guide is a good next read.)
You can fix all of these one endpoint at a time. Start with the one that moves money.
Start With the Controller That Scares You
Laravel 12 won’t tell you where your business logic goes. That’s not a gap in the framework. It’s a decision it leaves to you, and the answer is simpler than most architecture debates suggest.
Form Requests check the input. Policies check permission. Actions hold the rules. Services talk to the outside world. Queued listeners handle the slow stuff. Controllers translate HTTP.
Would I change any of this? Once a project grows into several teams with clear domain boundaries, full domain modules start paying off. The flat structure above stops being enough. And if you adopt a package like lorisleiva/laravel-actions, follow its conventions instead of hand-rolling yours.
Today, pick the endpoint that moves money and run the searches from section 8 against it. Write the action test first. Then move the code.
And if there’s one controller nobody on your team wants to touch, send it over. Paste a single controller method, with the secrets removed. Within 3 business days, we’ll tell you where each part belongs, and send back the refactored action plus a test for it. No call, no pitch. Send us your controller.
FAQ
Does Laravel 12 have a make:action command? No. Use php artisan make:class Actions/Orders/PlaceOrder, which creates a plain class in the right folder. Packages like lorisleiva/laravel-actions bring their own conventions, but the approach in this guide doesn’t need a package.
Are actions the same as jobs? No. A job is how work gets queued. An action is the work itself. A queued job can call an action from its handle() method, so the same logic runs immediately or in the background.
Should I use the repository pattern with Eloquent? Usually not for every model. Eloquent already does that job. A repository pays off when the same data comes from more than one source, such as an external API alongside your database.
Where should validation live: the Form Request or the action? Both, but they check different things. The Form Request checks the shape of the input, and it only runs for HTTP requests. The action enforces the rules that must hold everywhere, like stock checks, because jobs, commands, and imports never touch a Form Request.
Why shouldn’t an API call happen inside DB::transaction()? Two reasons. Row locks stay held while you wait for the other system to answer, which slows down everyone else. And if the transaction rolls back afterwards, the API call doesn’t roll back with it. That’s how you get a charge with no order.
Do I need this structure in a small app? Not everywhere. Start with the endpoints that move money or inventory, and leave simple CRUD in the controller. The structure should follow the risk, not the file count.
Tags
Larastaff
Related Articles
Upgrading Laravel 10 to 12: What Actually Breaks…
Table of contentsHide Why Laravel Upgrades Matter The safest Laravel 10 to 12 upgrade path…
Why Admin Panels Get Slow: Filament Queries, Livewire…
Table of contentsHide 1. Check Your Versions First 2. Find the Cause Before You Change…
Laravel 12 Breaking Changes You Must Fix After…
Table of contentsHide What You Don't Need to Change Find What Affects Your App in…
Leave a Comment