Aplikacja Laravel rzadko staje się trudna w zmianie przez jeden spektakularny błąd w kontrolerze. Zwykle problem narasta, gdy reguła biznesowa ma pięć domów: fragment w requeście, fragment w kontrolerze, fragment w obserwerze modelu, fragment w listenerze zdarzenia i fragment w notyfikacji kolejki. Każdy z nich osobno wygląda rozsądnie, ale żaden czytelnik nie wskaże miejsca, w którym use case się dzieje.
Większość aplikacji nie potrzebuje CQRS, command busa ani hierarchii abstrakcyjnych serwisów. Wystarczy mniejsza granica: Form Request do spraw HTTP, DTO do wejścia use case'u, Value Object dla pojęcia z regułami oraz akcja nazywająca operację biznesową. To pragmatyczny środek Laravela: ważne zachowanie dostaje własny dom bez drugiego frameworka wewnątrz pierwszego.
Utworzymy zamówienie z katalogu B2B. Przykład obejmuje zagnieżdżone dane, pieniądze, magazyn, transakcję i zdarzenie wysyłane po commicie, bo właśnie tam rozmyta architektura przecieka.
Form Request jest granicą HTTP
Form Request zna nazwy przesłanych pól, komunikaty walidacyjne i zalogowanego użytkownika. To właściwe miejsce, by rozstrzygnąć, czy żądanie ma poprawny kształt, ale nie jest dobrą zależnością samej operacji biznesowej. Harmonogramowany import, integracja API i job kolejki powinny umieć utworzyć zamówienie bez konstruowania sztucznego requestu HTTP.
<?php
declare(strict_types=1);
namespace App\Http\Requests\Orders;
use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'currency' => ['required', 'string', 'size:3'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'distinct', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:1000'],
];
}
}
Nie licz tutaj sumy i nie pytaj bazy, czy produkt jest jeszcze dostępny. Walidacja potrafi potwierdzić, że quantity jest dodatnim integerem. Nie potrafi bezpiecznie potwierdzić dostępności, bo inny request może kupić ostatnią sztukę między walidacją a zapisem. Dostępność jest zmiennym faktem biznesowym, więc należy do transakcji wykonującej zapis.
Polityka odpowiada na pytanie: „czy ten aktor może rozpocząć tę operację?”. Akcja nadal musi chronić fakty obowiązujące także wtedy, gdy wywołującym jest komenda konsolowa albo zaufana integracja. Autoryzacja nie zastępuje invariantów biznesowych.
DTO czyni kontrakt akcji jawnym
$request->validated() zwraca tablicę, w której klucze łatwo pomylić, a typy zagnieżdżonych wartości są niewidoczne dla czytelnika akcji. DTO mówi, co przyjmuje use case. To niemutowalny kontrakt wejściowy, a nie model Eloquent ani rekord persystencji.
<?php
declare(strict_types=1);
namespace App\Orders;
use App\Http\Requests\Orders\StoreOrderRequest;
final readonly class CreateOrderData
{
/** @param list<CreateOrderItemData> $items */
public function __construct(
public int $customerId,
public string $currency,
public array $items,
) {}
public static function fromRequest(StoreOrderRequest $request): self
{
$validated = $request->validated();
return new self(
customerId: $validated['customer_id'],
currency: strtoupper($validated['currency']),
items: array_map(
fn (array $item): CreateOrderItemData => new CreateOrderItemData(
productId: $item['product_id'],
quantity: $item['quantity'],
),
$validated['items'],
),
);
}
}
final readonly class CreateOrderItemData
{
public function __construct(public int $productId, public int $quantity) {}
}
Mapowanie następuje od razu na granicy. Akcja nigdy nie przyjmuje Request, array ani mixed, więc jej wywołujący nie doda później po cichu niesprawdzonego wejścia. Komenda CLI może skonstruować DTO samodzielnie, a kontroler JSON zmapować te same zwalidowane dane.
Ręcznie pisane DTO pasuje do stabilnej komendy z dwoma do dziesięciu pól. Jeśli każdy endpoint potrzebuje rekurencyjnego mapowania, dat, transformacji wyjścia i tego samego kontraktu w kilku kanałach, spatie/laravel-data może uzasadnić zależność. Instalowanie go wyłącznie po to, aby uniknąć dziesięcioliniowej klasy, to ceremonia.
Value Object broni pojęcia przez reguły
Nic w PHP nie zabrania dodać dolarów do integera o nazwie total_cents, zapisać ujemnej sumy faktury albo potraktować sformatowanego stringa jako pieniędzy. Value Object posiada te reguły i jest niemutowalny, dlatego można przekazywać go przez akcję bez zaskakującej mutacji.
<?php
declare(strict_types=1);
namespace App\Orders;
use DomainException;
final readonly class Money
{
public function __construct(public int $amount, public string $currency)
{
if ($amount < 0) {
throw new DomainException('Money cannot be negative.');
}
if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) {
throw new DomainException('Money must use an ISO-like currency code.');
}
}
public function multiply(int $quantity): self
{
if ($quantity < 1) {
throw new DomainException('Quantity must be at least one.');
}
return new self($this->amount * $quantity, $this->currency);
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('Cannot add different currencies.');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
Zapisuj pieniądze jako integer jednostek mniejszych, nigdy jako binarne floaty. Ten obiekt celowo nie liczy VAT, nie pobiera kursów i nie formatuje faktury. To osobne polityki.
Nie każdy scalar zasługuje na klasę. Jednorazowy sort_direction, ograniczony przez walidację requestu, nie potrzebuje SortDirection. Wprowadź Value Object, gdy nieprawidłowy stan jest kosztowny, reguła powtarza się w kilku miejscach albo typ nadaje nazwę pojęciu biznesowemu. W przeciwnym wypadku czytelniejszy będzie natywny enum, cast albo dobrze nazwany prymityw.
Akcja posiada jeden workflow i jego transakcję
Akcja koordynuje jedną operację biznesową, a jej metoda publiczna mówi, co robi. CreateOrder blokuje produkty, sprawdza stan, zapisuje zamówienie i emituje zdarzenie dopiero po commicie.
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Events\OrderPlaced;
use App\Models\Order;
use App\Models\Product;
use App\Orders\CreateOrderData;
use App\Orders\Money;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class CreateOrder
{
public function handle(CreateOrderData $data): Order
{
return DB::transaction(function () use ($data): Order {
$products = Product::query()
->whereIn('id', array_map(fn ($item): int => $item->productId, $data->items))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$order = Order::query()->create([
'customer_id' => $data->customerId,
'currency' => $data->currency,
'total_cents' => 0,
]);
$total = new Money(0, $data->currency);
foreach ($data->items as $item) {
$product = $products->get($item->productId);
if ($product === null || $product->stock < $item->quantity) {
throw ValidationException::withMessages([
'items' => ['One or more products are no longer available in the requested quantity.'],
]);
}
$lineTotal = (new Money($product->price_cents, $data->currency))->multiply($item->quantity);
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item->quantity,
'unit_price_cents' => $product->price_cents,
'line_total_cents' => $lineTotal->amount,
]);
$product->decrement('stock', $item->quantity);
$total = $total->add($lineTotal);
}
$order->update(['total_cents' => $total->amount]);
OrderPlaced::dispatch($order);
return $order->refresh();
}, attempts: 3);
}
}
Samo zdarzenie deklaruje ShouldDispatchAfterCommit, więc żaden wywołujący nie musi pamiętać o metodzie po commicie.
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Order;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final class OrderPlaced implements ShouldDispatchAfterCommit
{
use Dispatchable;
use SerializesModels;
public function __construct(public Order $order) {}
}
Akcja posiada transakcję, bo koordynuje zapisy, które muszą razem odnieść sukces albo razem się wycofać. Kontroler nie powinien otwierać jednej transakcji, gdy helpery z przyzwyczajenia otwierają kolejne. Akcja nie powinna też zwracać redirectów, tłumaczonych flash message ani odpowiedzi Inertia: są to decyzje warstwy dostarczania.
lockForUpdate() pasuje, gdy magazyn jest punktem rywalizacji, a baza źródłem prawdy. Może zwiększać oczekiwanie i deadlocki, więc blokuj w stałej kolejności, utrzymuj transakcję krótką i nie wykonuj w niej żądania HTTP. System rezerwacji o dużym wolumenie może zamiast tego potrzebować atomowego warunkowego update'u, osobnego serwisu magazynowego albo asynchronicznego workflow rezerwacji.
Kontroler staje się celowo nieciekawy
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Orders;
use App\Actions\Orders\CreateOrder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Orders\StoreOrderRequest;
use App\Orders\CreateOrderData;
use Illuminate\Http\RedirectResponse;
final class StoreOrderController extends Controller
{
public function __invoke(StoreOrderRequest $request, CreateOrder $createOrder): RedirectResponse
{
$order = $createOrder->handle(CreateOrderData::fromRequest($request));
return to_route('orders.show', $order)->with('success', 'Order created.');
}
}
Krótki kontroler pokazuje, że walidacja wejścia, orkiestracja biznesowa i formatowanie odpowiedzi są rozdzielone. Komenda CLI może wywołać akcję, a kontroler API zwrócić Resource.
Testuj granicę biznesową, nie mocki Eloquent
Test akcji powinien korzystać z prawdziwej bazy. Mockowanie łańcuchów Eloquent testuje głównie mock i omija błędy relacji, transakcji oraz czasu zdarzeń. Walidację requestu zostaw testowi feature; wynik akcji przetestuj osobno.
<?php
use App\Actions\Orders\CreateOrder;
use App\Events\OrderPlaced;
use App\Models\Customer;
use App\Models\Product;
use App\Orders\CreateOrderData;
use App\Orders\CreateOrderItemData;
use Illuminate\Support\Facades\Event;
use Illuminate\Validation\ValidationException;
it('creates an order and decrements stock', function () {
Event::fake();
$customer = Customer::factory()->create();
$product = Product::factory()->create(['stock' => 4, 'price_cents' => 1250]);
$order = app(CreateOrder::class)->handle(new CreateOrderData(
customerId: $customer->id,
currency: 'PLN',
items: [new CreateOrderItemData($product->id, 2)],
));
expect($order->total_cents)->toBe(2500);
expect($product->refresh()->stock)->toBe(2);
expect($order->items)->toHaveCount(1);
Event::assertDispatched(OrderPlaced::class);
});
it('does not create a partial order when stock is insufficient', function () {
$customer = Customer::factory()->create();
$product = Product::factory()->create(['stock' => 1, 'price_cents' => 1250]);
app(CreateOrder::class)->handle(new CreateOrderData(
customerId: $customer->id,
currency: 'PLN',
items: [new CreateOrderItemData($product->id, 2)],
));
})->throws(ValidationException::class);
W prawdziwym suite użyj ustalonego w projekcie traitu odświeżania bazy i sprawdź, że po wyjątku nie pozostaje żadne zamówienie. Dodaj test requestu dla zduplikowanych produktów i test polityki dla nieuprawnionego użytkownika. Jeśli współbieżny stan jest istotny, testuj też na silniku bazy z produkcji: SQLite ma inne cechy blokad.
Czego to unika i kiedy tego nie używać
Jedna pułapka to „DTO” dla każdej tabeli, kopiowane przez kontroler, serwis, zdarzenie i job. To przerzucanie danych, a nie granica. Druga to BaseAction z execute, run i ukrytymi hookami middleware. Wywołujący powinien widzieć, co robi klasa: CreateOrder::handle() oraz zwykłe dependency injection wystarczą.
Nie umieszczaj reguł domenowych wyłącznie w Form Request, bo importy i joby kolejki go omijają. Nie pozwól, aby akcja przyjmowała Request, array lub mixed, bo jej kontrakt staje się niewidoczny. Nie twórz Money, który po cichu wymienia waluty. Nie wysyłaj e-maili ani webhooków przed commitem, bo rollback zostawi świat zewnętrzny w przekonaniu, że istnieje zamówienie, którego nie ma.
Dla zwykłego CRUD-u aktualizującego jeden model bez invariantów między modelami wystarczą standardowy kontroler, request i model Laravela. Dla raportowego zapytania lepszy od akcji będzie scope Eloquent albo query object. Dla długiego procesu między usługami, retry i akceptacji człowieka zastosuj jawną maszynę stanów albo workflow kolejki. Nie każda metoda potrzebuje czterech klas. Użyj tego środka tam, gdzie zwykły Laravel zaczyna rozmywać ważne ścieżki.