Laravel Laravel Development

    Laravel 12 Architecture: Where Business Logic Belongs

    December 30, 2025
    Updated: September 18, 2026
    Larastaff
    18 min read
    Laravel 12 Architecture: Where Business Logic Belongs

    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.

    CodeWhere it belongsWhy
    Input shape, types, required fieldsForm RequestRuns 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)ActionForm Requests don’t run for jobs, commands, or imports
    One business operation (place order, issue refund, invite member)ActionReusable from web, API, jobs, and commands
    Talking to an external system (payments, tax, shipping)Service behind an interfaceCan be faked in tests and swapped later
    Reusable query conditionsEloquent scopeKeeps query logic on the model
    Formatting or converting a columnCast or accessorApplied every time the model is read
    Slow side effects (email, webhooks, PDFs)Queued listener or jobKeeps the request fast
    Which implementation or config value to injectService provider, #[Bind], or contextual attributesWiring 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.

    ActionService
    RepresentsOne business operationA capability many operations use
    Named asA verb: PlaceOrder, RefundPayment, InviteMemberA noun: PaymentGateway, TaxCalculator, PdfRenderer
    Public methodsOne (handle() or __invoke())Several related ones
    Called fromControllers, jobs, commands, listenersActions, and other services
    Usually wrapsYour own business rulesSomething outside your app
    Needs an interface?RarelyWhen 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

    clean code Laravel laravel architecture laravel design patterns service classes
    Larastaff

    Larastaff

    Related Articles

    Leave a Comment

    Your email address will not be published. Required fields are marked *