kamilkozak.dev
Published on

Eloquent i logika biznesowa – unikanie anemicznego modelu

Wprowadzenie

W Laravelu za reprezentację i obsługę danych w bazie odpowiadają modele Eloquent. Prócz samej warstwy mapowania encji na tabele, Laravel wzbogaca modele o system zdarzeń, zapytań czy akcesorów, co ułatwia tworzenie nawet bardziej złożonych funkcjonalności. W tym wpisie pokażę, w jaki sposób można bezpośrednio korzystać z bogactwa Eloquent i umieszczać logikę biznesową w modelach – tak, by kod pozostał wygodny w utrzymaniu. Warto wspomnieć, że o tym podejściu napisał na twitterze Taylor Otwell (https://twitter.com/taylorotwell/status/1440328182306000903).

Modele jako serce logiki biznesowej

To właśnie modele reprezentują rzeczywiste byty występujące w biznesie i doskonale nadają się do przechowywania logiki z nim związanej. Umożliwia to pisanie kodu wprost odzwierciedlającego operacje biznesowe – przykładowo:

  • Naliczanie rabatów czy podatków (metody kalkulacyjne),
  • Obsługa akcji typu send() czy markAsPaid(),
  • Generowanie dokumentów związanych z danym obiektem (np. faktury).

Kluczową zaletą jest bliskie powiązanie danych z zachowaniem – nie trzeba szukać w innych klasach reguł, które dotyczą podstawowej działalności modelu. Obiekty posiadające stan i zachowanie (metody z intencją zmiany stanu) to także podstawowe pojęcia z OOP (Object Oriented Programming) i DDD (Domain Driven Design).

Kiedy używać akcji?

W sytuacjach, w których logika biznesowa staje się wyjątkowo rozbudowana, warto jednak zadbać o czytelność kodu. Najlepszym rozwiązaniem bywa wtedy osobna klasa Action (np. w katalogu Actions), która faktycznie wykonuje duże lub wieloetapowe operacje. Jednak sam model wciąż powinien inicjować tę akcję – np. przez dedykowaną metodę, która decyduje o tym, jak i kiedy zainicjować daną czynność.

Przykładowo, w klasie Invoice można umieścić logikę naliczania rabatu tak:

class Invoice extends Model
{
    public function applyDiscount(): void
    {
        // Prosta logika – np. sprawdzenie czy rabat jest dostępny
        if ($this->discountEligible()) {
            // Delegacja do akcji wykonującej faktyczne obliczenia
            app(ApplyDiscountAction::class)->execute($this);
        }
    }

    public function discountEligible(): bool
    {
        // Prostszą logikę można zostawić w modelu
        return $this->total > 100;
    }
}

W ten sposób model zachowuje się jako pierwsza linia interpretacji reguł biznesowych, a jednocześnie przy większej liczbie kroków obliczeniowych pozwala przerzucić „ciężar” do klasy akcji.

Zalety łączenia danych i zachowań

  1. Czytelność – Metoda typu send() jasno pokazuje, że to obiekt (np. zamówienie) sam odpowiada za swoją wysyłkę.
  2. Spójność – Jedna klasa zawiera wszystko, co dotyczy danej encji i najważniejszych procesów wokół niej.
  3. Unikanie anemii – Wzorzec „anemicznego modelu” (same pola, zero zachowań) bywa krytykowany za utrudnianie zrozumienia domeny. Umieszczenie logiki w modelu wzmacnia spójność.
  4. Efektywne wykorzystanie Eloquent – Klasy Eloquent posiadają bogate API (eventy, akcesory, mutatory), przez co umieszczanie logiki biznesowej może być prostsze i szybsze.

Ograniczanie rozrostu modeli

Choć model może być wygodnym miejscem dla kluczowych operacji, nie powinien on pełnić roli monolitu przechowującego setki linii kodu. Jeśli zauważysz, że w modelu pojawia się coraz więcej metod związanych z filtracją lub konkretnym zakresem zapytań, rozważ przeniesienie tych fragmentów do dedykowanych Query Builderów lub kolekcji Eloquent.

Przykład Query Buildera:

namespace Domain\Invoices\QueryBuilders;

use Illuminate\Database\Eloquent\Builder;

class InvoiceQueryBuilder extends Builder
{
    public function wherePaid(): self
    {
        return $this->where('status', 'paid');
    }
}

Następnie w modelu:

class Invoice extends Model
{
    public function newEloquentBuilder($query): InvoiceQueryBuilder
    {
        return new InvoiceQueryBuilder($query);
    }
}

Dzięki temu unikasz zaśmiecania klasy modelu kolejnymi scope’ami, jednocześnie zyskując czytelny, reużywalny kod.

Kolekcje Eloquent (Custom Collections) z kolei pozwalają wydzielić logikę operującą na wielu rekordach naraz (np. sumowanie, filtrowanie). Wystarczy nadpisać metodę newCollection() w modelu, aby zwracała własną klasę kolekcji.

namespace Domain\Invoices\Collections;

use Illuminate\Database\Eloquent\Collection;

class InvoiceLineCollection extends Collection
{
    public function creditLines(): self
    {
        return $this->filter(fn ($invoiceLine) => $invoiceLine->isCreditLine());
    }
}

W modelu wskazujemy, że to właśnie tej klasy chcemy używać jako kolekcji:

namespace Domain\Invoices\Models;

use Domain\Invoices\Collections\InvoiceLineCollection;
use Illuminate\Database\Eloquent\Model;

class InvoiceLine extends Model
{
    public function newCollection(array $models = []): InvoiceLineCollection
    {
        return new InvoiceLineCollection($models);
    }

    public function isCreditLine(): bool
    {
        // Logika określająca, czy dana linia to korekta
        return $this->price < 0.0;
    }
}

Zdarzenia (Events)

Laravel oferuje system wbudowanych zdarzeń Eloquent, dzięki którym można reagować na różne momenty cyklu życia encji (saving, saved, deleting itp.). Jeśli jednak projekt wymaga bardziej precyzyjnej kontroli lub łączenia wielu akcji w odpowiedzi na zdarzenia, dobrym rozwiązaniem może być wprowadzenie dedykowanych klas zdarzeń i subskrybentów:

class InvoiceSaving
{
    public function __construct(public Invoice $invoice)
    {
        // ...
    }
}

class InvoiceSubscriber
{
    public function saving(InvoiceSaving $event): void
    {
        // Logika, np. przeliczenie danych w momencie zapisu
    }
}

W modelu:

class Invoice extends Model
{
    protected $dispatchesEvents = [
        'saving' => InvoiceSaving::class,
    ];
}

Takie podejście pozwala utrzymać porządek w kodzie i w prosty sposób łączyć wiele akcji czy obsługiwać nietypowe przypadki. Dzięki temu model nadal pełni ważną funkcję w definiowaniu zdarzeń i wykonywaniu podstawowych operacji, lecz szczegółowa logika może być przeniesiona do odpowiednich subskrybentów.

Delegowanie złożonej logiki do akcji

Oczywiście należy zachować umiar: model nie powinien stawać się monolitycznym, kilkuset-liniowym plikiem trudnym w utrzymaniu. Najlepsza praktyka to:

  • Proste operacje – obliczenia, walidacje, krótkie akcje – można trzymać bezpośrednio w metodach modelu.
  • Złożone, wieloetapowe procesy – delegować do klasy akcji, np. ProcessInvoiceAction. W modelu wywołujemy metodę, która jest punktem startowym całego procesu.

Przykładowo:

class Invoice extends Model
{
    public function processInvoice(): void
    {
        // Przykładowe przygotowanie danych
        if (!$this->isReadyForProcessing()) {
            throw new \Exception('Faktura nie jest gotowa do przetworzenia!');
        }

        // Delegacja wykonania do Akcji
        app(ProcessInvoiceAction::class)->execute($this);
    }

    public function isReadyForProcessing(): bool
    {
        return $this->status === 'new' && $this->items->isNotEmpty();
    }
}

Zachowujemy zatem czytelne API w modelu, a jeśli proces ten okaże się szczególnie skomplikowany (kilka kroków obliczeń, wywołanie zewnętrznych usług, itp.), klasa akcji stanie się miejscem na rozbudowane szczegóły implementacyjne.

Podsumowanie

Umieszczanie logiki biznesowej w modelach to podejście, które może znacząco ułatwić pracę nad kodem w średnich i większych projektach, zwłaszcza jeśli:

  • Dbamy o zachowanie czytelności poprzez łączenie danych z zachowaniem w jednej klasie,
  • W przypadku skomplikowanych procesów korzystamy z klas akcji wspierających model,
  • Stosujemy narzędzia oferowane przez Eloquent w przemyślany sposób (dedykowane Query Buildery, kolekcje),
  • Sprawnie zarządzamy cyklem życia encji za pomocą dedykowanych zdarzeń i subskrybentów.

Dzięki temu możemy odciążyć nasz kod z nadmiarowych warstw, zyskując jednocześnie wyraźny i czytelny podział. W efekcie otrzymujemy projekt, w którym modele stanowią faktyczne odzwierciedlenie domeny, a klasy akcji lub subskrybentów zdarzeń pełnią rolę wyspecjalizowanego wsparcia – wszędzie tam, gdzie procesy stają się rozbudowane lub wymagają osobnego miejsca na złożone operacje.