MidТеория8 min

Порождающие паттерны

Singleton, Factory Method, Abstract Factory, Builder, Prototype на PHP 8.4

Порождающие паттерны (Creational Patterns)

Порождающие паттерны абстрагируют процесс создания объектов. Вместо прямого new они предоставляют гибкие механизмы инстанцирования.

Singleton

Проблема

Нужно гарантировать, что у класса будет ровно один экземпляр, доступный глобально. Например, подключение к базе данных или конфигурация приложения.

Решение

<?php
declare(strict_types=1);

final class DatabaseConnection
{
    private static ?self $instance = null;

    // Private constructor prevents external instantiation
    private function __construct(
        private readonly string $dsn,
    ) {}

    public static function getInstance(string $dsn = 'sqlite::memory:'): self
    {
        return self::$instance ??= new self($dsn);
    }

    public function query(string $sql): array
    {
        // Execute query...
        return [];
    }

    // Prevent cloning
    private function __clone(): void {}

    // Prevent unserialization
    public function __wakeup(): never
    {
        throw new \RuntimeException('Cannot unserialize singleton');
    }
}

// Usage
$db = DatabaseConnection::getInstance('mysql:host=localhost;dbname=app');
$db->query('SELECT * FROM users');

PHP 8.4: Singleton через enum

<?php
declare(strict_types=1);

// Enum is a natural singleton -- only one instance per case
enum AppConfig
{
    case Instance;

    private const CONFIG_PATH = '/etc/app/config.json';

    public function get(string $key): mixed
    {
        static $config = null;
        $config ??= json_decode(
            file_get_contents(self::CONFIG_PATH),
            true,
            512,
            JSON_THROW_ON_ERROR,
        );

        return $config[$key] ?? null;
    }
}

// Usage
$dbHost = AppConfig::Instance->get('database.host');

Когда использовать

  • Подключение к внешнему ресурсу (БД, кеш) -- но лучше через DI
  • Конфигурация приложения
  • Логирование

В Symfony

В Symfony Singleton не нужен напрямую -- DI-контейнер делает это за вас:

# services.yaml
services:
    App\Service\DatabaseConnection:
        shared: true  # default — already singleton per container
<?php
declare(strict_types=1);

// Symfony manages single instance automatically
final readonly class OrderService
{
    public function __construct(
        private DatabaseConnection $db, // Same instance everywhere
    ) {}
}

Важно: В современном PHP Singleton как паттерн почти всегда заменяется Dependency Injection. Контейнер сам гарантирует единственный экземпляр.

Factory Method

Проблема

Код создаёт объекты через new, но конкретный класс должен определяться в рантайме. Прямое использование new привязывает к конкретной реализации.

Решение

<?php
declare(strict_types=1);

// Product interface
interface Notification
{
    public function send(string $recipient, string $message): void;
}

// Concrete products
final readonly class EmailNotification implements Notification
{
    public function send(string $recipient, string $message): void
    {
        // Send email via SMTP
        echo "Email to {$recipient}: {$message}\n";
    }
}

final readonly class SmsNotification implements Notification
{
    public function send(string $recipient, string $message): void
    {
        // Send SMS via gateway
        echo "SMS to {$recipient}: {$message}\n";
    }
}

final readonly class TelegramNotification implements Notification
{
    public function send(string $recipient, string $message): void
    {
        // Send via Telegram Bot API
        echo "Telegram to {$recipient}: {$message}\n";
    }
}

// Creator with factory method
abstract class NotificationFactory
{
    // Factory method -- subclasses decide which class to instantiate
    abstract protected function createNotification(): Notification;

    // Template method using factory
    public function notify(string $recipient, string $message): void
    {
        $notification = $this->createNotification();
        $notification->send($recipient, $message);
    }
}

final class EmailNotificationFactory extends NotificationFactory
{
    protected function createNotification(): Notification
    {
        return new EmailNotification();
    }
}

