<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПОРОДЖУВАЛЬНИЙ ПАТЕРН

БудівельникBuilder

Складний обʼєкт збирається крок за кроком, методами з назвами, а не конструктором на дванадцять параметрів. Наприкінці build() перевіряє все разом і віддає готовий обʼєкт.

Laravel Symfony складність 1 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Конструктор, у якому губляться аргументи

Бухгалтерський сервіс формує платіжки за рахунками. У платіжки є отримувач, IBAN, ЄДРПОУ, сума, призначення, ставка ПДВ, валюта, дата й ще кілька необовʼязкових полів. Конструктор росте, аргументи стоять у ряд без назв, а правило про ПДВ у призначенні повторюється в кожному місці, де створюють платіж.

src/Billing/PayInvoice.php
1final class PayInvoice
2{
3 public function handle(Invoice $invoice): PaymentOrder
4 {
5 $purpose = 'Оплата згідно з рахунком № '.$invoice->number;
6 if ($invoice->withVat) {
1Правило про ПДВ у призначенні копіюють у кожен сервіс, який створює платіж.
7 $purpose .= ', у т.ч. ПДВ 20% '.$invoice->vat();
8 }
9
10 return new PaymentOrder(
11 $invoice->company,
12 $invoice->iban,
13 $invoice->edrpou,
14 $invoice->total,
15 $purpose,
16 $invoice->withVat,
17 20,
2Що означає це 20, видно лише з підказки IDE. Переставте його з прапорцем рядком вище, і код усе одно запуститься.
18 null,
3Два null лише для того, щоб дістатися до параметрів після них.
19 null,
20 false,
21 'UAH',
22 new DateTimeImmutable('tomorrow'),
23 );
24 }
25}
02 · ІДЕЯ РІШЕННЯ

Частина за частиною, перевірка наприкінці

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
PAYMENTORDERBUILDERкожен метод повертає $thisnewPayInvoiceto()amount()withVat()build():PaymentOrder
PAYMENTORDERBUILDERкожен метод повертає $thisPayInvoiceto()amount()withVat()build():PaymentOrder
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • PaymentOrderBuilder (кожен метод повертає $this) містить: to(), amount(), withVat(), build().
  • PayInvoice використовує PaymentOrderBuilder.
  • Дані йдуть від to() до amount().
  • Дані йдуть від amount() до withVat().
  • Дані йдуть від withVat() до build().
  • build() створює PaymentOrder.
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class PayInvoice
2{
3 public function handle(Invoice $invoice): PaymentOrder
4 {
5 $purpose = 'Оплата згідно з рахунком № '.$invoice->number;
6 if ($invoice->withVat) {
7 $purpose .= ', у т.ч. ПДВ 20% '.$invoice->vat();
8 }
9
10 return new PaymentOrder(
11 $invoice->company,
12 $invoice->iban,
13 $invoice->edrpou,
14 $invoice->total,
15 $purpose,
16 $invoice->withVat,
17 20,
18 null,
19 null,
20 false,
21 'UAH',
22 new DateTimeImmutable('tomorrow'),
23 );
24 }
25}
1<?php
2
3final class PaymentOrder
4{
5 public function __construct(
6 public readonly string $recipient,
7 public readonly string $iban,
8 public readonly string $edrpou,
9 public readonly int $amount,
10 public readonly string $purpose,
11 ) {}
12}
13
14final class PaymentOrderBuilder
15{
16 private string $recipient = '';
17 private string $iban = '';
18 private string $edrpou = '';
19 private int $amount = 0;
20 private string $purpose = '';
21 private ?int $vatPercent = null;
22
231 public function to(string $recipient, string $iban, string $edrpou): self
24 {
25 $this->recipient = $recipient;
26 $this->iban = $iban;
27 $this->edrpou = $edrpou;
28
29 return $this;
30 }
31
32 public function amount(int $kopecks): self
33 {
34 $this->amount = $kopecks;
35
36 return $this;
37 }
38
39 public function purpose(string $text): self
40 {
41 $this->purpose = $text;
42
43 return $this;
44 }
45
46 public function withVat(int $percent = 20): self
47 {
48 $this->vatPercent = $percent;
49
50 return $this;
51 }
52
53 public function build(): PaymentOrder
54 {
552 if (! preg_match('/^UA\d{27}$/', $this->iban)) {
56 throw new InvalidArgumentException("IBAN «{$this->iban}» не схожий на український");
57 }
58 if ($this->amount <= 0 || $this->purpose === '') {
59 throw new InvalidArgumentException('Потрібні сума й призначення платежу');
60 }
61
623 $purpose = $this->vatPercent === null
63 ? "{$this->purpose}, без ПДВ"
64 : sprintf('%s, у т.ч. ПДВ %d%% %s грн', $this->purpose, $this->vatPercent, uah($this->vat()));
65
66 return new PaymentOrder($this->recipient, $this->iban, $this->edrpou, $this->amount, $purpose);
67 }
68
69 private function vat(): int
70 {
71 return intdiv($this->amount * $this->vatPercent, 100 + $this->vatPercent);
72 }
73}
74
75function uah(int $kopecks): string
76{
77 return number_format($kopecks / 100, 2, ',', ' ');
78}
79
804$order = (new PaymentOrderBuilder())
81 ->to('ТОВ «Кодова фабрика»', 'UA213223130000026007233566001', '41234567')
82 ->amount(1_250_000)
83 ->purpose('Оплата за розробку згідно з рахунком № 17')
84 ->withVat()
85 ->build();
86
87echo "Отримувач: {$order->recipient}\n";
88echo 'Сума: ', uah($order->amount), " грн\n";
89echo "Призначення: {$order->purpose}\n\n";
90
91try {
92 (new PaymentOrderBuilder())
93 ->to('ТОВ «Кодова фабрика»', 'UA2132', '41234567')
94 ->amount(500_000)
95 ->build();
96} catch (InvalidArgumentException $e) {
97 echo 'Не створено: ', $e->getMessage(), "\n";
98}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 23–51

    Кожен метод задає одну частину й повертає $this. Назви методів пояснюють, що означає кожне значення.

  2. 2 рядки 55–60

    Перевірки, які стосуються кількох частин одразу, живуть у build(). Напівготова платіжка не зʼявиться.

  3. 3 рядки 62–64

    Правило про ПДВ у призначенні записане один раз. Сервісам більше не треба його памʼятати.

  4. 4 рядки 80–85

    Ланцюжок читається як опис платіжки. Необовʼязкове withVat() можна просто не викликати.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Query Builder і MailMessage

