PHP / Архитектура
ООП в PHP: классы, объекты, интерфейсы и композиция
Основы объектно-ориентированного PHP 8.4: типизированные свойства, конструктор, инкапсуляция, интерфейсы, наследование и композиция.
Содержание статьи
Объект объединяет данные и операции, которые поддерживают их корректное состояние. ООП полезно, когда у предмета есть инварианты и поведение: заказ нельзя создать с отрицательной суммой, email-уведомление должно отправляться через определённый контракт.
Не каждую функцию нужно превращать в класс. Простая чистая операция часто понятнее как функция. Класс оправдан, когда он выражает сущность, сервис или устойчивый контракт.
Первый класс
<?phpdeclare(strict_types=1);final class Money{ public function __construct( private int $amountInKopecks, private string $currency = 'RUB', ) { if ($amountInKopecks < 0) { throw new InvalidArgumentException('Сумма не может быть отрицательной.'); } if ($currency === '') { throw new InvalidArgumentException('Укажите валюту.'); } } public function amountInKopecks(): int { return $this->amountInKopecks; } public function currency(): string { return $this->currency; } public function add(self $other): self { if ($this->currency !== $other->currency) { throw new DomainException('Нельзя складывать разные валюты.'); } return new self( $this->amountInKopecks + $other->amountInKopecks, $this->currency, ); }}$subtotal = new Money(10_000);$delivery = new Money(500);$total = $subtotal->add($delivery);Конструктор проверяет инварианты, типизированные свойства ограничивают форму состояния, private не позволяет менять сумму снаружи. Метод add() возвращает новый объект, поэтому исходные значения не изменяются.
final запрещает наследование, которое класс не проектировал. Это разумный default для небольшого доменного объекта.
Видимость
public— доступ из любого места;protected— внутри класса и наследников;private— только внутри объявившего класса.
Публичное свойство легко перевести в недопустимое состояние:
final class Account{ public int $balance = 0;}$account = new Account();$account->balance = -1_000_000;Лучше предоставить операции:
final class Account{ private int $balance = 0; public function deposit(int $amount): void { if ($amount <= 0) { throw new InvalidArgumentException('Сумма пополнения должна быть положительной.'); } $this->balance += $amount; } public function balance(): int { return $this->balance; }}Инкапсуляция — не сокрытие ради сокрытия, а защита правил объекта.
Интерфейс
Интерфейс задаёт контракт без конкретной реализации:
interface Notifier{ public function send(string $recipient, string $message): void;}final class EmailNotifier implements Notifier{ public function send(string $recipient, string $message): void { // Здесь вызывается почтовый транспорт. }}Сервис зависит от контракта:
final class PasswordResetService{ public function __construct(private Notifier $notifier) { } public function request(string $email): void { $message = 'Инструкция по восстановлению доступа.'; $this->notifier->send($email, $message); }}Зависимость передаётся через конструктор. Это проще тестировать и менять, чем создание new EmailNotifier() внутри каждого метода.
Композиция
Композиция строит объект из других объектов. В примере PasswordResetService имеет Notifier. Такое отношение обычно гибче наследования.
$notifier = new EmailNotifier();$service = new PasswordResetService($notifier);Для теста можно передать реализацию, которая сохраняет сообщения в память:
final class InMemoryNotifier implements Notifier{ /** @var list<array{recipient: string, message: string}> */ public array $messages = []; public function send(string $recipient, string $message): void { $this->messages[] = [ 'recipient' => $recipient, 'message' => $message, ]; }}Наследование
Наследование выражает отношение «является» и должно сохранять контракт родителя.
abstract class ReportExporter{ abstract public function contentType(): string; /** @param list<array<string, scalar|null>> $rows */ abstract public function export(array $rows): string;}final class JsonReportExporter extends ReportExporter{ public function contentType(): string { return 'application/json; charset=UTF-8'; } public function export(array $rows): string { return json_encode( $rows, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ); }}Не создавайте SportsCar extends Car только ради демонстрации. В приложении наследование полезно, когда есть реальное общее поведение и взаимозаменяемость. Иначе интерфейс и композиция обычно яснее.
readonly
Для объекта, свойства которого задаются один раз:
readonly class ArticleId{ public function __construct(public int $value) { if ($value < 1) { throw new InvalidArgumentException('ID должен быть положительным.'); } }}readonly не делает вложенный объект глубоко неизменяемым: он запрещает переназначать свойства самого объекта.
Пространства имён и автозагрузка
namespace App\Service;final class ArticlePublisher{}Пространства имён предотвращают конфликты. В большом проекте классы загружаются автозагрузчиком по соглашению о путях. Не подключайте десятки файлов вручную в случайных контроллерах; держите bootstrap как единую точку сборки приложения.
Частые ошибки
- класс содержит только getters/setters и не защищает правила;
- всё состояние объявлено
public; - сервис сам создаёт конкретные зависимости;
- наследование используют ради переиспользования пары строк;
- в одном «God object» смешаны HTTP, SQL, письмо и шаблон;
- исключение применяется для обычной ветки, которую проще выразить результатом;
- DTO, сущность и запись базы считаются одним и тем же объектом без анализа.
Что запомнить
Хороший класс защищает допустимое состояние и имеет небольшую ответственность. Используйте типы, private, конструкторную проверку и явные методы. Зависите от интерфейсов там, где действительно нужна смена реализации, и предпочитайте композицию наследованию без отношения «является».