Обидва патерни належать до породжувальних, і на співбесіді їх часто зводять до «щось, що створює обʼєкти». Корисніше розвести їх за питанням, на яке вони відповідають. Factory Method відповідає, який клас створити: базовий клас описує сценарій (надіслати клієнту посилання на оплату) і просить продукт у власного абстрактного методу, а підклас вирішує, чи це посилання monobank, чи LiqPay. Builder відповідає, як зібрати один обʼєкт, у якого багато частин і правила між ними: платіжне доручення з IBAN, сумою, ПДВ і призначенням збирається ланцюжком методів, а build() перевіряє все разом і віддає готовий обʼєкт.
У Laravel обидва патерни є, але не там, де їх шукають за назвою. Справжній Factory Method живе в Illuminate\Support\Manager: createDriver() шукає в нащадку метод create{Name}Driver(), і так SessionManager створює файловий чи database-драйвер. А Vacancy::factory() з Factory Method повʼязує лише слово: definition(), state(), count(), for() і has() збирають опис тестових записів, make() чи create() їх видають, тобто це будівельник. Будівельниками є і Query Builder, і MailMessage у сповіщеннях, а в Symfony FormBuilderInterface і Doctrine QueryBuilder.
На практиці найчастіше ламається мутабельність. Query Builder змінює сам себе: where() додає умову до того самого обʼєкта й повертає його ж, тому базовий запит, з якого зробили дві гілки, отримує умови обох. Перед розгалуженням потрібен clone() або новий виклик query(). Model factory поводиться навпаки: кожен state() і count() повертає нову копію, тож базовий опис можна тримати в змінній і використовувати повторно.
Межі в обох патернів помітні. Підклас, який існує лише заради одного new, простіше замінити переданим у конструктор обʼєктом чи замиканням: композицію легше читати й тестувати, ніж ієрархію. Будівельник для обʼєкта з кількома полями без правил між ними поступається named arguments з PHP 8, а для типових варіантів ще краще читаються іменовані конструктори на кшталт PaymentOrder::salary().
// Builder: обʼєкт збирається кроками з назвами, правила перевіряє build()
final class PaymentOrderBuilder
{
private ?string $iban = null;
private ?int $amount = null;
private ?int $vat = null;
private string $purpose = '';
public function to(string $iban): self { $this->iban = $iban; return $this; }
public function amount(int $kopecks): self { $this->amount = $kopecks; return $this; }
public function withVat(int $kopecks): self { $this->vat = $kopecks; return $this; }
public function purpose(string $text): self { $this->purpose = $text; return $this; }
public function build(): PaymentOrder
{
if ($this->iban === null || $this->amount === null) {
throw new LogicException('Потрібні IBAN і сума');
}
// Правило на кілька полів: перевірити його можна, лише коли зібрано все
if ($this->vat !== null && $this->vat >= $this->amount) {
throw new LogicException('ПДВ не може дорівнювати сумі чи перевищувати її');
}
return new PaymentOrder($this->iban, $this->amount, $this->vat, $this->purpose);
}
}
// Factory Method: сценарій у базовому класі, а клас продукту обирає підклас
abstract class PaymentLinkSender
{
abstract protected function createLink(Order $order): PaymentLink;
public function message(Order $order): string
{
return "Оплатіть замовлення №{$order->id}: {$this->createLink($order)->url()}";
}
}
final class MonobankLinkSender extends PaymentLinkSender
{
protected function createLink(Order $order): PaymentLink { return new MonobankLink($order); }
}
Скажіть: «Factory Method вирішує, який клас, Builder вирішує, як зібрати». Потім покажіть, що знаєте про мутабельність: Query Builder треба клонувати перед розгалуженням, а model factory можна тримати як шаблон, бо кожен `state()` повертає нову копію.