Po co PHP-owcowi programowanie obiektowe w codziennych projektach
PHP jako język, który dorósł do dużych systemów
PHP zaczynało jako prosty język skryptowy do generowania stron HTML. Dzisiejsze aplikacje PHP to jednak rozbudowane systemy: sklepy, CRM-y, panele administracyjne, systemy rezerwacji, API mobilne. Pojawiają się setki plików, dziesiątki zależności i rosnące wymagania biznesowe. Bez programowania obiektowego taki projekt szybko zamienia się w nieutrzymywalną mieszankę funkcji i include’ów.
Od wersji 5 w górę, szczególnie 7 i 8, PHP oferuje pełnoprawne wsparcie dla programowania obiektowego: klasy, interfejsy, typy, wyjątki, autoloading, przestrzenie nazw. To sprawia, że można pisać kod porównywalny jakościowo z aplikacjami w Javie czy C#. Same frameworki (Symfony, Laravel) są zbudowane obiektowo i wymuszają pewien poziom dyscypliny projektowej.
Programowanie obiektowe w PHP to nie „ładniejsza składnia”, ale fundament do skalowania projektu, dzielenia zadań w zespole i wprowadzania zmian bez ciągłego strachu przed nieoczekiwaną regresją.
Gdzie OOP realnie pomaga w codziennym projekcie
Największy zysk z programowania obiektowego w PHP pojawia się w projektach, które żyją dłużej niż kilka tygodni. Jeśli aplikacja:
- ma być rozwijana przez wielu developerów równolegle,
- będzie integrować się z zewnętrznymi systemami (płatności, dostawcy, API),
- wymaga testów automatycznych,
- ma być wdrażana na kilku środowiskach (dev, stage, prod),
to podejście obiektowe staje się naturalnym wyborem.
Klasy dzielą kod według odpowiedzialności: inne klasy odpowiadają za logikę biznesową, inne za dostęp do bazy, inne za komunikację z API czy renderowanie odpowiedzi. Łatwiej też zarządzać zmianą: kiedy dodajesz nowy sposób płatności, dopisujesz nową klasę implementującą istniejący interfejs, zamiast rozpychać if-ami jedną wielką funkcję.
Różnica odczuwalna dla developera
Kod obiektowy wpływa bezpośrednio na komfort pracy. Jeśli każda klasa ma jasno określoną odpowiedzialność, to:
- łatwiej znaleźć miejsce w kodzie, gdzie trzeba coś zmienić,
- review skupia się na logice, a nie na chaosie strukturalnym,
- testy jednostkowe stają się prostsze (klasy z małą liczbą zależności łatwo mockować),
- regresje pojawiają się rzadziej, bo zmiana w jednym komponencie mniej ingeruje w inne.
W praktyce widać to choćby przy pracy z Gitem. Małe, dobrze wydzielone klasy generują mniejsze diffy w PR-ach. Dwie osoby mogą równolegle rozwijać różne obszary, modyfikując inne klasy zamiast przepychać się w jednym pliku z logiką proceduralną.
Gdzie w typowym dniu developera pojawiają się obiekty
Typowy dzień PHP developera w projekcie opartym na frameworku (Laravel, Symfony) to praca z takimi elementami jak:
- Modele – klasy reprezentujące encje domenowe (User, Order, Product), zawierające logikę związaną z tymi bytami.
- Serwisy/aplikacyjne use-case’y – klasy, które implementują konkretne operacje biznesowe (RegisterUserService, CreateOrderHandler).
- Kontrolery – klasy mapujące requesty HTTP na działania w systemie i odpowiedź dla klienta.
- Repozytoria – klasy odpowiedzialne za dostęp do danych, zwykle opakowujące ORM lub bezpośrednie zapytania SQL.
- Value Objecty – drobne klasy typu Email, Money, Address, enkapsulujące reguły walidacji i zachowania.
Każdy z tych elementów to obiekty z jasno zdefiniowanym API. Dzięki temu kod w kontrolerze nie musi wiedzieć, jak dokładnie wygląda baza danych czy jak zbudowane jest zewnętrzne API – wystarczy, że współpracuje z klasami przez ich interfejsy.
Fundamenty OOP w PHP – klasy, obiekty, typy i właściwości
Definicja klasy i obiektu w PHP
Klasa w PHP to szablon opisujący stan (właściwości) i zachowanie (metody). Obiekt to instancja klasy, konkretny „egzemplarz” z własnymi wartościami pól. Minimalna definicja klasy wygląda następująco:
<?php
class User
{
public string $email;
public function __construct(string $email)
{
$this->email = $email;
}
}
$user = new User('john@example.com');
Słowo kluczowe class definiuje klasę, a __construct() to konstruktor, który wykonuje się przy tworzeniu obiektu. Wywołanie new User(...) zwraca nową instancję.
Właściwości, metody i widoczność
Właściwości (properties) to pola przechowujące stan obiektu. Metody to funkcje zdefiniowane wewnątrz klasy, które operują na tym stanie. Każda właściwość i metoda ma modyfikator dostępu:
- public – dostępna z każdego miejsca,
- protected – dostępna w klasie i klasach dziedziczących,
- private – dostępna tylko w obrębie tej klasy.
class User
{
private string $passwordHash;
private bool $active = false;
public string $email;
public function __construct(string $email, string $password)
{
$this->email = $email;
$this->passwordHash = password_hash($password, PASSWORD_DEFAULT);
}
public function activate(): void
{
$this->active = true;
}
public function changePassword(string $newPassword): void
{
$this->passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
}
public function isActive(): bool
{
return $this->active;
}
}
Hasło użytkownika i flaga aktywności są prywatne, bo nie powinny być modyfikowane bezpośrednio spoza klasy. Zamiast tego udostępniane są metody, które kontrolują zmianę stanu. Taki układ zapobiega „strzelaniu sobie w stopę” przez przypadkowe podmiany wartości z zewnątrz.
Typy w PHP: argumenty, zwroty, nullables
Nowe wersje PHP umożliwiają silniejsze typowanie. Dla programowania obiektowego to kluczowe, bo typy stają się częścią kontraktu między klasami. Można typować:
- argumenty metod:
public function changePassword(string $newPassword), - typ zwracany:
public function isActive(): bool, - właściwości:
private string $passwordHash;.
Warto korzystać również z typów złożonych i nullowalnych:
public function getPhoneNumber(): ?string
{
return $this->phoneNumber; // może być string lub null
}
Typy złożone (union types) pozwalają na bardziej elastyczne API, ale nie należy ich nadużywać:
public function setDiscount(float|int $discount): void
{
$this->discount = (float) $discount;
}
Silne typowanie:
- chroni przed błędami w czasie uruchomienia,
- ułatwia IDE podpowiedzi i refaktoryzację,
- tworzy automatyczną „dokumentację” kontraktów między klasami.
Realistyczny przykład klasy domenowej User
Rozbudowując przykład użytkownika, można dodać metody odzwierciedlające typowe operacje z codziennej pracy developera:
class User
{
private string $email;
private string $passwordHash;
private bool $active = false;
private DateTimeImmutable $createdAt;
public function __construct(string $email, string $plainPassword)
{
$this->email = strtolower(trim($email));
$this->setPassword($plainPassword);
$this->createdAt = new DateTimeImmutable();
}
public function activate(): void
{
if ($this->active) {
return;
}
$this->active = true;
}
public function deactivate(): void
{
$this->active = false;
}
public function changePassword(string $oldPassword, string $newPassword): void
{
if (!password_verify($oldPassword, $this->passwordHash)) {
throw new RuntimeException('Invalid current password.');
}
$this->setPassword($newPassword);
}
public function isActive(): bool
{
return $this->active;
}
public function getEmail(): string
{
return $this->email;
}
private function setPassword(string $plainPassword): void
{
if (strlen($plainPassword) < 8) {
throw new InvalidArgumentException('Password too short.');
}
$this->passwordHash = password_hash($plainPassword, PASSWORD_DEFAULT);
}
}
Klasa pilnuje spójności swoich danych. Nie ma możliwości nadania hasła krótszego niż określony limit, a zmiana hasła wymaga podania starego. To jest sedno programowania obiektowego: logika sklejona ze stanem, który chroni.
Nazewnictwo klas i metod zdradzające intencje
Dobrze nazwane klasy i metody redukują potrzebę komentarzy. Kilka praktycznych zasad:
- Klasa powinna być rzeczownikiem – User, Order, PasswordHasher, a nie DoUser().
- Metody powinny być czasownikami lub frazami opisującymi akcję – activate(), changePassword(), calculateTotal().
- Unikaj nazw typu „Manager”, „Helper”, „Util” – zwykle to sygnał, że klasa ma zbyt wiele odpowiedzialności.
- Metody boole’owe niech zaczynają się od is / has / can: isActive(), hasDiscount(), canBeCancelled().
Jeśli metoda nazywa się process() albo handle() i bez docblocka nie wiadomo, co robi – to prawdopodobnie sygnał, że trzeba ją albo podzielić, albo lepiej nazwać. Kod powinien tłumaczyć się sam, szczególnie w zespołach.
Dziedziczenie, kompozycja i polimorfizm – decyzje, które wracają w review
Dziedziczenie w PHP: extends, protected, final
Dziedziczenie pozwala rozszerzyć istniejącą klasę o nowe zachowania lub nadpisać część logiki. Składnia jest prosta:
class BaseController
{
protected function json(array $data, int $status = 200): SymfonyComponentHttpFoundationJsonResponse
{
return new JsonResponse($data, $status);
}
}
class UserController extends BaseController
{
public function profile(): JsonResponse
{
$user = ...;
return $this->json(['email' => $user->getEmail()]);
}
}
Modyfikator protected umożliwia używanie metody w klasach pochodnych. private byłby tu nieprzydatny, bo metoda nie byłaby widoczna w UserController. Słowo kluczowe final blokuje dalsze dziedziczenie lub nadpisywanie metod, jeśli trzeba zatrzymać łańcuch rozszerzeń.
Problem pojawia się, gdy klasa bazowa rośnie i staje się zbiorem narzędzi używanych w różnych kontekstach. Podklasy zaczynają ignorować część pól/metod, a nadpisywać inne w nieprzewidywalny sposób. Takie hierarchie trudno zrefaktoryzować, bo mnóstwo kodu opiera się na wspólnych polach.
Kompozycja jako domyślny wybór
Zamiast dziedziczenia lepiej sięgać po kompozycję. Kompozycja polega na tym, że klasa nie rozszerza innej, ale posiada jej instancję i deleguje do niej część zadań. Zamiast:
class BaseController
{
protected LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
protected function logInfo(string $message): void
{
$this->logger->info($message);
}
}
class OrderController extends BaseController
{
public function create(): Response
{
$this->logInfo('Creating order');
// ...
}
}
lepiej:
class OrderController
{
public function __construct(
private LoggerInterface $logger,
private OrderCreator $orderCreator
) {}
public function create(): Response
{
$this->logger->info('Creating order');
$order = $this->orderCreator->createFromRequest();
// ...
}
}
Kontroler nie musi już niczego dziedziczyć. Zamiast wspólnej, rozrastającej się klasy bazowej, ma jasno nazwane zależności. Dodanie kolejnej funkcjonalności nie wymaga dopisywania metod do BaseController, lecz stworzenia nowej wyspecjalizowanej klasy serwisu.
Polimorfizm w praktyce: różne implementacje jednej abstrakcji
Polimorfizm to zdolność do traktowania różnych obiektów tak samo, jeśli implementują ten sam kontrakt (interfejs lub klasę bazową). Typowy przykład w codziennej pracy PHP developera to bramki płatności czy system logowania.
interface PaymentGateway
{
public function charge(float $amount, string $currency): PaymentResult;
}
class StripePaymentGateway implements PaymentGateway
{
public function charge(float $amount, string $currency): PaymentResult
{
// implementacja Stripe
}
}
class PayPalPaymentGateway implements PaymentGateway
{
public function charge(float $amount, string $currency): PaymentResult
{
// implementacja PayPal
}
}
class PaymentService
{
public function __construct(private PaymentGateway $gateway)
{
}
public function payForOrder(Order $order): PaymentResult
{
return $this->gateway->charge($order->getTotal(), 'PLN');
}
}
W tym układzie PaymentService nie zna szczegółów integracji z konkretnym dostawcą. Wie tylko, że ma obiekt realizujący kontrakt PaymentGateway. Jeśli pojawia się nowa bramka (np. przelew ekspresowy), dopisujesz kolejną klasę implementującą interfejs i zmieniasz konfigurację DI, zamiast przekopywać logikę płatności po całym kodzie.
Ten sam wzorzec dobrze działa przy wysyłce powiadomień (NotificationSender z implementacjami e-mail, SMS, Slack), integracjach magazynowych, a nawet przy zapisie logów (różne adaptery LoggerInterface). Jeżeli gdzieś w kodzie pojawia się instrukcja switch lub rozgałęzienia typu if ($type === 'stripe'), to zwykle sygnał, że brakuje warstwy polimorficznej.
Praktycznym kryterium jest częstotliwość zmian. Jeśli implementacja danego zachowania zmienia się częściej niż jego interfejs (sygnatura metody, jej intencja), to sensownie jest wydzielić interfejs i kilka klas z konkretnymi strategiami. Interfejs pozostaje stabilny, a różne implementacje mogą się rozwijać niezależnie, co mocno upraszcza testy i deployment.
Z perspektywy codziennej pracy wynik jest prosty: mniej ifów rozrzuconych po projekcie, więcej małych klas o jednej odpowiedzialności, wyraźniejsze kontrakty między modułami i mniejsza presja przy zmianach. To właśnie dzięki takim decyzjom obiektowy kod PHP przestaje być „magazynem funkcji”, a zaczyna być modelem problemu, który da się utrzymywać latami.

