Singleton and Builder are two more creational patterns from the GoF catalogue. Singleton is probably the most controversial – often misused, but legitimate in specific contexts. Builder shines when constructing complex objects with many optional parameters. I show both with PHP implementations, pitfalls, and practical examples.
Singleton – one instance, global access
<?php
declare(strict_types=1);
// Classic Singleton - private constructor, static instance
class Configuration
{
private static ?self $instance = null;
private array $data = [];
private function __construct() {} // prevent direct instantiation
private function __clone() {} // prevent cloning
public static function getInstance(): static
{
if (static::$instance === null) {
static::$instance = new static();
}
return static::$instance;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
}
// Usage
Configuration::getInstance()->set('debug', true);
$debug = Configuration::getInstance()->get('debug'); // true - same instance
Why Singleton is often wrong – and when it is right
<?php
// The problems with Singleton:
// 1. Global state - hidden dependencies, hard to test
// 2. Tight coupling - cannot swap implementation without changing callers
// 3. Breaks single responsibility - manages its own lifecycle AND does its job
// Hard to test:
class OrderService
{
public function process(): void
{
$debug = Configuration::getInstance()->get('debug'); // hidden dependency!
// How do you test with different config values?
}
}
// BETTER - inject the dependency (DI)
class OrderService
{
public function __construct(private ConfigInterface $config) {}
public function process(): void
{
$debug = $this->config->get('debug'); // visible dependency, mockable
}
}
// When Singleton IS legitimate:
// - Logger (one handler, append-only, no state that affects behaviour)
// - Database connection pool (resource management, one set of connections)
// - In Magento 2: DI container manages shared instances - effectively singletons
// without the global state problem, because they are injected not retrieved
Builder – step-by-step construction
<?php
declare(strict_types=1);
// Without Builder - constructor explosion
class Order
{
public function __construct(
int $customerId, array $items, string $shippingMethod,
?string $couponCode, ?string $giftMessage, bool $isGift,
string $billingAddress, string $shippingAddress,
?string $paymentMethod, float $shippingCost
// ... more fields
) {}
}
// With Builder - readable, optional steps
class OrderBuilder
{
private int $customerId;
private array $items = [];
private string $shippingMethod = 'flatrate';
private ?string $couponCode = null;
private ?string $giftMessage = null;
private bool $isGift = false;
private float $shippingCost = 0.0;
public function forCustomer(int $customerId): static
{
$this->customerId = $customerId;
return $this;
}
public function withItems(array $items): static
{
$this->items = $items;
return $this;
}
public function addItem(string $sku, int $qty, float $price): static
{
$this->items[] = compact('sku', 'qty', 'price');
return $this;
}
public function withShipping(string $method, float $cost = 0.0): static
{
$this->shippingMethod = $method;
$this->shippingCost = $cost;
return $this;
}
public function withCoupon(string $code): static
{
$this->couponCode = $code;
return $this;
}
public function asGift(string $message): static
{
$this->isGift = true;
$this->giftMessage = $message;
return $this;
}
public function build(): Order
{
if (empty($this->customerId)) {
throw new \LogicException('Customer ID is required');
}
if (empty($this->items)) {
throw new \LogicException('Order must have at least one item');
}
return new Order(
$this->customerId, $this->items, $this->shippingMethod,
$this->couponCode, $this->giftMessage, $this->isGift,
$this->shippingCost
);
}
}
// Readable construction
$order = (new OrderBuilder())
->forCustomer(42)
->addItem('MG-RED-M', 2, 49.99)
->addItem('MG-BLUE-L', 1, 79.99)
->withShipping('dhl', 14.99)
->withCoupon('SUMMER10')
->asGift('Happy birthday!')
->build();
Builder in Magento 2 – SearchCriteriaBuilder
<?php
// Magento 2's SearchCriteriaBuilder is a classic Builder
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 'active')
->addFilter('price', 100, 'gt')
->addSortOrder(
$this->sortOrderBuilder->setField('name')->setAscendingDirection()->create()
)
->setPageSize(20)
->setCurrentPage(1)
->create(); // build() equivalent
$result = $this->productRepository->getList($searchCriteria);
Summary
Singleton: use it only when you genuinely need one instance with global access and no testability requirements – most of the time DI is the better choice. Builder: use it when a constructor has more than 4-5 parameters, especially when many are optional. It makes construction intent clear and prevents invalid states by validating in build(). Both Magento 2’s DI container and SearchCriteriaBuilder demonstrate these patterns in a well-designed, practical form.
