---
title: 'Action-Klassen, Services und DTOs in Laravel | DevSense'
description: 'Wie Sie fette Controller und Models in Laravel entlasten: Action-Klassen, Service-Schicht, DTOs als readonly-Klassen, Modulgrenzen, Transaktionen und Events – und wann das alles Overengineering ist.'
faq:
    - { question: 'Worin unterscheidet sich eine Action-Klasse von einem Service in Laravel?', answer: 'Eine Action beschreibt genau ein Geschäftsszenario (eine Bestellung aufgeben, ein Mitglied ins Team einladen) und hat eine einzige öffentliche Methode. Ein Service bündelt Operationen rund um eine Ressource oder Integration (Payment-Gateway, Preisrechner) und kennt das Szenario als Ganzes nicht. Die Action ruft Services auf, nicht umgekehrt.' }
    - { question: 'Wozu DTOs, wenn es $request->validated() und Arrays gibt?', answer: 'Ein Array verrät nicht, welche Schlüssel es enthält und welche Typen sie haben: Ein Tippfehler im Schlüssel fällt erst zur Laufzeit auf, die IDE bietet keine Vervollständigung, statische Analyse ist machtlos. Ein DTO als readonly-Klasse legt den Vertrag fest – den Typ jedes Feldes, ob es Pflicht ist, und seine Unveränderlichkeit. Ein und dieselbe Action lässt sich aus einem Controller, einem Konsolenbefehl, einem Queue-Job und einem Test aufrufen.' }
    - { question: 'Darf man Eloquent-Models zwischen Modulen weitergeben?', answer: 'Innerhalb eines Moduls – ja. Zwischen Modulen übergibt man besser DTOs oder IDs: Ein Model zieht Relationen, Lazy-Loading-Abfragen und die Möglichkeit mit sich, fremde Daten per save() zu ändern. Eine Grenze aus DTOs macht Abhängigkeiten explizit und erlaubt es, das Tabellenschema eines Moduls zu ändern, ohne die Nachbarn zu brechen.' }
    - { question: 'Wann sind Action-Klassen und DTOs Overengineering?', answer: 'Bei CRUD ohne Geschäftsregeln, Admin-Panels und Prototypen. Wenn ein Controller in fünf Zeilen den Request validiert und das Model speichert, bringt die Auslagerung in Action und DTO drei zusätzliche Dateien und keine einzige Regel. Schichten zahlen sich aus, sobald ein Szenario Invarianten, mehrere Einstiegspunkte oder Seiteneffekte bekommt.' }
published: '2026-10-03'
---
# Action-Klassen, Services und DTOs in Laravel: Controller und Models entlasten

Fast jedes Laravel-Projekt durchläuft dieselbe Phase. `OrderController@store` wächst auf zweihundert Zeilen an: Validierung, Preisberechnung mit Gutscheincode, Warenreservierung, Abbuchung, E-Mail an den Kunden, Webhook ans CRM. Dann kommt eine API für die Mobile-App dazu, und diese zweihundert Zeilen werden in einen zweiten Controller kopiert. Dann ein Konsolenbefehl zum Import von Bestellungen aus einem Marktplatz – die dritte Kopie. Ein halbes Jahr später ist in einer der Kopien ein Rundungsfehler behoben, in den beiden anderen nicht.

Parallel wächst das Model `Order`: Es enthält die Methoden `send()`, `refund()`, `syncWithCrm()`, Event-Handler und ein paar HTTP-Requests. Testen lässt sich das nur über HTTP, und jede Änderung macht Angst. In diesem Artikel geht es darum, solchen Code auf Schichten zu verteilen: **Action-Klassen** für Szenarien, **Services** für wiederverwendbare Operationen, **DTOs** für die Daten dazwischen. Und darum, wo man aufhören sollte, damit aus einem einfachen CRUD kein Enterprise-Monolith aus vierzig Interfaces wird.

**Verwandte Leitfäden:** [Design-Antipatterns](design-antipatterns) · [Strukturmuster der GoF](structural-design-patterns) · [Ausfallsichere Integrationen](resilient-external-integrations) · [Laravel-Queues in Produktion](laravel-queues-production)

## Inhalt

