Объектно-ориентированное программирование на PHP: классы, объекты, наследование и интерфейсы

Объектно-ориентированное программирование на PHP: классы, объекты, наследование и интерфейсы Полезное

Объектно-ориентированное программирование на PHP — это способ строить программу из объектов: каждый объект хранит свои данные (свойства) и умеет с ними работать (методы), а описывается классом. Ниже — рабочие примеры на одном сюжете (товары и заказ интернет-магазина): класс и объект, конструктор, области видимости, наследование, абстрактные классы, интерфейсы, трейты, readonly и enum.

Весь код проверен 30 сентября 2026 года на PHP 8.4.25 и на текущей ветке PHP 8.5 (8.5.11, CLI) — вывод одинаковый. Возможности, которых нет в более старых версиях, подписаны номером версии.

Четыре термина, которые легко спутать:

  • класс — описание: какие свойства и методы будут у объектов;
  • объект (экземпляр) — конкретное значение, созданное по классу через new;
  • свойство — переменная внутри объекта;
  • метод — функция внутри класса, внутри нее текущий объект доступен как $this.

Класс и объект: минимальный рабочий пример

Класс похож на бланк накладной, объект — на заполненную накладную. Сохраните код в product.php и запустите php product.php.

<?php
declare(strict_types=1);

class Product
{
    public function __construct(
        public readonly string $title,
        private int $priceKopecks,
    ) {
        if ($priceKopecks < 0) {
            throw new InvalidArgumentException('Цена не может быть отрицательной');
        }
    }

    public function price(): int
    {
        return $this->priceKopecks;
    }

    public function discount(int $percent): void
    {
        if ($percent < 0 || $percent > 100) {
            throw new InvalidArgumentException('Скидка - от 0 до 100 процентов');
        }
        $this->priceKopecks = intdiv($this->priceKopecks * (100 - $percent), 100);
    }
}

$book = new Product('Книга по PHP', 150000);
$pen = new Product('Ручка', 9900);

$book->discount(10);

echo $book->title, ': ', $book->price() / 100, " руб.\n";
echo $pen->title, ': ', $pen->price() / 100, " руб.\n";

В консоли появятся две строки: скидка изменила только книгу, потому что у каждого объекта свой набор значений.

Книга по PHP: 1350 руб.
Ручка: 99 руб.

Цена хранится целым числом копеек: дробные рубли во float дают ошибки округления.

Конструктор и сокращенная запись свойств

__construct — метод, который PHP вызывает сам при new Product(...). Его задача — привести объект в корректное состояние, поэтому проверка цены стоит именно здесь: объект с отрицательной ценой создать нельзя.

Модификатор перед параметром конструктора (public, private, protected) одновременно объявляет свойство и записывает в него аргумент. Это constructor property promotion, доступно с PHP 8.0. Без него пришлось бы отдельно объявить свойство и написать $this->title = $title;.

Области видимости и readonly

Модификатор Откуда доступно
public из любого кода
protected из самого класса и его наследников
private только из класса, где объявлено

readonly (PHP 8.1) разрешает записать свойство один раз, обычно в конструкторе. Снаружи класса это сделать нельзя; до PHP 8.3 включительно инициализировать свойство мог только класс, где оно объявлено, с PHP 8.4 — и его наследник. Граница: запрет касается присваивания свойству. Если в readonly-свойстве лежит другой объект, его внутреннее состояние менять по-прежнему можно.

Что будет при нарушении правил — три попытки после кода выше:

$book->priceKopecks = 1;
// Error: Cannot access private property Product::$priceKopecks

$book->title = 'Другая книга';
// Error: Cannot modify readonly property Product::$title

new Product('Ручка', '99');
// TypeError: Product::__construct(): Argument #2 ($priceKopecks)
// must be of type int, string given

Каждая строка бросает ошибку и останавливает скрипт, если ее не перехватить. Error и TypeError не наследуют Exception, поэтому catch (Exception $e) их не поймает — нужен catch (Error $e) или общий catch (Throwable $e). Исправление — работать через публичные методы (price(), discount()) и передавать значения нужного типа. Третья ошибка возникает из-за declare(strict_types=1): без этой директивы PHP молча превратил бы строку '99' в число.

Скрытие данных за методами называют инкапсуляцией: правило «цена не отрицательна, скидка от 0 до 100» живет в одном месте, и обойти его снаружи нельзя.

Объект не копируется при присваивании