final class SmsNotificationFactory extends NotificationFactory
{
    protected function createNotification(): Notification
    {
        return new SmsNotification();
    }
}

// Usage
$factory = new EmailNotificationFactory();
$factory->notify('[email protected]', 'Hello!');

PHP 8.4: Factory с enum и new без скобок

<?php
declare(strict_types=1);

enum NotificationType: string
{
    case Email = 'email';
    case Sms = 'sms';
    case Telegram = 'telegram';

    public function create(): Notification
    {
        return match ($this) {
            self::Email => new EmailNotification,
            self::Sms => new SmsNotification,
            self::Telegram => new TelegramNotification,
        };
    }
}

// Usage: clean and type-safe
$type = NotificationType::from('email');
$notification = $type->create();
$notification->send('[email protected]', 'Welcome!');

Когда использовать

  • Тип объекта определяется в рантайме (из конфига, БД, API)
  • Нужна расширяемость -- новые типы без изменения существующего кода
  • Инкапсуляция логики создания

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Psr\Container\ContainerInterface;

// Symfony service locator as factory
final readonly class NotificationFactory
{
    public function __construct(
        #[AutowireLocator(services: [
            'email' => EmailNotification::class,
            'sms' => SmsNotification::class,
            'telegram' => TelegramNotification::class,
        ])]
        private ContainerInterface $notifications,
    ) {}

    public function create(string $type): Notification
    {
        if (!$this->notifications->has($type)) {
            throw new \InvalidArgumentException("Unknown notification type: {$type}");
        }

        return $this->notifications->get($type);
    }
}

Abstract Factory

Проблема

Нужно создавать семейства связанных объектов без привязки к их конкретным классам. Например, элементы UI для разных ОС или отчёты для разных форматов.

Решение

<?php
declare(strict_types=1);

// Abstract products
interface Button
{
    public function render(): string;
}

interface Input
{
    public function render(): string;
}

// Concrete products: Bootstrap family
final readonly class BootstrapButton implements Button
{
    public function __construct(
        private string $label,
    ) {}

    public function render(): string
    {
        return "<button class=\"btn btn-primary\">{$this->label}</button>";
    }
}

final readonly class BootstrapInput implements Input
{
    public function __construct(
        private string $placeholder,
    ) {}

    public function render(): string
    {
        return "<input class=\"form-control\" placeholder=\"{$this->placeholder}\">";
    }
}

// Concrete products: Tailwind family
final readonly class TailwindButton implements Button
{
    public function __construct(
        private string $label,
    ) {}

    public function render(): string
    {
        return "<button class=\"px-4 py-2 bg-blue-500 text-white rounded\">{$this->label}</button>";
    }
}

final readonly class TailwindInput implements Input
{
    public function __construct(
        private string $placeholder,
    ) {}

    public function render(): string
    {
        return "<input class=\"border rounded px-3 py-2\" placeholder=\"{$this->placeholder}\">";
    }
}

// Abstract factory
interface UiFactory
{
    public function createButton(string $label): Button;
    public function createInput(string $placeholder): Input;
}

// Concrete factories
final class BootstrapUiFactory implements UiFactory
{
    public function createButton(string $label): Button
    {
        return new BootstrapButton($label);
    }

    public function createInput(string $placeholder): Input
    {
        return new BootstrapInput($placeholder);
    }
}

final class TailwindUiFactory implements UiFactory
{
    public function createButton(string $label): Button
    {
        return new TailwindButton($label);
    }

    public function createInput(string $placeholder): Input
    {
        return new TailwindInput($placeholder);
    }
}

// Client code works with any family
function renderForm(UiFactory $factory): string
{
    $button = $factory->createButton('Submit');
    $input = $factory->createInput('Enter name...');

    return $input->render() . "\n" . $button->render();
}

// Switch entire UI family with one change
echo renderForm(new TailwindUiFactory());

Когда использовать

  • Система должна работать с семействами связанных объектов
  • Нужно гарантировать совместимость объектов одного семейства
  • Хотите скрыть детали реализации от клиентского кода

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\When;

