Readonly, enums, attributes w PHP
PHP długo kojarzył się z językiem, w którym wiele reguł trzeba było egzekwować "umową zespołu", testami i dyscypliną programistów. Wraz z pojawieniem się readonly, enum i atrybutów ten model zaczął się wyraźnie zmieniać. Coraz więcej decyzji projektowych można dziś wyrazić bezpośrednio w kodzie, a nie w komentarzach, konwencjach czy rozproszonych plikach konfiguracyjnych. To nie są tylko wygodne dodatki do składni. Te trzy mechanizmy wpływają na to, jak modelujesz domenę, jak projektujesz API klas i jak ograniczasz liczbę błędów, zanim jeszcze trafią do testów lub produkcji.
PHP coraz bardziej typuje intencję
W nowoczesnym PHP coraz mniej miejsca zostaje na „luźne” modele danych. Zamiast obiektów, które można modyfikować w dowolnym momencie, coraz częściej buduje się małe, wyraźne typy: niezmienne obiekty wartości, zamknięte zbiory stanów i deklaratywne metadane. Taki styl projektowania ma bardzo praktyczne konsekwencje. Kod staje się bardziej przewidywalny, łatwiejszy do refaktoryzacji i mniej podatny na przypadkowe błędy. Widać też wyraźnie, że język zaczął wspierać bardziej domenowe myślenie: zamiast „jak przechować dane”, pytasz „co ten obiekt oznacza” i „jakie ma dozwolone wartości”.readonly jako kontrakt niezmienności
Największą zaletą readonly jest to, że przenosi część odpowiedzialności z programisty na język. Jeśli obiekt ma reprezentować konkretny stan, to po jego utworzeniu nie powinien się już zmieniać. Właśnie do tego świetnie nadają się klasy readonly. Najczęściej używa się ich do DTO, command objects i value objects. Dzięki temu nie trzeba pilnować, czy ktoś później nie nadpisze pola przypadkowym setterem albo nie zmieni wartości w środku pipeline’u.
<?php
declare(strict_types=1);
final readonly class Money
{
public function __construct(
public string $currency,
public int $amountInMinorUnits,
) {
if ($currency === '') {
throw new InvalidArgumentException('Currency cannot be empty.');
}
if ($amountInMinorUnits < 0) {
throw new InvalidArgumentException('Amount cannot be negative.');
}
}
public function format(): string
{
return sprintf('%s %s', $this->amountInMinorUnits / 100, $this->currency);
}
}
Ten przykład pokazuje ważną zmianę w stylu projektowania: obiekt nie jest już „pojemnikiem na dane”, tylko nośnikiem reguł. Walidacja dzieje się przy tworzeniu, a po tym momencie stan pozostaje stabilny. To bardzo dobrze działa w modelu domenowym, bo zmniejsza liczbę miejsc, w których trzeba sprawdzać spójność danych.
<?php
declare(strict_types=1);
final readonly class OrderLine
{
public function __construct(
public string $productId,
public int $quantity,
public Money $unitPrice,
) {
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity must be greater than zero.');
}
}
public function total(): Money
{
return new Money(
currency: $this->unitPrice->currency,
amountInMinorUnits: $this->unitPrice->amountInMinorUnits * $this->quantity,
);
}
}
W takim układzie OrderLine nie wymaga setterów, a logika domenowa pozostaje blisko danych, których dotyczy. To dużo czytelniejsze niż klasy, które są jednocześnie rekordem, walidatorem i miejscem przechowywania tymczasowego stanu.
enum zamiast magicznych stringów
Enumeracje rozwiązują jeden z najstarszych problemów w aplikacjach biznesowych: zbiór dozwolonych wartości zapisany jako zwykłe stringi. Jeśli status zamówienia albo typ płatności jest przekazywany jako tekst, to literówki, niekonsekwentne nazwy i rozjazdy między warstwami aplikacji są tylko kwestią czasu. Enum zmienia tę sytuację radykalnie. Zamiast „jakiegoś stringa” masz typ, który jasno definiuje zamknięty zbiór opcji.
<?php
declare(strict_types=1);
enum OrderStatus: string
{
case Draft = 'draft';
case PendingPayment = 'pending_payment';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Draft => 'Szkic',
self::PendingPayment => 'Oczekuje na płatność',
self::Paid => 'Opłacone',
self::Shipped => 'Wysłane',
self::Cancelled => 'Anulowane',
};
}
public function isEditable(): bool
{
return match ($this) {
self::Draft, self::PendingPayment => true,
self::Paid, self::Shipped, self::Cancelled => false,
};
}
}
Taki enum to nie tylko lista wartości, ale też mały, dobrze zdefiniowany fragment logiki domenowej. Dzięki metodom pomocniczym możesz przenieść reguły blisko samej definicji typu, zamiast rozrzucać switch i if po wielu klasach.
<?php
declare(strict_types=1);
final class Order
{
public function __construct(
private string $id,
private OrderStatus $status,
) {}
public function status(): OrderStatus
{
return $this->status;
}
public function canBeEdited(): bool
{
return $this->status->isEditable();
}
}
To podejście jest dużo bezpieczniejsze niż operowanie na string $status. Jeśli później dodasz nowy status, kompilator i analiza statyczna szybciej pokażą miejsca wymagające obsługi.
W przypadku danych z formularzy lub API możesz jawnie konwertować wartość wejściową na enum. To od razu separuje świat zewnętrzny od wewnętrznego modelu aplikacji.
<?php
declare(strict_types=1);
$status = OrderStatus::tryFrom($requestData['status'] ?? '');
if ($status === null) {
throw new InvalidArgumentException('Unknown order status.');
}
To proste, ale bardzo ważne. Zamiast przepuszczać niewalidowane dane dalej, od razu zamieniasz je na typ domenowy albo przerywasz proces.
Atrybuty jako metadane przy kodzie
Atrybuty są szczególnie wartościowe tam, gdzie wcześniej używało się komentarzy, adnotacji albo osobnych plików konfiguracyjnych. Ich największa przewaga polega na tym, że metadane są umieszczone dokładnie tam, gdzie dotyczą konkretnego elementu. Dzięki temu model staje się bardziej samodokumentujący. Patrzysz na klasę i od razu widzisz, jakie ma ograniczenia, jak jest walidowana albo jak ma być mapowana przez framework.
<?php
declare(strict_types=1);
use Symfony\Component\Validator\Constraints as Assert;
final readonly class RegisterUserRequest
{
public function __construct(
#[Assert\NotBlank(message: 'Email is required.')]
#[Assert\Email(message: 'Invalid email address.')]
public string $email,
#[Assert\NotBlank(message: 'Password is required.')]
#[Assert\Length(min: 12, minMessage: 'Password must have at least 12 characters.')]
public string $password,
) {}
}
To rozwiązanie dobrze pasuje do aplikacji, w których walidacja ma charakter deklaratywny. Zamiast rozpraszać reguły po osobnych serwisach, opisujesz je przy strukturze danych wejściowych. Z punktu widzenia utrzymania to duża oszczędność, bo reguły i model zaczynają żyć razem.
Atrybuty świetnie sprawdzają się też w mapowaniu zdarzeń, routingu czy integracjach z ORM.
<?php
declare(strict_types=1);
use App\Attribute\CommandHandler;
#[CommandHandler('create_order')]
final readonly class CreateOrderCommand
{
public function __construct(
public string $customerId,
public array $items,
) {}
}
W takim wariancie atrybut nie zastępuje logiki biznesowej, ale ułatwia frameworkowi lub własnej infrastrukturze zrozumienie, jak ma obsłużyć daną klasę. To właśnie ten poziom użycia jest najzdrowszy: metadane opisują zachowanie, ale go nie implementują.
Jak te mechanizmy zmieniają projektowanie?
Najciekawsze w readonly, enum i atrybutach jest to, że nie działają tylko lokalnie. One wpływają na cały sposób myślenia o kodzie. Po pierwsze, przesuwają model w stronę niezmienności. Jeśli dane raz utworzone nie powinny się zmieniać, readonly wymusza ten kierunek i upraszcza rozumienie programu. Po drugie, ograniczają przestrzeń błędu. Enumy eliminują przypadkowe wartości, a więc też sporą klasę bugów związanych z literówkami i rozbieżnościami. Po trzecie, zwiększają deklaratywność. Atrybuty pozwalają opisywać rzeczywistość blisko kodu, bez rozbijania konfiguracji na wiele miejsc. Efekt końcowy jest bardzo praktyczny: kod staje się bardziej czytelny, bardziej przewidywalny i łatwiejszy do testowania.Wzorzec, który warto powielać
Dobry nowoczesny model w PHP często łączy te trzy mechanizmy w jednej osi projektowej: - dane wejściowe są reprezentowane przez readonly DTO, - ograniczone zestawy wartości są modelowane przez enum, - walidacja i metadane są opisywane przez atrybuty.
<?php
declare(strict_types=1);
enum PaymentMethod: string
{
case Card = 'card';
case Blik = 'blik';
case Transfer = 'transfer';
}
final readonly class CreatePaymentRequest
{
public function __construct(
#[Assert\NotBlank]
public string $orderId,
#[Assert\NotNull]
public PaymentMethod $method,
#[Assert\Positive]
public int $amountInMinorUnits,
) {}
}
Taki kod jest zwięzły, ale nie ubogi. Każdy element niesie konkretną intencję: readonly ogranicza mutację, enum kontroluje wybór metody płatności, a atrybuty dokumentują i wspierają walidację.
Praktyczne wskazówki
Jeśli chcesz wdrażać te mechanizmy rozsądnie, trzymaj się kilku zasad.- Używaj readonly dla obiektów, które reprezentują stan, a nie przebieg procesu.
- Stosuj enum dla zamkniętych zbiorów wartości, szczególnie statusów, typów i ról.
- Umieszczaj atrybuty przy danych lub klasach, których dotyczą, zamiast budować osobne warstwy "magicznej" konfiguracji.
- Nie przenoś całej logiki do atrybutów. Powinny opisywać zachowanie, a nie zastępować logikę biznesową.
- Staraj się łączyć te elementy z małymi, jednoznacznymi klasami zamiast z rozbudowanymi obiektami "do wszystkiego".