$same = $book;        // вторая переменная для того же объекта
$copy = clone $book;  // отдельная копия
$same->discount(50);

echo $book->price(), ' ', $copy->price(), "\n"; // 67500 135000

Переменная хранит не сам объект, а его идентификатор. Поэтому объект, переданный в функцию, меняется и для вызывающего кода. clone делает поверхностную копию: вложенные объекты остаются общими, пока вы не опишете их копирование в методе __clone.

Наследование, абстрактные классы и интерфейсы

Наследование (extends) — это отношение «является разновидностью»: дочерний класс получает свойства и методы родителя и может их дополнить или переопределить. У класса в PHP только один родитель.

<?php
declare(strict_types=1);

interface HasPrice
{
    public function price(): int;
}

abstract class Item implements HasPrice
{
    public function __construct(
        protected string $title,
        protected int $basePrice,
    ) {
    }

    abstract public function delivery(): int;

    public function price(): int
    {
        return $this->basePrice + $this->delivery();
    }

    public function label(): string
    {
        return static::class . ' «' . $this->title . '»';
    }
}

class PaperBook extends Item
{
    public function delivery(): int
    {
        return 30000;
    }
}

class EBook extends Item
{
    public function delivery(): int
    {
        return 0;
    }

    #[\Override]
    public function label(): string
    {
        return parent::label() . ' (файл)';
    }
}

final class GiftCard implements HasPrice
{
    public function __construct(private int $amount)
    {
    }

    public function price(): int
    {
        return $this->amount;
    }
}

function total(HasPrice ...$items): int
{
    $sum = 0;
    foreach ($items as $item) {
        $sum += $item->price();
    }
    return $sum;
}

$paper = new PaperBook('PHP 8', 150000);
$ebook = new EBook('PHP 8', 90000);

echo $paper->label(), ' - ', $paper->price(), "\n";
echo $ebook->label(), ' - ', $ebook->price(), "\n";
echo 'Итого: ', total($paper, $ebook, new GiftCard(50000)), "\n";
PaperBook «PHP 8» - 180000
EBook «PHP 8» (файл) - 90000
Итого: 320000

Что здесь происходит:

  • Item — абстрактный класс: общий код (price(), label()) плюс метод delivery() без тела, который обязан написать каждый наследник. Создать сам Item нельзя: new Item('x', 1) дает Error: Cannot instantiate abstract class Item.
  • HasPrice — интерфейс: только договор «у объекта есть price(): int». GiftCard не товар и не наследует Item, но договор выполняет.
  • total() принимает любые объекты с этим интерфейсом и не знает их классов. Один вызов $item->price() исполняет разный код — это и есть полиморфизм.
  • parent::label() вызывает версию родителя, static::class дает имя класса реального объекта.
  • #[\Override] (PHP 8.3) просит PHP проверить, что метод действительно переопределяет родительский. При опечатке lable() скрипт не запустится: Fatal error: B::lable() has #[\Override] attribute, but no matching parent method exists.
  • final запрещает наследовать класс.

Перегрузки методов по набору параметров, как в Java или C#, в PHP нет: два метода с одним именем в классе объявить нельзя. Вместо нее используют параметры по умолчанию, объединенные типы (int|string) или отдельные методы с говорящими именами.

Трейты, enum и readonly-классы

Третий пример собирает заказ и показывает возможности PHP 8.1-8.4.

<?php
declare(strict_types=1);

trait HasHistory
{
    private array $history = [];

    protected function remember(string $event): void
    {
        $this->history[] = $event;
    }

    public function history(): array
    {
        return $this->history;
    }
}

enum OrderStatus: string
{
    case New = 'new';
    case Paid = 'paid';
    case Shipped = 'shipped';

    public function label(): string
    {
        return match ($this) {
            self::New => 'Новый',
            self::Paid => 'Оплачен',
            self::Shipped => 'Отправлен',
        };
    }
}

final readonly class Money
{
    public function __construct(
        public int $kopecks,
        public string $currency = 'RUB',
    ) {
    }

    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException('Разные валюты');
        }
        return new self($this->kopecks + $other->kopecks, $this->currency);
    }
}

final class Order
{
    use HasHistory;

    public private(set) OrderStatus $status = OrderStatus::New;

    public function __construct(public readonly Money $total)
    {
    }

    public function pay(): void
    {
        if ($this->status !== OrderStatus::New) {
            throw new LogicException('Оплатить можно только новый заказ');
        }
        $this->status = OrderStatus::Paid;
        $this->remember('Оплата');
    }
}