// Register concrete factory based on environment
#[When(env: 'dev')]
#[AsAlias(UiFactory::class)]
final class BootstrapUiFactoryService extends BootstrapUiFactory {}

#[When(env: 'prod')]
#[AsAlias(UiFactory::class)]
final class TailwindUiFactoryService extends TailwindUiFactory {}

Builder

Проблема

Объект имеет множество параметров. Конструктор с 10+ аргументами нечитаем. Разные комбинации параметров порождают телескопический конструктор.

Решение

<?php
declare(strict_types=1);

// Complex product
final readonly class Query
{
    public function __construct(
        public string $table,
        public array $select,
        public array $where,
        public ?string $orderBy,
        public ?string $direction,
        public ?int $limit,
        public ?int $offset,
    ) {}

    public function toSql(): string
    {
        $columns = implode(', ', $this->select) ?: '*';
        $sql = "SELECT {$columns} FROM {$this->table}";

        if ($this->where !== []) {
            $conditions = implode(' AND ', $this->where);
            $sql .= " WHERE {$conditions}";
        }

        if ($this->orderBy !== null) {
            $sql .= " ORDER BY {$this->orderBy} {$this->direction}";
        }

        if ($this->limit !== null) {
            $sql .= " LIMIT {$this->limit}";
        }

        if ($this->offset !== null) {
            $sql .= " OFFSET {$this->offset}";
        }

        return $sql;
    }
}

// Builder
final class QueryBuilder
{
    private string $table = '';
    private array $select = [];
    private array $where = [];
    private ?string $orderBy = null;
    private string $direction = 'ASC';
    private ?int $limit = null;
    private ?int $offset = null;

    public function from(string $table): self
    {
        $this->table = $table;
        return $this;
    }

    public function select(string ...$columns): self
    {
        $this->select = $columns;
        return $this;
    }

    public function where(string $condition): self
    {
        $this->where[] = $condition;
        return $this;
    }

    public function orderBy(string $column, string $direction = 'ASC'): self
    {
        $this->orderBy = $column;
        $this->direction = $direction;
        return $this;
    }

    public function limit(int $limit): self
    {
        $this->limit = $limit;
        return $this;
    }

    public function offset(int $offset): self
    {
        $this->offset = $offset;
        return $this;
    }

    public function build(): Query
    {
        if ($this->table === '') {
            throw new \LogicException('Table name is required');
        }

        return new Query(
            table: $this->table,
            select: $this->select,
            where: $this->where,
            orderBy: $this->orderBy,
            direction: $this->direction,
            limit: $this->limit,
            offset: $this->offset,
        );
    }
}

// Usage: fluent interface
$query = (new QueryBuilder())
    ->from('users')
    ->select('id', 'name', 'email')
    ->where('active = 1')
    ->where('age > 18')
    ->orderBy('name')
    ->limit(10)
    ->build();

echo $query->toSql();
// SELECT id, name, email FROM users WHERE active = 1 AND age > 18 ORDER BY name ASC LIMIT 10

PHP 8.4: Builder с property hooks

<?php
declare(strict_types=1);

final class HttpRequestBuilder
{
    private string $method = 'GET';
    private string $url = '';
    private array $headers = [];
    private ?string $body = null;

    // Property hook for validation on set
    public string $timeout {
        set(string $value) {
            if ((int) $value < 0) {
                throw new \InvalidArgumentException('Timeout must be positive');
            }
            $this->timeout = $value;
        }
    }

    public function method(string $method): self
    {
        $this->method = strtoupper($method);
        return $this;
    }

    public function url(string $url): self
    {
        $this->url = $url;
        return $this;
    }

    public function header(string $name, string $value): self
    {
        $this->headers[$name] = $value;
        return $this;
    }

    public function body(string $body): self
    {
        $this->body = $body;
        return $this;
    }