Query Builder збирає SQL частинами: where(), orderBy(), limit(), а get() виконує запит. MailMessage у сповіщеннях так само збирає лист: subject(), greeting(), line(), action(), а рендерить його вже пошта.

public function toMail(object $notifiable): MailMessage
{
    return (new MailMessage)
        ->subject('Новий відгук на вакансію')
        ->greeting('Вітаємо!')
        ->line('Кандидат відгукнувся на Senior PHP Developer.')
        ->action('Переглянути відгук', $this->url);
}

Ви вже користуєтесь цим, коли пишете DB::table('jobs')->where(...)->get() або метод toMail() у сповіщенні.

SYMFONY

FormBuilder і Doctrine QueryBuilder

У формі buildForm() отримує FormBuilderInterface і додає поля через add(). Doctrine QueryBuilder збирає DQL: where(), setParameter(), orderBy(), а getQuery() віддає готовий запит.

$jobs = $this->createQueryBuilder('j')
    ->where('j.city = :city')
    ->setParameter('city', 'Київ')
    ->orderBy('j.publishedAt', 'DESC')
    ->getQuery()
    ->getResult();

Ви вже користуєтесь цим, коли описуєте форму через $builder->add('title', TextType::class) або пишете createQueryBuilder() у репозиторії.

✓ ЯК ЦЕ ТЕСТУВАТИ

Тестуйте результат build(): повний набір частин дає готовий обʼєкт, неповний або неправильний дає виняток. Кожен метод ланцюжка окремо перевіряти не треба.

it('rejects a short IBAN', function () {
    $builder = (new PaymentOrderBuilder())
        ->to('ТОВ «Кодова фабрика»', 'UA2132', '41234567')
        ->amount(500_000)
        ->purpose('Оплата згідно з рахунком № 18');
 
    expect(fn () => $builder->build())
        ->toThrow(InvalidArgumentException::class);
});
05 · КОЛИ НЕ ТРЕБА

Named arguments часто вистачає

Якщо між полями немає правил, а необовʼязкові мають типові значення, named arguments з PHP 8 читаються не гірше за ланцюжок, і окремий клас не потрібен.

$order = new PaymentOrder(
    recipient: 'ТОВ «Кодова фабрика»',
    iban: 'UA213223130000026007233566001',
    amount: 1_250_000,
);
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Щоб викликати їх ланцюжком: ->to()->amount()->purpose(). Кожен виклик задає одну частину, і код читається як опис обʼєкта.

Частина правил стосується кількох полів одразу, а поки обʼєкт збирається, якихось полів ще немає. build() бачить усе разом і або віддає готовий обʼєкт, або кидає виняток.

Налаштування першого протечуть у другий: ПДВ чи призначення залишаться від попереднього виклику. Іммутабельний будівельник повертає копію в кожному методі, тож базовий екземпляр можна безпечно розгалужувати. Laravel Query Builder мутабельний, тому для розгалуження в нього є метод clone().