$sum = (new Money(150000))->add(new Money(9900));
$order = new Order($sum);
$order->pay();

echo $order->status->label(), ', ', $order->total->kopecks / 100, " руб.\n";
echo implode(', ', $order->history()), "\n";
echo OrderStatus::from('shipped')->label(), "\n";
var_dump(OrderStatus::tryFrom('lost'));
Оплачен, 1599 руб.
Оплата
Отправлен
NULL
  • Трейт (trait + use) — готовый кусок кода, который PHP вставляет в класс. Он не создает отношения «является» и типом не служит: $order instanceof HasHistory вернет false.
  • Enum (PHP 8.1) — тип с закрытым списком значений. Свойство $status не может содержать ничего, кроме трех вариантов, а опечатка в имени варианта — ошибка, а не «тихая» неверная строка. from() превращает значение из базы или формы в вариант и бросает ValueError: "lost" is not a valid backing value for enum OrderStatus; tryFrom() в той же ситуации возвращает null.
  • readonly-класс (PHP 8.2) делает все свойства readonly. Money — объект-значение: add() не меняет сумму, а возвращает новую. Попытка $sum->kopecks = 0 дает Error: Cannot modify readonly property Money::$kopecks.
  • public private(set) (PHP 8.4) — читать свойство можно отовсюду, записывать только внутри класса. Строка $order->status = OrderStatus::Shipped; снаружи дает Error: Cannot modify private(set) property Order::$status from global scope. Повторный $order->pay() бросит LogicException.

Что выбрать: абстрактный класс, интерфейс или трейт

Абстрактный класс Интерфейс Трейт
Сколько можно подключить один родитель несколько несколько
Содержит реализацию да нет (сигнатуры методов и константы, с PHP 8.4 еще объявления свойств без хранения данных) да
Хранит свойства с данными да нет да
Служит типом параметра да да нет
Когда брать у близких классов общий код и состояние нужен договор для несвязанных классов одинаковый код в классах без общего родителя

Практическое правило для начала: тип параметра функции — интерфейс, общий код родственных классов — абстрактный класс, трейт — когда первых двух не хватает. Глубокие цепочки наследования трудно менять, поэтому объект часто получает другой объект в конструкторе (композиция), как Order получает Money.

Выводы

  • Класс описывает свойства и методы, объект создается через new; переменная хранит идентификатор объекта, копию делает clone.
  • Конструктор с promotion (PHP 8.0) объявляет и заполняет свойства в одной записи; проверки в нем не дают создать некорректный объект.
  • private, protected, readonly и private(set) закрывают данные от внешнего кода: изменить состояние можно только через методы класса.
  • Наследование одно (extends), интерфейсов и трейтов может быть несколько; полиморфизм — вызов одного метода интерфейса у объектов разных классов.
  • enum заменяет строковые константы статусов закрытым типом, readonly class подходит для объектов-значений.

Где применяется / связь с практикой

На классах построены современные PHP-фреймворки и библиотеки: контроллер, модель, сервис отправки почты, запрос и ответ HTTP — это объекты, а контейнер зависимостей связывает их через интерфейсы. Без уверенного владения ООП код фреймворка прочитать трудно, а тест на сервис не написать: подмена зависимости в тесте работает именно за счет интерфейса.

Освойте тему на практике

Проектирование классов, паттерны, тестирование и работа с фреймворками разбираются на курсе «PHP-разработчик. Продвинутый уровень». Познакомиться с форматом занятий можно на открытых уроках Otus.

Смежные темы: принципы и особенности ООП, что нового в PHP 8.

FAQ

С какой версии в PHP есть ООП?
Классы были уже в PHP 3 и 4, но в урезанном виде. Области видимости, интерфейсы, абстрактные классы и исключения появились в PHP 5 (2004 год), трейты — в PHP 5.4.

Чем self отличается от static?
self указывает на класс, в котором написан код, static — на класс, у которого метод реально вызван (позднее статическое связывание). В примере static::class в Item вернул PaperBook и EBook, а self::class вернул бы Item.

Можно ли обойтись без геттеров и сеттеров?
Часто да: public readonly закрывает чтение без изменения, private(set) в PHP 8.4 — чтение снаружи и запись внутри. Метод нужен там, где изменение сопровождается проверкой или действием, как pay() у заказа.

OTUS Журнал