    public function build(): HttpRequest
    {
        return new HttpRequest(
            method: $this->method,
            url: $this->url,
            headers: $this->headers,
            body: $this->body,
        );
    }
}

final readonly class HttpRequest
{
    public function __construct(
        public string $method,
        public string $url,
        public array $headers,
        public ?string $body,
    ) {}
}

Когда использовать

  • Объект с множеством необязательных параметров
  • Нужен пошаговый процесс создания
  • Один и тот же процесс создания должен давать разные представления

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

// Symfony FormBuilder -- classic Builder pattern
final class UserFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, ['label' => 'Name'])
            ->add('email', EmailType::class, ['label' => 'Email'])
            ->add('save', SubmitType::class, ['label' => 'Register']);
    }
}

Prototype

Проблема

Нужно создавать копии объектов, не зная их конкретных классов. Или создание нового объекта дорого (запрос к БД, API), а нужно много похожих.

Решение

<?php
declare(strict_types=1);

// Prototype interface
interface Prototype
{
    public function clone(): static;
}

final class Report implements Prototype
{
    /** @var list<string> */
    private array $sections = [];

    public function __construct(
        private string $title,
        private string $template,
        private \DateTimeImmutable $date,
    ) {}

    public function addSection(string $section): void
    {
        $this->sections[] = $section;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    public function clone(): static
    {
        // Deep copy with new date
        $clone = clone $this;
        $clone->date = new \DateTimeImmutable();
        return $clone;
    }

    public function describe(): string
    {
        $sectionCount = count($this->sections);
        return "{$this->title} ({$this->template}) - {$sectionCount} sections";
    }
}

// Create expensive prototype once
$monthlyReport = new Report(
    title: 'Monthly Sales Report',
    template: 'financial',
    date: new \DateTimeImmutable(),
);
$monthlyReport->addSection('Revenue');
$monthlyReport->addSection('Expenses');
$monthlyReport->addSection('Profit');

// Clone and customize -- much cheaper
$januaryReport = $monthlyReport->clone();
$januaryReport->setTitle('January Sales Report');

$februaryReport = $monthlyReport->clone();
$februaryReport->setTitle('February Sales Report');

PHP 8.4: Clone с readonly и clone with

<?php
declare(strict_types=1);

// In PHP 8.4 readonly properties can be reset in clone context
final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}

    public function withAmount(int $amount): self
    {
        // PHP 8.4: clone with expression (RFC accepted)
        $clone = clone $this;
        // Readonly can be written in __clone context
        return $clone;
    }

    public function multiply(int $factor): self
    {
        return new self(
            amount: $this->amount * $factor,
            currency: $this->currency,
        );
    }
}

// Immutable value object with easy cloning
$price = new Money(amount: 1000, currency: 'USD');
$doubled = $price->multiply(2);
echo $doubled->amount; // 2000

Когда использовать

  • Создание объекта дорого (сложная инициализация, запросы)
  • Нужны вариации одного объекта-шаблона
  • Конкретный тип объекта неизвестен клиенту

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\DelayStamp;

// Symfony Messenger: Envelope is effectively a prototype
$baseEnvelope = new Envelope(new OrderCreatedEvent(orderId: 1));

// Clone and add stamps for different delivery configurations
$delayedEnvelope = $baseEnvelope->with(new DelayStamp(5000));

Сравнение порождающих паттернов

Паттерн Ключевая идея PHP 8.4 подход
Singleton Один экземпляр Enum case или DI container
Factory Method Делегирование создания Enum с create()
Abstract Factory Семейство объектов Interface + DI
Builder Пошаговое создание Fluent interface + named args
Prototype Клонирование clone + readonly

Проверь себя

Какой Symfony-компонент является примером паттерна Builder?

Чем Factory Method отличается от Abstract Factory?

Как в PHP 8.4 лучше всего реализовать Singleton?

Какой паттерн лучше использовать для создания объекта с 10+ необязательными параметрами?

Почему Singleton считается антипаттерном в современном PHP?