- 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()
czymarkAsPaid()
, - 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ń
- Czytelność – Metoda typu
send()
jasno pokazuje, że to obiekt (np. zamówienie) sam odpowiada za swoją wysyłkę. - Spójność – Jedna klasa zawiera wszystko, co dotyczy danej encji i najważniejszych procesów wokół niej.
- Unikanie anemii – Wzorzec „anemicznego modelu” (same pola, zero zachowań) bywa krytykowany za utrudnianie zrozumienia domeny. Umieszczenie logiki w modelu wzmacnia spójność.
- 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.