Interfejsy jako sposób na rozdzielenie decyzji technicznych od biznesowych
Interfejs w PHP to kontrakt: zbiór metod, które klasa musi zaimplementować. Dla developera oznacza to oddzielenie decyzji co klasa potrafi od decyzji jak to robi. Dzięki temu warstwa domenowa nie musi znać szczegółów frameworka, bazy danych czy zewnętrznego API.
interface UserRepository
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
public function save(User $user): void;
}
class DoctrineUserRepository implements UserRepository
{
public function __construct(private EntityManagerInterface $em) {}
public function findById(int $id): ?User
{
return $this->em->find(User::class, $id);
}
public function findByEmail(string $email): ?User
{
return $this->em
->getRepository(User::class)
->findOneBy(['email' => $email]);
}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
}Warstwa aplikacyjna widzi tylko UserRepository. Czy za nim stoi Doctrine, PDO, API GraphQL czy plik JSON – to szczegół konfiguracji. Zespół może rozwijać logikę użytkowników bez blokady po stronie DevOpsów i odwrotnie.
Interfejsy szczególnie dobrze sprawdzają się tam, gdzie:
- może powstać więcej niż jedna implementacja (np. testowa, produkcyjna, legacy),
- operacja jest krytyczna biznesowo (płatności, zamówienia, loginy),
- logika domenowa nie powinna „widzieć” frameworka (np. brak zależności od Symfony w klasach domenowych).
Częsty błąd to tworzenie interfejsu „bo tak się robi”, bez realnej potrzeby i tylko dla jednej, nigdy niezmienianej implementacji. Kontrakt ma sens, gdy stabilna jest intencja (np. „zapisz użytkownika”), natomiast szczegóły techniczne zmieniają się częściej lub mogą się zmienić.
Kiedy interfejs, a kiedy klasa abstrakcyjna
Interfejs definiuje tylko metody. Klasa abstrakcyjna może zawierać zarówno deklaracje metod abstrakcyjnych, jak i wspólną implementację oraz pola. W PHP często klasa abstrakcyjna bywa nadużywana jako „półprodukt” między strukturą danych a logiką, co potem trudno utrzymać.
Przejrzyste kryteria:
- Interfejs, jeśli chcesz tylko kontrakt, zero kodu wspólnego. Użyteczny w warstwie domenowej i przy integracjach.
- Klasa abstrakcyjna, jeśli kilka klas ma wspólne zachowanie i stan, którego nie chcesz powielać. Użyteczne np. przy obsłudze zdarzeń, schedulerów, wspólnej walidacji.
interface EventHandler
{
public function handle(object $event): void;
}
abstract class AsyncEventHandler implements EventHandler
{
public function handle(object $event): void
{
if (!$this->supports($event)) {
return;
}
$this->enqueue($event);
}
abstract protected function supports(object $event): bool;
abstract protected function enqueue(object $event): void;
}
class UserRegisteredAsyncHandler extends AsyncEventHandler
{
protected function supports(object $event): bool
{
return $event instanceof UserRegisteredEvent;
}
protected function enqueue(object $event): void
{
// wrzuć do kolejki, np. RabbitMQ
}
}Interfejs EventHandler jest kontraktem widocznym „na zewnątrz”, a abstrakcyjna klasa AsyncEventHandler dostarcza część zachowania współdzielonego tylko przez niektórych handlerów. Klient widzi kontrakt, a detale asynchroniczności są ukryte.
Kontrakty a testowalność kodu
Kiedy warstwa domenowa opiera się na interfejsach, testy jednostkowe robią się prostsze. Zamiast walczyć z pełną implementacją repozytorium, tworzysz prosty stub lub mock.
class InMemoryUserRepository implements UserRepository
{
/** @var User[] */
private array $users = [];
public function findById(int $id): ?User
{
return $this->users[$id] ?? null;
}
public function findByEmail(string $email): ?User
{
foreach ($this->users as $user) {
if ($user->getEmail() === $email) {
return $user;
}
}
return null;
}
public function save(User $user): void
{
$this->users[$user->getId()] = $user;
}
}Taki in-memory repozytorium nie wymaga bazy, migracji ani kontenera DI – da się go utworzyć bezpośrednio w teście. Logika biznesowa jest dzięki temu testowana w izolacji od infrastruktury.
Przestrzenie nazw i autoloading jako narzędzia porządkowania projektu
Bez przestrzeni nazw średni projekt PHP zamienia się w stertę plików z powtarzającymi się nazwami klas. Konflikty typu Helper, Controller czy Service są wtedy nieuniknione. namespace pozwala nadać szerszy kontekst klasie, a standard PSR-4 wiąże przestrzeń nazw z katalogiem.
namespace AppUserDomain;
class User
{
// ...
}
namespace AppUserInfrastructure;
class DoctrineUserRepository implements UserRepository
{
// ...
}Organizacja katalogów może wynikać z modułów (np. User, Order, Billing) albo z warstw (np. Domain, Application, Infrastructure). Kluczowe jest, by reguła była spójna i komunikowała model mentalny projektu.
Podstawowa konfiguracja autoloadingu w Composerze
Composer umożliwia powiązanie przestrzeni nazw z fizycznym katalogiem. Bez tego kończy się na ręcznym require lub magii frameworka.
{
"autoload": {
"psr-4": {
"App": "src/"
}
}
}Po dodaniu lub zmianie konfiguracji autoloadingu trzeba wywołać:
composer dump-autoloadOd tej chwili dowolna klasa z przestrzeni nazw App... znajdująca się w src/ będzie ładowana automatycznie. W codziennej pracy sprowadza się to do trzech prostych reguł:
- nazwa pliku odpowiada nazwie klasy (
src/User/Domain/User.php→AppUserDomainUser), - brak ręcznego
require_once– wszystko ładuje się z automatu, - przy przenoszeniu pliku zawsze aktualizujesz
namespace, inaczej autoloading się rozjedzie.
Struktura katalogów a granice modułów
To, jak zorganizowany jest katalog src/, wpływa na to, jak zespół myśli o systemie. Dwa najpopularniejsze podejścia to:
- według warstw:
Domain/,Application/,Infrastructure/,UI/, - według modułów:
User/,Order/,Catalog/, każde z nich z wewnętrznym podziałem na warstwy.
src/
User/
Domain/
User.php
UserRepository.php
Application/
RegisterUserHandler.php
Infrastructure/
DoctrineUserRepository.php
Order/
Domain/
Application/
Infrastructure/Drugie podejście (modułowe) lepiej skaluje się w większych projektach. Zależności między modułami stają się czytelniejsze, da się łatwiej wydzielić osobny komponent czy mikroserwis. Dodatkowo review skupia się na jednym obszarze biznesowym zamiast przebiegać przez „poziome” foldery.
Projektowanie klas pod kątem zasady pojedynczej odpowiedzialności
Zasada pojedynczej odpowiedzialności (pierwsze „S” w SOLID) w praktyce oznacza: jedna klasa – jeden powód do zmiany. Jeśli klasa UserService zmienia się przy każdej modyfikacji formularza, logiki mailingowej i eksportu do Excela, to ma zbyt wiele odpowiedzialności.
class UserService
{
public function register(array $data): void
{
// walidacja danych
// zapis do bazy
// wysłanie maila powitalnego
// logowanie zdarzenia
// ...
}
}Lepiej rozbić proces na kilka wyspecjalizowanych elementów:
class UserRegistrationService
{
public function __construct(
private UserFactory $userFactory,
private UserRepository $userRepository,
private WelcomeEmailSender $welcomeEmailSender,
private EventDispatcherInterface $eventDispatcher
) {}
public function register(UserRegistrationData $data): User
{
$user = $this->userFactory->createFromData($data);
$this->userRepository->save($user);
$this->welcomeEmailSender->sendTo($user);
$this->eventDispatcher->dispatch(new UserRegisteredEvent($user));
return $user;
}
}Każda z wstrzykiwanych klas ma węższą odpowiedzialność. Jeśli zmieni się szablon maila, dotykasz tylko wysyłki. Jeśli zmieni się sposób tworzenia User, aktualizujesz wyłącznie UserFactory. Taka separacja minimalizuje zasięg zmian.
Otwartość na rozszerzenia, zamknięcie na modyfikacje
Zasada OCP (Open/Closed Principle) sprowadza się do tego, by przy dodawaniu nowego zachowania raczej dodawać klasy, niż edytować istniejące. W praktyce często widać to przy rozliczaniu rabatów, stawek podatkowych czy sposobów wysyłki.
interface DiscountPolicy
{
public function calculateDiscountFor(Order $order): Money;
}
class NoDiscountPolicy implements DiscountPolicy
{
public function calculateDiscountFor(Order $order): Money
{
return Money::zero();
}
}
class BlackFridayDiscountPolicy implements DiscountPolicy
{
public function calculateDiscountFor(Order $order): Money
{
// logika Black Friday
}
}Logika naliczania rabatu nie jest zakodowana w Order ani w OrderService. Jeśli pojawia się nowa kampania (np. rabat lojalnościowy), dochodzi tylko kolejna klasa implementująca DiscountPolicy, a kod obliczający cenę końcową pozostaje spokojny.
Liskov Substitution w praktycznych review
Jeśli klasa B dziedziczy po klasie A, to każdy kod, który działa poprawnie z A, powinien działać też z B. Gdy w review pojawiają się if ($this instanceof SomeChildClass) albo jakieś metody rzucające wyjątek „not supported in this implementation”, to sygnał, że hierarchia złamała zasadę LSP.
class FileLogger
{
public function log(string $message): void
{
// zapis do pliku
}
}
class NullLogger extends FileLogger
{
public function log(string $message): void
{
// nic nie robi
}
}W tym przykładzie LSP nie jest naruszone – obie klasy respektują to samo zachowanie: wywołanie log() nie psuje reszty systemu. Klient nie musi wiedzieć, czy log trafia do pliku, czy jest ignorowany. Gdyby jednak klasa potomna zaczęła rzucać wyjątek przy niektórych wiadomościach albo nagle wysyłać maila do admina, kontrakt stałby się nieprzewidywalny.
Refaktoryzacja proceduralnego kodu do obiektowego
Większość realnych projektów PHP ma za sobą etap „skryptów”: plików pełnych funkcji, globalnych tablic $_POST i powtarzalnych fragmentów SQL. Przepisanie całości na OOP „od zera” zwykle jest zbyt ryzykowne. Rozsądniejsze bywa podejście iteracyjne.
Wydzielanie pierwszych klas z istniejących funkcji
Punktem startowym jest znalezienie fragmentów kodu, które powtarzają się w wielu miejscach albo są trudne do przetestowania. Typowe kandydaty to:
- walidacja danych formularza,
- obsługa płatności,
- wysyłka maili,
- składanie i walidacja zamówienia.
function register_user(array $data, PDO $pdo)
{
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email.');
}
if (strlen($data['password']) < 8) {
throw new Exception('Password too short.');
}
$passwordHash = password_hash($data['password'], PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
$stmt->execute([
':email' => strtolower(trim($data['email'])),
':hash' => $passwordHash,
]);
mail($data['email'], 'Welcome', 'Thanks for registering!');
}Zamiast jednego wielkiego „proceduralnego” bloku można wyłuskać naturalne obiekty: User, UserRepository, UserRegistrationService, Mailer. Refaktoryzacja krok po kroku:
- Tworzysz klasę
Userodpowiedzialną za walidację i normalizację własnych danych. - Tworzysz repozytorium odpowiedzialne za zapis do bazy.
- Tworzysz serwis rejestracji, który orkiestruje całość.
class User
{
private string $email;
private string $passwordHash;
public function __construct(string $email, string $plainPassword)
{
$this->setEmail($email);
$this->setPassword($plainPassword);
}
private function setEmail(string $email): void
{
$normalized = strtolower(trim($email));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email.');
}
$this->email = $normalized;
}
private function setPassword(string $plainPassword): void
{
if (strlen($plainPassword) < 8) {
throw new InvalidArgumentException('Password too short.
}
$this->passwordHash = password_hash($plainPassword, PASSWORD_DEFAULT);
}
public function email(): string
{
return $this->email;
}
public function passwordHash(): string
{
return $this->passwordHash;
}
}
class UserRepository
{
public function __construct(private PDO $pdo) {}
public function add(User $user): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, password_hash) VALUES (:email, :hash)'
);
$stmt->execute([
':email' => $user->email(),
':hash' => $user->passwordHash(),
]);
}
}
class Mailer
{
public function sendWelcome(string $email): void
{
mail($email, 'Welcome', 'Thanks for registering!');
}
}
class UserRegistrationService
{
public function __construct(
private UserRepository $userRepository,
private Mailer $mailer
) {}
public function register(array $data): User
{
$user = new User($data['email'], $data['password']);
$this->userRepository->add($user);
$this->mailer->sendWelcome($user->email());
return $user;
}
}Stara funkcja proceduralna może teraz po prostu delegować do serwisu rejestracji. Nie trzeba od razu usuwać wszystkich „skryptów” – wystarczy, że zaczną korzystać z nowych klas. Dzięki temu ryzyko regresji jest mniejsze, a logika biznesowa stopniowo migruje w jedno, lepiej kontrolowane miejsce.
Stopniowa wymiana procedur na serwisy i obiekty domenowe
Dobrym schematem pracy jest refaktoryzacja przy okazji zmian. Jeśli poprawiasz błąd w module płatności, nie przepisujesz całego systemu fakturowania, tylko wydzielasz sensowny fragment jako nową klasę. Z czasem powstaje coraz więcej obiektów domenowych i serwisów aplikacyjnych, a proceduralna „skorupa” cienkoje i zostaje jako adapter pomiędzy starym światem a nowym.
Na etapie przejściowym nie ma sensu ślepo usuwać wszystkich funkcji. Jeżeli jakaś prosta operacja naprawdę nie ma modelu w domenie (np. obliczenie prostego checksumu), zwykła funkcja pomocnicza może być wystarczająca. Kluczowe jest, aby istotne reguły biznesowe i zachowania, z którymi codziennie pracujesz, były reprezentowane przez obiekty z jasnymi kontraktami.
Testy jako bezpieczna siatka przy refaktoryzacji
Refaktoryzacja proceduralnego kodu w obiektowy jest znacznie łatwiejsza, jeśli otaczasz krytyczne ścieżki testami. Zanim rozbijesz dużą funkcję na klasy, napisz choćby kilka testów integracyjnych lub end-to-end, które przechodzą przez tę funkcję. Po podziale na obiekty odpalasz testy i masz natychmiastową informację, czy przepływ nadal działa tak samo.
Później, gdy już istnieją sensowne klasy, warto dopisać testy jednostkowe bezpośrednio dla nich. Dzięki temu kolejne zmiany w konstruktorach, interfejsach czy polach nie będą wymagały ręcznego „przeklikania” całej aplikacji. W dużych projektach to często jedyny sposób, by odważyć się na głębszą zmianę modelu bez paraliżu zespołu.
Łączenie starego i nowego kodu bez „wielkiego przełączenia”
Mieszanka stylów – część modułu napisana proceduralnie, część obiektowo – jest naturalnym etapem przejściowym. Da się nim zarządzać, jeśli świadomie wyznaczysz granice. Typowy wzorzec to wprowadzenie „fasady” lub serwisu aplikacyjnego, który jest pierwszym obiektowym punktem wejścia, a w środku na razie wywołuje trochę starego kodu.
Stopniowo coraz więcej szczegółów przenosisz za tę fasadę do nowych klas. Z perspektywy zewnętrznych wywołań interfejs pozostaje stabilny, a implementacja dojrzewa. Dzięki takiemu podejściu nie ma jednego, ryzykownego momentu „przełączenia”, tylko ciąg małych, kontrolowanych kroków, które z czasem prowadzą do spójnego, obiektowego modelu domeny.
Przy mieszaniu podejść przydaje się prosta zasada: nowy kod piszesz już obiektowo, a stary dotykasz proceduralnie tylko wtedy, gdy naprawdę musisz. Jeśli endpoint HTTP jest jeszcze „skryptowy”, ale logikę obsługuje nowy serwis, nie ma sensu na siłę przepisywać routingu – wystarczy, że punktem styku z resztą systemu będzie stabilny interfejs obiektowy. Z czasem, gdy pojawi się potrzeba większej zmiany (np. wprowadzenie frameworka), taka granica ułatwia migrację, bo pod spodem logika domenowa jest już uporządkowana.
W bardziej złożonych systemach przydają się adaptery, które „opakowują” stare funkcje w obiekty o określonym interfejsie. Przykład: istniejąca funkcja process_payment() może zostać zamknięta w klasie LegacyPaymentGateway implementującej interfejs PaymentGateway. Nowy kod korzysta tylko z interfejsu, a implementacja może pozostać proceduralna tak długo, jak jest to wygodne. Kiedyś po prostu podmieniasz klasę na nową, bez ruszania całej reszty.
Przy takich mini‑migracjach kluczowa staje się dyscyplina na poziomie granic: po której stronie interfejsu stoją obiekty domenowe, a po której „stary świat”. Jeśli logika biznesowa zacznie z powrotem wylewać się do skryptów i helperów, cały zysk z OOP ucieka. Dobrym sygnałem ostrzegawczym są rozrastające się tablice asocjacyjne przekazywane w dziesiątki miejsc – wtedy zwykle opłaca się zatrzymać i wyciągnąć z nich sensowną klasę.
Efekt końcowy takiej pracy rzadko jest spektakularny z dnia na dzień, za to po kilku miesiącach różnica jest mocno odczuwalna. Łatwiej zrozumieć przepływy, testy odpalają się szybciej niż manualne klikanie, a review przestaje sprowadzać się do walki z globalnymi zmiennymi. Programowanie obiektowe w PHP przestaje być „dodatkiem do frameworka”, a staje się codziennym narzędziem, które realnie obniża koszt zmian i liczbę produkcyjnych niespodzianek.
Obiektowe podejście a frameworki PHP w codziennym projekcie
W aplikacjach opartych na frameworkach (Symfony, Laravel, Laminas) programowanie obiektowe przenika każdy poziom: od warstwy HTTP po dostęp do bazy. W praktyce często widać dwa style korzystania z tych narzędzi: „magiczno‑frameworkowy” i „świadomie obiektowy”.
Styl „magiczny” polega na tym, że większość logiki ląduje w kontrolerach, formach, event listenerach – bo tak szybciej. W efekcie obiekty frameworka stają się nośnikiem reguł biznesowych. Przy małych projektach to jeszcze działa, ale przy rozbudowie taka zależność szybko zaczyna ciążyć: kod trudniej testować, trudno go wydzielić do osobnej biblioteki lub CLI, bo wszystko wisi na kontenerze DI i globalnym stanie aplikacji HTTP.
Bardziej zdrowe podejście to traktowanie frameworka jako „infrastruktury na brzegu” i świadome utrzymywanie serca systemu w własnych obiektach domenowych i serwisach. Kontroler Symfony albo Controller w Laravelu staje się wtedy cienką warstwą, która tylko:
- parsuje i waliduje dane wejściowe (najczęściej przy pomocy osobnych form/DTO),
- woła odpowiedni serwis aplikacyjny / domenowy,
- odpowiednio formatuje wynik (JSON, HTML, redirect).
Przykładowy kontroler rejestracji może wyglądać tak:
class RegisterController extends AbstractController
{
public function __construct(
private UserRegistrationService $registrationService
) {}
public function __invoke(Request $request): Response
{
$data = [
'email' => $request->request->get('email'),
'password' => $request->request->get('password'),
];
try {
$user = $this->registrationService->register($data);
} catch (InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], 422);
}
return $this->json(['id' => $user->id(), 'email' => $user->email()]);
}
}Reguły dotyczące poprawnego adresu e‑mail, złożoności hasła, side‑effectów (wysyłka maila) są schowane w obiektach, a nie w metodzie kontrolera. Jeśli za rok pojawi się potrzeba rejestracji użytkownika z CLI albo z workerów kolejki, kontroler nie jest tu przeszkodą – po prostu te miejsca również skorzystają z UserRegistrationService.
Model domenowy a DTO i warstwa prezentacji
W codziennym kodzie miesza się kilka „rodzajów” obiektów: domenowe, infrastrukturalne, transportowe (DTO), widokowe. Różne odpowiedzialności wrzucone do jednego typu klasy kończą się bałaganem: encje z adnotacjami ORM znają szczegóły frameworka, w modelach lądują reguły formatowania dat, a obiekt będący odpowiedzią API ma metody, które nic nie mają wspólnego z JSON‑em.
Skuteczniejszy podział to rozróżnienie na:
- obiekty domenowe – enkapsulują reguły biznesowe i stan (np.
User,Order,Invoice), - obiekty infrastrukturalne – repozytoria, adaptery do API, gatewaye płatności,
- DTO / request/response objects – opisują dane przepływające między warstwami (np. parametry wyszukiwania, payload żądania, odpowiedź API),
- modele widokowe – dopasowane do potrzeb templatu lub frontendu (np.
UserProfileViewzbierające dane z kilku encji).
W prostym scenariuszu rejestracji można wprowadzić DTO na poziomie żądania, aby kontroler nie przekazywał „nagich” tablic:
class RegisterUserRequest
{
public function __construct(
public readonly string $email,
public readonly string $password
) {}
}
class UserRegistrationService
{
public function __construct(
private UserRepository $userRepository,
private Mailer $mailer
) {}
public function register(RegisterUserRequest $request): User
{
$user = new User($request->email, $request->password);
$this->userRepository->add($user);
$this->mailer->sendWelcome($user->email());
return $user;
}
}Taka zmiana usuwa „tablice magii” z wnętrza serwisu i nadaje strukturę przekazywanym danym. Dodatkowo, gdy zmienią się pola formularza, różnice widać natychmiast po stronie typów, a nie dopiero przy runtime’owym Undefined index.
Specjalizacja obiektów domenowych i modelowanie reguł
Obiekt User często w rzeczywistości reprezentuje kilka różnych ról: klienta sklepu, administratora, operatora supportu. Jeśli wszystkie te odpowiedzialności wrzuci się do jednej klasy, w pewnym momencie każdy refactor będzie bolesny – zmiana logiki uprawnień dla administratora przypadkowo wpłynie na zwykłego użytkownika.
W OOP wygodniej rozróżnić role na poziomie typu. Nie zawsze musi to oznaczać dziedziczenie. Czasem lepsza jest kompozycja, czyli osobna klasa odpowiedzialna za konkretną część zachowania. Przykład lekkiego wydzielenia reguł uprawnień:
class UserPermissions
{
public function __construct(private array $roles) {}
public function canManageOrders(): bool
{
return in_array('admin', $this->roles, true)
|| in_array('support', $this->roles, true);
}
public function canViewInvoices(): bool
{
return !empty($this->roles);
}
}
class User
{
public function __construct(
private string $email,
private string $passwordHash,
private UserPermissions $permissions
) {}
public function permissions(): UserPermissions
{
return $this->permissions;
}
}Zamiast mieć kilkadziesiąt metod typu isAdmin(), isSupport() w samym User, cała logika uprawnień trafia do wyspecjalizowanego obiektu. Przy testach jednostkowych można go izolować, a przy zmianie modelu ról nie trzeba grzebać w całej klasie użytkownika.
Standardy kodowania i konwencje jako „miękkie” wsparcie OOP
Nawet dobrze zaprojektowane klasy trudno utrzymać w ryzach, jeśli w projekcie panuje pełna dowolność nazewnictwa i struktur. Konsekwentne stosowanie kilku prostych zasad pozwala nie zastanawiać się codziennie nad detalami, tylko skupić na modelu domenowym.
Typowe, praktyczne ustalenia w zespole to:
- nazwy klas rzeczownikami w liczbie pojedynczej (
Order,OrderRepository,InvoicePdfGenerator), - jedna klasa – jeden plik, brak klas „narzędziowych” z tuzinami statycznych metod,
- brak „God classes” – jeśli plik przekracza określoną liczbę linii lub ma kilkanaście metod publicznych, to sygnał do rozbicia,
- zawsze włączone typowanie (
declare(strict_types=1);na górze pliku), - jawne importy przestrzeni nazw (
use) zamiast używania w kodzie pełnych FQCN.
Konwencje szczególnie pomagają przy review. Jeśli FooService w jednym module zachowuje się jak serwis aplikacyjny, a w innym jak helper, każdy PR staje się debatą o tym, co co oznacza. Gdy w projekcie panuje uzgodnione słownictwo – „serwis aplikacyjny woła domenę i infrastrukturę”, „repozytorium ukrywa szczegóły dostępu do danych” – łatwiej szybko ocenić, czy dana klasa nie dźwiga zbyt wiele.
Typowanie, value objects i uszczelnianie modeli
Obiektowe programowanie w PHP realnie zyskuje moc dopiero w połączeniu z typowaniem. Deklaracje typów w argumentach, zwracanych wartościach i właściwościach nie są tylko „dokumentacją dla IDE”, ale stanowią dodatkową warstwę kontroli nad modelem.
Przy prostych domenach często wszystko sprowadza się do ciągów znaków i liczb: e‑mail jako string, kwota jako float, identyfikator jako kolejny string. Z czasem takie podejście zaczyna generować problemy – przekazujesz „jakiegoś stringa” tam, gdzie w rzeczywistości wymagany jest np. znormalizowany e‑mail klienta.
Rozwiązaniem są value objects, czyli małe klasy opakowujące prymitywne typy. Zamiast przekazywać „luźny” e‑mail, przekazujesz obiekt, który gwarantuje poprawność:
final class Email
{
private string $value;
public function __construct(string $value)
{
$normalized = strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
$this->value = $normalized;
}
public function equals(Email $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
class User
{
public function __construct(
private Email $email,
private string $passwordHash
) {}
public function email(): Email
{
return $this->email;
}
}Od tego momentu każda metoda przyjmująca Email wie, że ma do czynienia z poprawnie zbudowaną wartością. Nie trzeba już w kółko wołać filter_var() i trim() w różnych miejscach. Przy pracy zespołowej minimalizuje to ryzyko, że ktoś pominie walidację „bo w innym miejscu i tak się sprawdza”.
Domain events i luźne powiązania w obrębie modelu
Gdy w systemie pojawia się coraz więcej zależności (po rejestracji użytkownika trzeba wysłać e‑mail, powiadomienie do CRM, dodać wpis do logów audytowych), bezpośrednie wołanie tych wszystkich elementów w jednym serwisie zaczyna być niewygodne. Każde kolejne wymaganie powiększa listę zależności konstruktora i rozrasta metodę.
Jednym ze sposobów rozluźnienia połączeń są zdarzenia domenowe. Sedno polega na tym, że ważne zdarzenie w modelu (np. „użytkownik zarejestrowany”) jest reprezentowane przez obiekt, który można przekazać różnym słuchaczom. Serwis rejestracji nie musi znać wszystkich odbiorców; wystarczy, że „wyemituje” zdarzenie.
class UserRegistered
{
public function __construct(
public readonly User $user
) {}
}
interface DomainEventDispatcher
{
public function dispatch(object $event): void;
}
class UserRegistrationService
{
public function __construct(
private UserRepository $userRepository,
private DomainEventDispatcher $events
) {}
public function register(RegisterUserRequest $request): User
{
$user = new User($request->email, $request->password);
$this->userRepository->add($user);
$this->events->dispatch(new UserRegistered($user));
return $user;
}
}W innym miejscu można zaimplementować słuchacza, który zajmie się wysyłką maila powitalnego lub integracją z zewnętrznym systemem.
class SendWelcomeEmailOnUserRegistered
{
public function __construct(private Mailer $mailer) {}
public function __invoke(UserRegistered $event): void
{
$this->mailer->sendWelcome($event->user->email()->__toString());
}
}Dzięki takiemu podejściu nowy wymóg (np. dodatkowe powiadomienie do Slacka) nie oznacza, że trzeba dopisywać kolejne linijki w UserRegistrationService. Wystarczy nowy listener, zarejestrowany w kontenerze DI lub prostym dispatcherze. Model domenowy pozostaje czystszy, a miejsca odpowiedzialne za „szum techniczny” są zebrane w jednym obszarze.
Integracje z zewnętrznymi API – obiektowe opakowania zamiast „surowych” wywołań
W projektach biznesowych sporo energii pochłania integracja z płatnościami, systemami mailingowymi, CRM‑ami czy platformami e‑commerce. Bezpośrednie wołanie klienta HTTP wraz z parsowaniem JSON‑a w środku serwisów domenowych bardzo szybko zaciera granicę między „naszym” modelem a zewnętrznym API.
Lepszą taktyką jest wprowadzenie klasy‑adaptera, która ukrywa szczegóły rozmowy z API za prostym interfejsem. Po stronie domeny operujesz wtedy na własnych typach, a nie strukturach zwróconych przez klienta HTTP.
interface PaymentGateway
{
public function charge(Money $amount, string $token): PaymentResult;
}
class StripePaymentGateway implements PaymentGateway
{
public function __construct(private HttpClient $http) {}
public function charge(Money $amount, string $token): PaymentResult
{
$response = $this->http->post('https://api.stripe.com/v1/charges', [
'amount' => $amount->toCents(),
'currency' => $amount->currency(),
'source' => $token,
]);
// ...mapowanie odpowiedzi na PaymentResult ...
}
}
class OrderPaymentService
{
public function __construct(
private PaymentGateway $payments,
private OrderRepository $orders
) {}
public function pay(OrderId $orderId, string $token): void
{
$order = $this->orders->get($orderId);
$result = $this->payments->charge($order->totalAmount(), $token);
if (!$result->isSuccessful()) {
// ...obsługa błędu...
}
$order->markAsPaid();
$this->orders->save($order);
}
}Jeśli provider płatności się zmieni, refaktor ogranicza się do wymiany implementacji interfejsu PaymentGateway. Kod domenowy (klasy Order, OrderPaymentService) nie musi znać detali nowego API. Taki poziom izolacji rzadko jest możliwy bez konsekwentnego stosowania interfejsów, kompozycji i jawnych typów zwracanych obiektów.
Podobny wzorzec można zastosować przy innych integracjach: systemach mailingowych, SMS, webhookach czy magazynach plików. Z perspektywy modelu domenowego istnieje np. NotificationChannel z metodą send(Notification $notification), a to, czy pod spodem pracuje AWS SES, Sendgrid czy lokalny serwer SMTP, pozostaje szczegółem implementacyjnym. Różne adaptery można łatwo podstawić w testach lub w konfiguracji środowisk (dev/stage/prod) bez grzebania w logice biznesowej.
Sporo zyskują też testy. Serwis płatności czy integracji z CRM można przetestować, podstawiając atrapę interfejsu PaymentGateway czy CrmClient, która zwraca z góry przygotowane odpowiedzi. Dzięki temu test jednostkowy skupia się na tym, czy obiekt poprawnie reaguje na wynik transakcji, a nie na tym, czy prawidłowo wysyła żądanie HTTP. W praktyce oznacza to szybsze, stabilniejsze testy i mniej „ślepych” wywołań do środowisk zewnętrznych.
Dobrze zaprojektowane obiektowe adaptery pomagają też przetrwać nieuniknione zmiany po stronie dostawców. Jeśli nowa wersja API dodaje pole, zmienia format odpowiedzi lub wymaga innego sposobu autoryzacji, najczęściej wystarczy poprawka w jednej klasie adaptera oraz ewentualne dostosowanie kilku value objects używanych do mapowania danych. Reszta kodu nadal operuje na tych samych, stabilnych kontraktach.
Im większy projekt, tym bardziej takie obiektowe „fasady” oddzielające domenę, infrastrukturę i zewnętrzne API zaczynają działać jak amortyzatory. Zmiany biznesowe, refaktoryzacje struktury bazy, migracja do innego dostawcy usług – to wszystko da się wtedy przeprowadzić przy mniejszych turbulencjach, bo ciężar zmian przejmuje warstwa, która została od początku potraktowana jak wymienialny moduł, a nie jak rozlany po całym kodzie zestaw wywołań funkcji.
OOP w PHP przestaje być wtedy dodatkiem do „skryptu, który ma działać”, a staje się narzędziem do budowania kodu przewidywalnego w zachowaniu, łatwiejszego w zmianie i zrozumiałego dla kolejnych osób, które dołączą do projektu po miesiącach czy latach.

Projektowanie warstw i modułów w aplikacji obiektowej
Gdy liczba klas i interfejsów rośnie, sama poprawność obiektowego kodu przestaje wystarczać. Kłopotem staje się to, że „wszystko widzi wszystko”, a logika biznesowa przenika się z HTTP, bazą i integracjami. W PHP szczególnie widać to w większych aplikacjach opartych o frameworki, gdzie kontrolery często stają się mieszanką SQL‑a, walidacji i operacji na modelu.
Jednym ze sposobów opanowania tego chaosu jest jasne rozdzielenie warstw i trzymanie się prostych reguł, które można później egzekwować w review.
Granice między warstwami: domena, aplikacja, infrastruktura
Przy codziennej pracy wystarczy najczęściej podział na trzy główne obszary:
- warstwa domeny – czysta logika biznesowa: encje, value objects, serwisy domenowe, reguły;
- warstwa aplikacji – przypadki użycia, orkiestracja: co ma się stać w odpowiedzi na żądanie;
- warstwa infrastruktury – szczegóły techniczne: HTTP, baza danych, kolejki, integracje.
Prosty schemat zależności:
- domena nie zna infrastruktury,
- aplikacja zna domenę, ale nie zna szczegółów infrastruktury (tylko interfejsy),
- infrastruktura zna domenę i aplikację, bo implementuje ich kontrakty.
// src/Application/RegisterUserHandler.php
class RegisterUserHandler
{
public function __construct(
private UserRegistrationService $registration,
private UserRepository $users
) {}
public function handle(RegisterUserCommand $command): UserId
{
$user = $this->registration->register(
new Email($command->email),
$command->rawPassword
);
$this->users->add($user);
return $user->id();
}
}
// src/Infrastructure/Http/RegisterUserController.php
class RegisterUserController
{
public function __construct(private RegisterUserHandler $handler) {}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
$data = $request->getParsedBody();
$command = new RegisterUserCommand(
$data['email'] ?? '',
$data['password'] ?? ''
);
$userId = $this->handler->handle($command);
return new JsonResponse(['id' => (string) $userId], 201);
}
}Kontroler HTTP staje się cienkim adapterem: mapuje request na komendę, wywołuje handler i tłumaczy wynik na odpowiedź. Logika leży w obiektach domeny i aplikacji. Jeśli za jakiś czas pojawi się konieczność obsługi tego samego przypadku użycia z CLI lub kolejki, wystarczy inny adapter, który wywoła ten sam handler.
Moduły a zależności między nimi
W projektach z wieloma obszarami biznesowymi dobrze jest myśleć modułami: „Users”, „Billing”, „Catalog”, „Orders”. Każdy moduł ma swoje encje, value objects, serwisy i repozytoria. Ważne jest nie tylko to, co w nim jest, lecz także to, czego nie powinien znać.
Przykładowa struktura katalogów:
src/
Users/
Domain/
Application/
Infrastructure/
Billing/
Domain/
Application/
Infrastructure/Jeśli moduł „Billing” zaczyna korzystać z encji użytkownika, lepiej wprowadzić interfejs na granicy (np. CustomerProvider) niż robić bezpośrednie use AppUsersDomainUser w warstwie domenowej „Billingu”. W praktyce często wystarczy prosty kontrakt:
namespace AppBillingDomain;
interface CustomerProvider
{
public function getCustomerById(string $id): CustomerView;
}Implementacja powstaje w warstwie infrastruktury, a kod domenowy Billingu nie zależy od konkretnej encji z modułu Users. Przy większych systemach takie izolowanie modułów zmniejsza ryzyko „efektu domina”, gdzie zmiana w jednym obszarze wymusza serię poprawek w wielu pozornie niezależnych miejscach.
Współpraca z frameworkiem bez uzależniania domeny
Najczęstsza pułapka w PHP to „przeciekanie” frameworka do modelu. Encje są rozszerzeniami bazowych klas ORM, w serwisach ląduje Request z frameworka, a logika biznesowa zaczyna żyć w kontrolerach. Refaktoryzacja czy migracja do innego narzędzia staje się wtedy bardzo droga.
Encje domenowe a ORM (np. Doctrine)
Naturalnym odruchem jest dziedziczenie po klasach bazowych ORM lub dodawanie adnotacji/frameworkowych traitów wprost do encji domenowej. Da się tak pracować, ale koszt pojawia się, gdy zmieni się sposób przechowywania danych.
Bezpieczniejszy wariant to oddzielenie encji domenowej od „rekordu” infrastrukturalnego. Przy mniejszych projektach wystarczy prosty mapper lub dedykowane repozytoria, które konwertują encję domenową na obiekt ORM i odwrotnie.
// Encja domenowa - brak zależności od ORM
final class User
{
public function __construct(
private UserId $id,
private Email $email,
private string $passwordHash
) {}
// ...logika domenowa...
}
// Encja ORM - szczegół infrastruktury (np. Doctrine)
/**
* @Entity
* @Table(name="users")
*/
class UserRecord
{
/** @Id @Column(type="guid") */
public string $id;
/** @Column(type="string") */
public string $email;
/** @Column(type="string", name="password_hash") */
public string $passwordHash;
}
class DoctrineUserRepository implements UserRepository
{
public function __construct(private EntityManagerInterface $em) {}
public function add(User $user): void
{
$record = new UserRecord();
$record->id = (string) $user->id();
$record->email = (string) $user->email();
$record->passwordHash = $user->passwordHash();
$this->em->persist($record);
}
public function get(UserId $id): User
{
$record = $this->em->find(UserRecord::class, (string) $id);
if (!$record) {
throw new RuntimeException('User not found');
}
return new User(
new UserId($record->id),
new Email($record->email),
$record->passwordHash
);
}
}Taki dodatkowy poziom mapowania bywa postrzegany jako nadmiarowy, ale przy większym projekcie spłaca się wielokrotnie: można wymienić ORM, zmienić strukturę tabel czy przenieść część danych do innego magazynu bez ruszania logiki w domenie.
Kontrolery cienkie, serwisy grube
Dobrym heurystycznym testem jest odpowiedź na pytanie: „czy tę samą operację mogę wykonać z CLI/queue, wykorzystując ten sam kod biznesowy?”. Jeśli odpowiedź brzmi „nie, bo logika siedzi w kontrolerze”, to sygnał do refaktoryzacji.
Kontroler powinien ograniczać się do:
- odczytu i walidacji danych wejściowych (często z pomocą formularzy/DTO),
- wywołania przypadku użycia (serwis / handler aplikacyjny),
- zmapowania wyniku na odpowiedź HTTP.
Resztę powinna wykonywać warstwa aplikacji i domeny. W realnym projekcie często kończy się to czymś takim:
class ResetPasswordController
{
public function __construct(private ResetPasswordHandler $handler) {}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
$data = $request->getParsedBody();
$command = new ResetPasswordCommand(
token: $data['token'] ?? '',
newPassword: $data['password'] ?? ''
);
$this->handler->handle($command);
return new JsonResponse(['status' => 'ok']);
}
}
class ResetPasswordHandler
{
public function __construct(
private UserRepository $users,
private PasswordEncoder $encoder
) {}
public function handle(ResetPasswordCommand $command): void
{
$user = $this->users->getByResetToken($command->token);
$user->resetPassword(
$this->encoder->encode($command->newPassword)
);
$this->users->save($user);
}
}Jeśli następnie pojawi się zadanie: „dodać masowe resetowanie haseł dla użytkowników importowanych z zewnętrznego systemu”, ten sam handler może zostać użyty z innego adaptera (np. job w kolejce), bez powielania logiki.
SOLID w praktyce: sygnały do refaktoryzacji
SOLID często jest traktowany jak zestaw haseł z prezentacji. W codziennej pracy bardziej przydaje się zestaw „zapachów”, które wskazują, że którąś zasadę zaczynamy łamać. OOP w PHP staje się wtedy narzędziem diagnostycznym: łatwiej zobaczyć, gdzie kod zaczyna się „kleić”.
Single Responsibility – klasy, które robią zbyt wiele
Jeśli w klasie UserService znajdują się metody od wszystkiego – rejestracji, logowania, resetu hasła, wysyłania maili, raportowania aktywności – to nie chodzi już o abstrakcyjną zasadę SRP, tylko o bardzo przyziemny problem: trudno to przetestować, utrzymać i bezpiecznie zmienić.
Sygnalizuje to kilka objawów:
- metoda konstruktora przyjmuje długą listę zależności,
- różne metody korzystają z różnych podzbiorów zależności,
- każda zmiana wymaga grzebania w tej samej, „centralnej” klasie.
Refaktoryzacja często sprowadza się do wyciągnięcia konkretnych odpowiedzialności do osobnych serwisów aplikacyjnych lub domenowych:
class UserRegistrationService
{
// tylko rejestracja
}
class UserAuthenticationService
{
// tylko logowanie
}
class UserPasswordResetService
{
// tylko reset haseł
}W review łatwiej wtedy przeanalizować zmianę: zamiast „co tym razem dołożono do UserService” pojawia się pytanie „czy to na pewno należy do tej odpowiedzialności?”.
Open/Closed – zmiany przez rozszerzanie, nie modyfikowanie
Zasada OCP nabiera konkretnego sensu, gdy pojawiają się dynamiczne wymagania biznesowe: nowe kanały powiadomień, kolejne typy zniżek, różne strategie naliczania podatku. Jeśli nowy wariant oznacza zawsze dopisywanie if-ów w istniejącej klasie, kod szybko zmienia się w „centralny switch”.
Lepszą strategią jest wprowadzenie interfejsu i osobnych implementacji, które można dodać bez dotykania istniejącego kodu.
interface DiscountPolicy
{
public function calculateDiscount(Money $regularPrice): Money;
}
class PercentageDiscountPolicy implements DiscountPolicy
{
public function __construct(private float $percent) {}
public function calculateDiscount(Money $regularPrice): Money
{
return $regularPrice->multiply($this->percent / 100);
}
}
class FixedAmountDiscountPolicy implements DiscountPolicy
{
public function __construct(private Money $amount) {}
public function calculateDiscount(Money $regularPrice): Money
{
return $this->amount->min($regularPrice);
}
}
class OrderPricingService
{
public function __construct(private DiscountPolicy $discountPolicy) {}
public function priceFor(Order $order): Money
{
$base = $order->totalAmount();
$discount = $this->discountPolicy->calculateDiscount($base);
return $base->subtract($discount);
}
}Nowy typ promocji to nowa klasa. OrderPricingService pozostaje nietknięty, zmienia się tylko konfiguracja kontenera DI lub miejsce, w którym tworzony jest konkretny wariant DiscountPolicy.
Liskov, Interface Segregation, Dependency Inversion w kodzie zespołowym
Pozostałe zasady SOLID ujawniają się głównie w pracy kilku osób nad tym samym kodem.
Liskov Substitution staje się problemem, gdy ktoś zmienia zachowanie klasy dziedziczącej w sposób nieoczekiwany dla reszty kodu. Klasy potomne powinny zachowywać kontrakt bazowy – jeśli zaczynają rzucać inne wyjątki, ignorować część danych lub zmieniać domyślne działanie, pojawiają się subtelne błędy. W review warto patrzeć na dziedziczenie krytycznym okiem i częściej wybierać kompozycję.
Interface Segregation w PHP szczególnie pomaga przy tworzeniu testów. Rozbudowane interfejsy „god objects”, które mają po kilkanaście metod, trudno zastąpić prostą atrapą. O wiele wygodniej testuje się kod w oparciu o wąskie interfejsy, np. rozdzielając Mailer na TransactionalMailer i NewslettersMailer, jeśli są używane w innych kontekstach.
Dependency Inversion w praktyce oznacza trzymanie się interfejsów po stronie domeny i aplikacji oraz implementowanie ich w infrastrukturze. Dzięki temu decyzje o tym, z jakiego klienta HTTP, ORM czy systemu kolejkowego korzysta projekt, są „odwrócone” – to szczegóły dostosowują się do wymagań wyższego poziomu, a nie odwrotnie.
Refaktoryzacja kodu proceduralnego do obiektowego krok po kroku
Większość realnych projektów PHP nie startuje od razu z czystym, warstwowym OOP. Częściej mamy do czynienia z plikami zawierającymi SQL, echo HTML‑a i kilka funkcji pomocniczych. Przestawienie wszystkiego na obiekty „od jutra” zwykle kończy się paraliżem. Lepsze efekty daje stopniowe wyciąganie obiektów z istniejącego kodu.
1. Wyciągnięcie logiki biznesowej z kontrolera/skryptu do klasy
Pierwszy krok to znalezienie fragmentu, który zawiera sensowną logikę, i przeniesienie go do metody klasy. Nie trzeba od razu projektować idealnego modelu domenowego – wystarczy „opakować” istniejące operacje tak, aby można je było wywołać spoza kontrolera.
Typowy punkt startu to kontroler lub wręcz pojedynczy plik PHP obsługujący żądanie HTTP. Zamiast trzymać tam całe „mięso”, można wydzielić serwis aplikacyjny, który przejmie odpowiedzialność za scenariusz biznesowy.
// register.php (upraszczając)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$pdo = new PDO(...);
// logika biznesowa wymieszana z infrastrukturą
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
if ($stmt->fetch()) {
echo 'User exists';
return;
}
$hash = password_hash($password, PASSWORD_BCRYPT);
$stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
$stmt->execute(['email' => $email, 'password' => $hash]);
echo 'OK';
}Pierwsza iteracja refaktoryzacji nie musi być idealna – celem jest wyjęcie decyzji biznesowych do klasy, tak by endpoint odgrywał tylko rolę adaptera.
class UserRegistrationService
{
public function __construct(private PDO $pdo) {}
public function register(string $email, string $plainPassword): void
{
$stmt = $this->pdo->prepare('SELECT id FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
if ($stmt->fetch()) {
throw new RuntimeException('User exists');
}
$hash = password_hash($plainPassword, PASSWORD_BCRYPT);
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, password) VALUES (:email, :password)'
);
$stmt->execute(['email' => $email, 'password' => $hash]);
}
}
// register.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$service = new UserRegistrationService(new PDO(...));
try {
$service->register($_POST['email'] ?? '', $_POST['password'] ?? '');
echo 'OK';
} catch (RuntimeException $e) {
echo $e->getMessage();
}
}Taka zmiana zwykle jest do przełknięcia nawet w projekcie bez frameworka. Zyskiem od razu jest możliwość napisania prostego testu jednostkowego dla UserRegistrationService i ograniczenie liczby miejsc, w których trzeba grzebać przy zmianie procesu rejestracji.
2. Wyodrębnienie zależności do interfejsów i klas infrastruktury
W kolejnym kroku sensowne jest rozdzielenie logiki biznesowej od dostępu do bazy czy innych systemów. Jeśli w każdej metodzie serwisu występuje PDO albo surowe zapytania SQL, to kod mocno „przyrasta” do konkretnej technologii. W takiej sytuacji lepiej wprowadzić repozytorium z interfejsem po stronie domeny lub aplikacji oraz osobną implementacją infrastrukturalną.
interface UserRepository
{
public function existsByEmail(string $email): bool;
public function add(User $user): void;
}
class PdoUserRepository implements UserRepository
{
public function __construct(private PDO $pdo) {}
public function existsByEmail(string $email): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
return (bool) $stmt->fetchColumn();
}
public function add(User $user): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (id, email, password) VALUES (:id, :email, :password)'
);
$stmt->execute([
'id' => $user->id(),
'email' => $user->email(),
'password' => $user->passwordHash(),
]);
}
}
class UserRegistrationService
{
public function __construct(
private UserRepository $users,
private PasswordEncoder $encoder
) {}
public function register(string $email, string $plainPassword): void
{
if ($this->users->existsByEmail($email)) {
throw new RuntimeException('User exists');
}
$user = User::register($email, $this->encoder->encode($plainPassword));
$this->users->add($user);
}
}Od tego momentu sama rejestracja przestaje zależeć od bazy danych.
Infrastruktura (konkretny typ bazy, sposób haszowania, kolejka, HTTP client) staje się wymienna, a testy jednostkowe mogą działać na prostych atrapach repozytoriów i enkoderów. Zmiana z MySQL na PostgreSQL, migracja z jednego dostawcy e‑mail na innego albo wprowadzenie cache zwykle zamyka się wtedy w implementacjach interfejsów, a nie w całej logice biznesowej.
3. Wprowadzanie obiektów domenowych zamiast tablic i „wiaderek danych”
Kolejny etap to zastępowanie surowych tablic i stdClass obiektami domenowymi. Jeśli w kodzie krążą struktury typu ['email' => '...', 'password' => '...'], łatwo o literówki, niespójne formaty i brak podstawowej walidacji. Prosty obiekt User z metodą fabrykującą może przejąć odpowiedzialność za poprawne utworzenie użytkownika.
final class User
{
private function __construct(
private string $id,
private string $email,
private string $passwordHash
) {}
public static function register(string $email, string $passwordHash): self
{
// miejsce na walidację emaila, normalizację itd.
return new self(Uuid::uuid4()->toString(), $email, $passwordHash);
}
public function id(): string
{
return $this->id;
}
public function email(): string
{
return $this->email;
}
public function passwordHash(): string
{
return $this->passwordHash;
}
}Taki obiekt można dalej rozwijać: dodać zmianę hasła z kontrolą aktualnego, blokadę konta, flagę potwierdzenia e‑mail. Zamiast rozprowadzać te zasady po całym kodzie, trzyma się je w jednym miejscu. Przy rozwoju systemu ryzyko „bocznych efektów” spada, bo mniej logiki rozlewa się po kontrolerach i helperach.
4. Stopniowe obejmowanie OOP kolejnych fragmentów systemu
W praktyce rzadko kiedy da się od razu „przepisać wszystko na obiekty”. Sensowniejsza strategia to otaczanie krytycznych ścieżek biznesowych nowym kodem obiektowym i zostawianie reszty w dotychczasowej formie. Typowy schemat: nowi użytkownicy idą już przez UserRegistrationService i repozytorium, ale starsza część panelu administracyjnego nadal działa na starych skryptach z mieszanym SQL‑em i HTML‑em.
Kluczowe jest pilnowanie granic. Nowy kod nie powinien bezpośrednio zależeć od „starego proceduralnego świata” – lepiej wprowadzić cienkie adaptery, które tłumaczą stare struktury danych na obiekty domenowe i odwrotnie. Z czasem coraz większa część aplikacji zacznie korzystać z serwisów i repozytoriów, a pojedyncze legacy‑skrypty zostaną albo wycięte, albo objęte podobnym procesem refaktoryzacji.
Obiektowe podejście w PHP przestaje być wtedy celem samym w sobie, a staje się narzędziem: pozwala projektować kod wokół reguł biznesowych, kontrolować złożoność i spokojniej przechodzić przez kolejne rewizje wymagań, zamiast za każdym razem walczyć z nieprzewidywalną siecią zależności i kopii wklejek.
Najczęściej zadawane pytania (FAQ)
Po co stosować programowanie obiektowe w PHP, skoro da się pisać procedurami?
Programowanie proceduralne w małych skryptach sprawdza się całkiem dobrze, ale przy większych projektach szybko zaczyna brakować struktury. W aplikacji złożonej z setek plików i wielu zależności proceduralny kod zamienia się w zbiór funkcji i include’ów, w których trudno coś bezpiecznie zmienić.
OOP w PHP pozwala podzielić system na klasy o jasno określonej odpowiedzialności: inne za logikę biznesową, inne za komunikację z bazą, inne za integracje z API. Dzięki temu łatwiej rozdzielić pracę w zespole, pisać testy jednostkowe i wprowadzać zmiany bez ryzyka, że jeden if „położy” pół systemu.
Czy programowanie obiektowe w PHP jest obowiązkowe przy pracy z Laravelem lub Symfony?
Przy frameworkach takich jak Laravel czy Symfony podejście obiektowe jest de facto standardem. Same frameworki są napisane obiektowo, a ich architektura zakłada pracę z klasami: kontrolerami, modelami, serwisami, repozytoriami, eventami. Próba pisania w nich „po proceduralnemu” jest walką z narzędziem.
Jeśli używasz tych frameworków w komercyjnych projektach, to umiejętność projektowania klas, interfejsów oraz pracy z typami w PHP staje się koniecznością. Bez tego szybko skończysz z „Boską klasą” typu Helper albo potężnym kontrolerem robiącym wszystko naraz.
Od jakiego poziomu projektu opłaca się przejść na OOP w PHP?
Jeżeli piszesz jednorazowy skrypt do migracji danych czy prosty cron na kilkadziesiąt linijek, klasy nie są konieczne. Gdy jednak projekt:
- ma być rozwijany dłużej niż kilka tygodni,
- obsługuje różne integracje (płatności, hurtownie, zewnętrzne API),
- jest rozwijany przez kilka osób,
- wymaga testów automatycznych i kilku środowisk (dev, stage, prod),
to obiektowe podejście bardzo szybko zwraca się w postaci mniejszej liczby regresji i łatwiejszej refaktoryzacji. Już od prostego panelu administracyjnego czy sklepu internetowego dobrze jest startować w pełni obiektowo.
Jakie klasy najczęściej tworzę w typowym projekcie PHP (Laravel/Symfony)?
W typowym dniu pracy programista PHP korzysta przede wszystkim z kilku rodzajów klas. Najbardziej powtarzalne to:
- Modele (encje domenowe) – np.
User,Order,Product, trzymające stan i reguły biznesowe danego bytu. - Serwisy / use-case’y – np.
RegisterUserService,CreateOrderHandler, realizujące konkretne operacje biznesowe. - Kontrolery – klasy mapujące request HTTP na kod aplikacji i składanie odpowiedzi.
- Repozytoria – klasy odpowiedzialne za dostęp do danych, owijające ORM lub SQL.
- Value Objecty – małe klasy typu
Email,Money, które pilnują poprawności danych i zachowań.
Taki podział sprawia, że kontroler nie musi „znać” szczegółów bazy czy zewnętrznego API – współpracuje z klasami przez interfejsy, co upraszcza zarówno kod, jak i testowanie.
Jak widoczność (public, private, protected) pomaga w codziennej pracy z PHP?
Modyfikatory dostępu określają, skąd można użyć danej właściwości lub metody. public oznacza dostęp „z zewnątrz”, private – tylko wewnątrz klasy, a protected – wewnątrz klasy i klas potomnych. Używając ich świadomie, zamykasz stan obiektu przed przypadkowymi zmianami.
Przykład: hasło i status aktywności użytkownika trzymasz w polach prywatnych, a na zewnątrz udostępniasz tylko metody typu activate(), deactivate(), changePassword(). Dzięki temu nikt z innej części systemu nie ustawi „ręcznie” niepoprawnego hasha ani nieaktywnego użytkownika, omijając reguły biznesowe.
Czy w OOP w PHP muszę zawsze używać typów (type hinting, return types)?
Technicznie nie musisz, ale w praktyce silne typowanie w OOP jest ogromnym ułatwieniem. Jeśli typujesz argumenty, zwracane wartości i właściwości, to kontrakt między klasami staje się jasny, a sporo błędów wychwycisz jeszcze przed uruchomieniem kodu – IDE od razu sygnalizuje niezgodności.
Typy pomagają też łatwiej nawigować po projekcie: widzisz, że metoda przyjmuje User i zwraca bool, bez grzebania w implementacji. Dobrą praktyką jest typowanie wszystkiego, co się da, w tym używanie nullowalnych typów (?string) i – z umiarem – typów złożonych (float|int) tam, gdzie to ma sens.
Jaki jest prosty, praktyczny przykład klasy domenowej w PHP?
Dobrym przykładem jest klasa User, która nie tylko przechowuje e‑mail, ale też dba o normalizację danych, szyfrowanie hasła, zmianę hasła z walidacją starego, a także obsługę aktywacji i dezaktywacji konta. Taka klasa ukrywa szczegóły implementacji (np. jak liczone jest hasło) i wystawia czytelne metody do użytku w innych częściach systemu.
W praktyce oznacza to, że kontroler zamiast ręcznie bawić się w password_hash i flagi aktywności, wywołuje po prostu $user->changePassword(...) lub $user->activate(). Mniej bałaganu w kontrolerze, więcej logiki tam, gdzie jej miejsce – w klasie reprezentującej konkretny byt domenowy.
Co warto zapamiętać
- PHP ewoluowało z prostego języka skryptowego do platformy dla dużych systemów (sklepy, CRM, API), więc programowanie obiektowe jest konieczne, żeby taki kod w ogóle dało się utrzymać.
- Nowoczesne PHP (7 i 8) ma pełnoprawne wsparcie OOP – klasy, interfejsy, typy, wyjątki, autoloading, przestrzenie nazw – co pozwala pisać rozwiązania jakościowo zbliżone do Javy czy C#.
- OOP najbardziej pomaga w długowiecznych projektach zespołowych: ułatwia integracje z zewnętrznymi systemami, automatyczne testy i wielośrodowiskowe wdrożenia, bo porządkuje zależności i odpowiedzialności.
- Podział na klasy według odpowiedzialności (osobno logika biznesowa, dostęp do bazy, komunikacja z API, prezentacja) sprawia, że zmiany – jak dodanie nowej metody płatności – sprowadzają się do dopisania nowej klasy zamiast rozbudowy jednego „god objecta” z if-ami.
- Kod obiektowy poprawia komfort pracy developera: łatwiej zlokalizować miejsce zmiany, review skupia się na logice, testy jednostkowe są prostsze dzięki małym, izolowanym klasom, a ryzyko regresji spada.
- W codziennej pracy w frameworkach typu Symfony czy Laravel developer operuje głównie na obiektach (modele, serwisy, kontrolery, repozytoria, value objecty), które komunikują się przez jasno zdefiniowane interfejsy i ukrywają szczegóły bazy czy zewnętrznych API.
Bibliografia
- PHP Manual: Classes and Objects. The PHP Group – Oficjalna dokumentacja OOP w PHP: klasy, obiekty, widoczność, typy
- PHP Manual: Type Declarations. The PHP Group – Opis deklaracji typów, typów zwracanych, union types i nullables w PHP
- Object-Oriented Software Construction. Prentice Hall (1997) – Klasyczne omówienie zasad OOP: klasy, enkapsulacja, kontrakty
- Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley (2003) – Modele domenowe, encje, value objecty, serwisy w projektach biznesowych