* [Symptome fetter Controller und fetter Models](#symptoms)
* [Schichtenkarte: wer wofür zuständig ist](#layers)
* [DTO: ein Vertrag statt eines Arrays](#dto)
* [Action-Klassen: ein Szenario – eine Klasse](#actions)
* [Service-Schicht: wann sie wirklich nötig ist](#services)
* [Der Controller nach dem Refactoring](#thin-controller)
* [Eine Logik – viele Einstiegspunkte: Befehle, Jobs, Tests](#reuse)
* [Was im Model bleibt](#models)
* [Modulgrenzen: DTOs zwischen Kontexten](#modules)
* [Wann das Overengineering ist](#when-not)
* [Schrittweise refaktorisieren](#migration)
* [Häufige Fehler](#common-mistakes)
* [Checkliste](#checklist)
* [Quiz zur Selbstkontrolle](#self-test-quiz)

---

<a id="symptoms"></a>
## Symptome fetter Controller und fetter Models

Das Problem ist nicht die Anzahl der Zeilen, sondern dass an einer Stelle verschiedene Gründe für Änderungen vermischt sind. Anzeichen dafür, dass es Zeit ist, den Code aufzuteilen:

* **Logik wird zwischen Einstiegspunkten kopiert.** Web-Controller, API-Controller und Konsolenbefehl machen „fast dasselbe“.
* **Ein Szenario-Test erfordert einen HTTP-Request.** Um die Rabattberechnung zu prüfen, muss man sich einloggen, ein Formular zusammenbauen und einen Redirect auswerten.
* **Unklar, wo die Transaktionsgrenze liegt.** Die Bestellung ist gespeichert, die Warenreservierung ist fehlgeschlagen – und in der Datenbank liegt eine Bestellung ohne Reservierung.
* **Seiteneffekte passieren vor dem Commit.** Die E-Mail „Bestellung aufgegeben“ ist raus, die Transaktion wurde aber zurückgerollt.
* **Das Model weiß alles.** `Order` verschickt E-Mails, spricht mit dem CRM und berechnet Steuern. Jede Aufgabe erfordert Änderungen an derselben Datei, und Git-Konflikte werden zur Normalität.
* **Arrays mit unbekannter Struktur.** `$data['items'][0]['qty']` oder `$data['items'][0]['quantity']`? Die Antwort gibt nur der Debugger.

---

<a id="layers"></a>
## Schichtenkarte: wer wofür zuständig ist

| Schicht | Zuständig für | Darf nicht |
|------|-------------|-----------|
| **Controller** | HTTP-Request entgegennehmen, Szenario aufrufen, Antwort zurückgeben | Preise berechnen, externe APIs aufrufen |
| **FormRequest** | Autorisierung und Validierung der Eingabe, Aufbau des DTO | Daten speichern |
| **DTO** | Transport typisierter Daten zwischen Schichten | Geschäftslogik enthalten oder auf die DB zugreifen |
| **Action** | Ein Geschäftsszenario, Transaktionsgrenzen, Reihenfolge der Schritte | HTTP, Session, Redirects kennen |
| **Service** | Eine wiederverwendbare Operation oder Integration (Preise, Zahlungen, Lager) | Das Szenario als Ganzes steuern |
| **Model** | Daten, Relationen, Casts, Scopes, einfache abgeleitete Werte | E-Mails verschicken, HTTP-Requests ausführen |
| **Job / Event** | Verzögerte und asynchrone Seiteneffekte | Ein Ergebnis an den Request zurückgeben |

Die Abhängigkeitsrichtung ist eindeutig: Controller → Action → Services und Models. Ein Service ruft nie eine Action auf, und das Model kennt weder das eine noch das andere.

---

<a id="dto"></a>
## DTO: ein Vertrag statt eines Arrays

Ein DTO (Data Transfer Object) ist ein Objekt ohne Verhalten, das Daten über eine Schichtgrenze transportiert. In modernem PHP sind das dank readonly-Klassen ([PHP 8.2](../php/8.2)) und Constructor Property Promotion nur ein paar Zeilen:

```php
// app/Domain/Orders/Data/OrderLineData.php
namespace App\Domain\Orders\Data;

final readonly class OrderLineData
{
    public function __construct(
        public int $productId,
        public int $quantity,
    ) {}
}
```

```php
// app/Domain/Orders/Data/PlaceOrderData.php
namespace App\Domain\Orders\Data;

use App\Domain\Orders\Enums\DeliveryMethod;

final readonly class PlaceOrderData
{
    /**
     * @param  list<OrderLineData>  $lines
     */
    public function __construct(
        public int $customerId,
        public array $lines,
        public DeliveryMethod $delivery,
        public ?string $promoCode = null,
    ) {}
}
```

Was das im Vergleich zu einem Array bringt:

* **Typen.** `quantity` ist immer `int`, `delivery` ein Enum-Wert und nicht der String `"courrier"` mit Tippfehler.
* **Unveränderlichkeit.** Kein Schritt des Szenarios kann die Daten für den nächsten „still“ verändern.
* **IDE-Unterstützung und statische Analyse.** PHPStan/Psalm erkennen den Zugriff auf ein nicht existierendes Feld schon vor der Ausführung.
* **Unabhängigkeit von der Quelle.** Die Action weiß nicht, ob die Daten aus einem Formular, einer JSON-API, einem CSV-Import oder einem Test stammen.

Die Validierung bleibt im `FormRequest` – das ist seine Aufgabe. Das DTO wird erst aus den geprüften Daten gebaut. Praktisch ist es, den Aufbau direkt neben den Regeln zu halten:

```php
// app/Http/Requests/PlaceOrderRequest.php
public function rules(): array
{
    return [
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.product_id' => ['required', 'integer', 'exists:products,id'],
        'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
        'delivery' => ['required', Rule::enum(DeliveryMethod::class)],
        'promo_code' => ['nullable', 'string', 'max:32'],
    ];
}

public function toData(): PlaceOrderData
{
    $validated = $this->validated();

    return new PlaceOrderData(
        customerId: $this->user()->id,
        lines: array_map(
            fn (array $line) => new OrderLineData((int) $line['product_id'], (int) $line['quantity']),
            $validated['lines'],
        ),
        delivery: DeliveryMethod::from($validated['delivery']),
        promoCode: $validated['promo_code'] ?? null,
    );
}
```

> [!NOTE]
> **Braucht man ein Package?** Für ein Dutzend DTOs reichen handgeschriebene readonly-Klassen. Packages wie `spatie/laravel-data` ergänzen den automatischen Aufbau aus dem Request, Validierung über Attribute und die Transformation in JSON. Das ist praktisch, wenn es Hunderte DTOs gibt und sie zugleich als API-Ressourcen dienen, bindet die Domänenschicht aber an das Package. Beginnen Sie mit einfachen Klassen.

Geldbeträge speichern Sie im DTO als Ganzzahl in der kleinsten Einheit (Cent) oder als `Money`-Objekt, nicht als `float`: `0.1 + 0.2` ist in PHP nicht gleich `0.3`.

---

<a id="actions"></a>
## Action-Klassen: ein Szenario – eine Klasse

Eine Action ist eine Klasse mit einer einzigen öffentlichen Methode, die ein Geschäftsszenario von Anfang bis Ende ausführt. Der Name ist ein Verb aus der Sprache der Fachdomäne: `PlaceOrder`, `CancelSubscription`, `InviteTeamMember`.

```php
// app/Domain/Orders/Actions/PlaceOrder.php
namespace App\Domain\Orders\Actions;

use App\Domain\Orders\Data\PlaceOrderData;
use App\Domain\Orders\Events\OrderPlaced;
use App\Domain\Orders\Services\PriceCalculator;
use App\Domain\Orders\Services\StockReservations;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(
        private PriceCalculator $prices,
        private StockReservations $stock,
    ) {}

    public function handle(PlaceOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $quote = $this->prices->quote($data->lines, $data->promoCode);

            $order = Order::create([
                'customer_id' => $data->customerId,
                'delivery' => $data->delivery,
                'total_cents' => $quote->totalCents,
                'discount_cents' => $quote->discountCents,
            ]);

            $order->lines()->createMany($quote->linesForStorage());

            // Throws when stock is insufficient, which rolls back the whole order.
            $this->stock->reserve($order);

            // The event implements ShouldDispatchAfterCommit,
            // so listeners never see an order that was rolled back.
            OrderPlaced::dispatch($order->id);

            return $order;
        });
    }
}
```

Die zentralen Entscheidungen in diesem Code:

* **Die Transaktionsgrenze liegt in der Action.** Das Szenario weiß, welche Schritte atomar ausgeführt werden müssen. Der Controller muss darüber nicht nachdenken, die Services ebenfalls nicht.
* **Abhängigkeiten über den Konstruktor.** Der Laravel-Container baut `PriceCalculator` und `StockReservations` selbst zusammen, und im Test lassen sie sich leicht austauschen.
* **Seiteneffekte nach dem Commit.** Ein Event mit dem Interface `ShouldDispatchAfterCommit` wird erst nach dem Festschreiben der Transaktion ausgelöst. Für Queue-Jobs leistet dasselbe `ShouldQueueAfterCommit` oder die Option `after_commit` der Connection.
* **Zurückgegeben wird ein Ergebnis, keine HTTP-Antwort.** Die Action weiß nicht, was danach kommt – ein Redirect, JSON oder eine Zeile in der Konsole.

```php
// app/Domain/Orders/Events/OrderPlaced.php
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

final class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(public int $orderId) {}
}
```

Wie die Methode heißt – `handle()`, `execute()` oder `__invoke()` – ist Teamkonvention. Wichtig ist, dass es genau eine gibt und sie im ganzen Projekt gleich heißt. `__invoke()` erlaubt es, die Action als Callable zu übergeben, `handle()` liest sich beim expliziten Aufruf besser.

> [!NOTE]
> **Eine Action darf eine andere Action aufrufen**, wenn das Teil des Szenarios ist: `PlaceOrder` kann `ApplyLoyaltyPoints` aufrufen. Achten Sie aber auf die Transaktionen: Ein verschachteltes `DB::transaction()` legt einen Savepoint an, und das Zurückrollen der inneren Transaktion rollt die äußere nicht zurück, wenn die Exception abgefangen wird.

---

<a id="services"></a>
## Service-Schicht: wann sie wirklich nötig ist

Ein Service ist eine Klasse rund um eine einzige Verantwortung, die von mehreren Szenarien genutzt wird: Preisberechnung, Lagerverwaltung, Payment-Gateway, PDF-Erzeugung. Er weiß nicht, wozu er aufgerufen wurde.

```php
// app/Domain/Payments/Contracts/PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amountCents, string $currency, string $idempotencyKey): PaymentResult;

    public function refund(string $paymentId, int $amountCents): RefundResult;
}
```

```php
// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
}
```

Ein Interface ist gerechtfertigt, wenn es mehr als eine Implementierung gibt (produktives Gateway und Fake für Tests, zwei Anbieter in verschiedenen Ländern) oder wenn es sich um eine Grenze zur Außenwelt handelt. Für einen internen `PriceCalculator` mit einer einzigen Implementierung ist ein Interface eine überflüssige Datei: Die konkrete Klasse lässt sich im Test genauso leicht per `$this->mock()` oder `$this->app->instance()` austauschen.

Die größte Gefahr der Service-Schicht ist der **God Service**. Ein `OrderService` mit den Methoden `create`, `cancel`, `refund`, `export`, `notify`, `recalculate` und vierzig Abhängigkeiten im Konstruktor ist derselbe fette Controller, nur in einem anderen Ordner. Hat ein Service mehr als fünf bis sieben öffentliche Methoden, die keine gemeinsamen Daten verbinden, sind das mehrere Action-Klassen, die zu einer zusammengeklebt wurden.

---

<a id="thin-controller"></a>
## Der Controller nach dem Refactoring

```php
// app/Http/Controllers/OrderController.php
public function store(PlaceOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
    try {
        $order = $placeOrder->handle($request->toData());
    } catch (InsufficientStock $e) {
        return back()->withErrors(['lines' => $e->getMessage()])->withInput();
    }

    return to_route('orders.show', $order);
}
```

Der Controller übersetzt HTTP in den Aufruf eines Szenarios und das Ergebnis des Szenarios zurück in HTTP. Die Domänen-Exception `InsufficientStock` wird zu einem Formularfehler. Im API-Controller wird dieselbe Exception zu einer `422`-Antwort, die Bestelllogik ändert sich dabei nicht.

---

<a id="reuse"></a>
## Eine Logik – viele Einstiegspunkte: Befehle, Jobs, Tests

Dieselbe Action wird unverändert aus dem Konsolenbefehl für den Import aufgerufen:

```php
// app/Console/Commands/ImportMarketplaceOrders.php
public function handle(MarketplaceClient $client, PlaceOrder $placeOrder): int
{
    foreach ($client->newOrders() as $external) {
        $placeOrder->handle(MarketplaceOrderMapper::toData($external));
    }

    return self::SUCCESS;
}
```

Und sie wird ohne HTTP, Sessions und CSRF getestet:

```php
public function test_placing_an_order_reserves_stock(): void
{
    $customer = User::factory()->create();
    $product = Product::factory()->create(['stock' => 10, 'price_cents' => 1500]);

    $order = app(PlaceOrder::class)->handle(new PlaceOrderData(
        customerId: $customer->id,
        lines: [new OrderLineData($product->id, 2)],
        delivery: DeliveryMethod::Courier,
    ));

    $this->assertSame(3000, $order->total_cents);
    $this->assertSame(8, $product->fresh()->stock);
}

public function test_order_is_rolled_back_when_stock_is_insufficient(): void
{
    $product = Product::factory()->create(['stock' => 1]);

    $this->expectException(InsufficientStock::class);

    try {
        app(PlaceOrder::class)->handle(new PlaceOrderData(
            customerId: User::factory()->create()->id,
            lines: [new OrderLineData($product->id, 5)],
            delivery: DeliveryMethod::Pickup,
        ));
    } finally {
        $this->assertDatabaseCount('orders', 0);
    }
}
```

Die Controller-Tests werden danach schlank: Sie prüfen Validierung, Autorisierung und dass die Antwort stimmt. Die Geschäftsregeln werden einmal geprüft – auf Ebene der Action.

---

<a id="models"></a>
## Was im Model bleibt

Ziel ist kein „anämisches Model“ ohne eine einzige Methode. Ein Eloquent-Model ist ein guter Ort für alles, was **die Daten selbst** beschreibt:

* Relationen (`lines()`, `customer()`), Casts (`'delivery' => DeliveryMethod::class`), Scopes (`scopePaid()`);
* einfache abgeleitete Werte ohne Seiteneffekte: `isPaid()`, `canBeCancelledBy(User $user)`, ein Accessor `total` aus `total_cents`;
* Invarianten einer einzelnen Entität, die keine anderen Services benötigen.

Was nicht ins Model gehört: E-Mail-Versand, HTTP-Requests, Arbeit mit Queues, komplexe Logik in Observern. Observer sind besonders tückisch: Sie greifen beim Import, in Seedern und in Tests, und im Code des Szenarios sind sie unsichtbar. Ist ein Seiteneffekt Teil des Szenarios, gehört er in die Action.

---

<a id="modules"></a>
## Modulgrenzen: DTOs zwischen Kontexten

Wenn ein Projekt wächst, ist es sinnvoll, Code nicht nach technischen Schichten (`Controllers`, `Models`, `Services`) zu gruppieren, sondern nach Fachdomänen:

```
app/
├── Domain/
│   ├── Orders/
│   │   ├── Actions/        PlaceOrder, CancelOrder
│   │   ├── Data/           PlaceOrderData, OrderLineData
│   │   ├── Enums/          DeliveryMethod, OrderStatus
│   │   ├── Events/         OrderPlaced
│   │   └── Services/       PriceCalculator, StockReservations
│   ├── Billing/
│   │   ├── Actions/        ChargeOrder, IssueRefund
│   │   ├── Contracts/      PaymentGateway
│   │   └── Data/           PaymentResult
│   └── Catalog/
├── Http/                   controllers and form requests stay framework-shaped
└── Models/
```

Die Regel für Grenzen: **Das Modul `Billing` nimmt kein `Order`-Model aus dem Modul `Orders` entgegen.** Es erhält ein DTO oder eine ID:

```php
// Inside the Orders module: a listener translates the event into a Billing call.
final class ChargePlacedOrder implements ShouldQueue
{
    public function handle(OrderPlaced $event, ChargeOrder $charge, OrderSummaryQuery $orders): void
    {
        $summary = $orders->summary($event->orderId); // returns a DTO, not a model

        $charge->handle(new ChargeRequestData(
            reference: "order-{$summary->id}",
            amountCents: $summary->totalCents,
            currency: $summary->currency,
        ));
    }
}
```

Warum kein Model:

* Ein Model erlaubt es, `$order->update()` aus einem fremden Modul aufzurufen – und niemand erfährt, wer den Status geändert hat;
* Lazy-Loading-Relationen verwandeln den Zugriff auf `$order->customer->address` in Abfragen, von denen das Modul `Billing` nichts ahnt;
* das Schema der Tabelle `orders` wird zur öffentlichen API – man kann es nicht ändern, ohne alle Module zu prüfen.

Das ist dasselbe Prinzip wie beim [Übergang vom Monolithen zu Microservices](monolith-to-microservices-architecture): zuerst explizite Grenzen innerhalb einer Anwendung, und erst danach – über das Netzwerk, falls das überhaupt nötig wird.

---

<a id="when-not"></a>
## Wann das Overengineering ist

Schichten kosten Zeit: mehr Dateien, mehr Sprünge durch den Code, mehr Absprachen. Sie zahlen sich nicht aus, wenn:

* **Es CRUD ohne Regeln ist.** Ein Formular zum Bearbeiten des Profils, das fünf Felder validiert und speichert, braucht weder `UpdateProfileAction` noch `UpdateProfileData`. `$user->update($request->validated())` ist völlig normaler Code.
* **Es ein Admin-Panel mit Filament oder Nova ist.** Das Framework gibt bereits eine eigene Struktur vor, und Action-Klassen darüber duplizieren oft seine Ressourcen.
* **Es ein Prototyp ist.** Solange Sie das Produkt noch suchen, ist Änderungsgeschwindigkeit wichtiger als Architektur. Das Refactoring in Schichten kommt, wenn sich das Szenario stabilisiert hat.
* **Das Szenario einen einzigen Einstiegspunkt und keine Seiteneffekte hat.** Es gibt nichts wiederzuverwenden und nichts zu isolieren.

Ein gutes Signal, dass es Zeit ist: der zweite Einstiegspunkt in dasselbe Szenario, das erste „E-Mail ist raus, aber die Transaktion wurde zurückgerollt“ oder der erste Test, der sich ohne HTTP nicht schreiben lässt.

---

<a id="migration"></a>
## Schrittweise refaktorisieren

Das ganze Projekt „auf Actions“ umzuschreiben ist weder nötig noch ungefährlich. Ein funktionierender Weg:

1. **Neue Szenarien direkt im neuen Stil.** Das setzt ein Vorbild, ohne alten Code zu gefährden.
2. **Vor dem Auslagern – ein Charakterisierungstest.** Man schreibt einen Feature-Test für das aktuelle Verhalten des Controllers, auch wenn es seltsam ist. Er fängt Regressionen beim Umzug ab.
3. **Lagern Sie ein Szenario nach dem anderen aus.** Der Rumpf der Controller-Methode wandert nahezu unverändert in das `handle()` der Action, und der Controller ruft sie auf. Der Test muss grün bleiben.
4. **Erst danach verbessern.** Führen Sie DTOs statt Arrays ein, verschieben Sie Seiteneffekte hinter den Commit, extrahieren Sie Services aus wiederkehrenden Codestücken.
5. **Löschen Sie die Kopien.** Der zweite und der dritte Controller rufen dieselbe Action auf, die Duplikate verschwinden.

Jeder Schritt ist ein eigener kleiner PR, der sich zurückrollen lässt.

---

<a id="common-mistakes"></a>
## Häufige Fehler

**1. Die Action nimmt einen `Request` entgegen.**
Das Szenario ist wieder an HTTP gebunden und lässt sich aus einem Befehl oder Job nicht ohne Fake-Request aufrufen. Übergeben Sie ein DTO.

**2. DTO mit Geschäftslogik.**
Eine Methode `calculateTotal()` im DTO ist ein Service, der sich in einem Datenobjekt versteckt. Ein DTO transportiert nur Daten.

**3. Veränderliche DTOs.**
Öffentliche Properties ohne readonly erlauben es einem Schritt, die Daten für einen anderen zu ändern. Verwenden Sie `readonly`.

**4. God Service statt fettem Controller.**
Ein `OrderService` mit zweitausend Zeilen ist dasselbe Problem in einem neuen Ordner. Szenarien gehören in Action-Klassen, Services bleiben schmal.

**5. Seiteneffekte innerhalb der Transaktion.**
E-Mails, Webhooks und Queue-Jobs, die vor dem Commit ausgelöst werden, greifen auch beim Rollback. Verwenden Sie `ShouldDispatchAfterCommit`, `ShouldQueueAfterCommit` oder `afterCommit()`.

**6. Ein Interface für jede Klasse.**
Ein `PriceCalculatorInterface` mit einer einzigen Implementierung und ohne externe Grenze ist eine überflüssige Indirektionsebene. Laravel kann konkrete Klassen in Tests austauschen.

**7. Schichten um der Schichten willen.**
`UpdateUserNameAction` mit `UpdateUserNameData` für eine einzige Zeile `$user->update()` ist Zeremonie, keine Architektur.

---

<a id="checklist"></a>
## Checkliste

1. Der Controller übersetzt nur HTTP in einen Szenario-Aufruf und das Ergebnis zurück in HTTP.
2. Validierung und Autorisierung liegen im `FormRequest`, dort wird auch das DTO gebaut.
3. Ein Geschäftsszenario – eine Action-Klasse mit einer einzigen öffentlichen Methode.
4. Die Transaktionsgrenze wird in der Action festgelegt.
5. Seiteneffekte (E-Mails, Webhooks, Jobs) werden nach dem Commit ausgelöst.
6. DTOs sind `final readonly`, typisiert und ohne Logik; Geldbeträge nicht als `float`.
7. Services sind schmal; Interfaces nur für externe Grenzen und mehrere Implementierungen.
8. Zwischen Modulen werden DTOs oder IDs übergeben, keine Models.
9. Geschäftsregeln sind auf Ebene der Action getestet, ohne HTTP.

---

## Zusammenfassung

Bei Action-Klassen, Services und DTOs geht es nicht um schöne Ordner, sondern darum, dass jedes Geschäftsszenario genau einen Ort, eine Transaktionsgrenze und einen Satz Tests hat. Beginnen Sie beim Schmerz – Duplizierung, Seiteneffekte vor dem Commit, fehlende Testbarkeit – und führen Sie genau so viele Schichten ein, wie nötig sind, um ihn zu beseitigen. Ein einfaches CRUD darf einfach bleiben.

---

<a id="self-test-quiz"></a>
## Selbsttest-Quiz

### Frage 1: Wo sollte beim Aufgeben einer Bestellung die Transaktionsgrenze festgelegt werden?
- A) Im Controller, rund um den Aufruf der Action.
- B) In der Action-Klasse, die weiß, welche Schritte des Szenarios atomar ausgeführt werden müssen.
- C) In jedem Service einzeln – eine eigene Transaktion pro Aufruf.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Das Szenario weiß, dass die Bestellung, ihre Positionen und die Warenreservierung gemeinsam gespeichert werden müssen. Der Controller sollte solche Details nicht kennen, und separate Transaktionen in den Services sorgen nicht für die Atomarität des gesamten Szenarios.
</details>

### Frage 2: Innerhalb von `DB::transaction()` wird die E-Mail „Bestellung aufgegeben“ verschickt, danach schlägt die Warenreservierung mit einer Exception fehl. Was passiert ohne zusätzliche Maßnahmen?
- A) Die E-Mail wird nicht verschickt, weil die Transaktion zurückgerollt wurde.
- B) Die E-Mail geht raus (oder der Job für den Versand landet in der Queue), obwohl es die Bestellung in der Datenbank nicht gibt.
- C) Laravel storniert die E-Mail automatisch über ein Rollback-Event.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Ein Rollback betrifft nur die Datenbank. Externe Seiteneffekte müssen nach dem Commit ausgelöst werden: über `ShouldDispatchAfterCommit` bei Events, `ShouldQueueAfterCommit` oder `afterCommit()` bei Jobs.
</details>

### Frage 3: Das Modul `Billing` soll für eine Bestellung Geld abbuchen. Was übergibt man ihm aus dem Modul `Orders` am besten?
- A) Das komplette `Order`-Model, damit `Billing` die benötigten Relationen selbst laden kann.
- B) Ein DTO mit Betrag, Währung und Referenz auf die Bestellung oder die ID der Bestellung.
- C) Das Array `$request->all()` aus dem ursprünglichen Request.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Das DTO legt fest, welche Daten das Modul braucht, und verhindert, dass es fremde Datensätze ändert oder über Relationen versteckte Abfragen ausführt. Das Schema der Tabelle `orders` ist damit für andere Module keine öffentliche API mehr.
</details>