php/object-creation@v1.0.0
article··11 min read

Factory Method: the pattern nobody needs until they need it for everything

#php #design-patterns #factory #dependency-injection #architecture

The textbook examples for Factory Method involve shapes and animals. ShapeFactory returning a Circle or Square based on a string. AnimalFactory constructing a Dog or a Cat. These examples are correct. They are also useless as design guidance, because nobody's business domain involves shapes.

The pattern matters the moment you have a runtime decision about which implementation to create, and the point where you make that decision should not be scattered across the entire codebase.

The payment gateway problem

We had four payment methods: card (Stripe), bank transfer (local PSP), BLIK, and instalment financing (third-party integration). Each had a different API, different error modes, different retry semantics, different webhook formats.

Without a factory the selection logic ended up in the controller:

// Before: selection logic in the wrong place
class PaymentController
{
    public function charge(Request $request): Response
    {
        $method = $request->input('payment_method');

        if ($method === 'card') {
            $gateway = new StripeGateway(config('stripe.secret'));
        } elseif ($method === 'blik') {
            $gateway = new BlikGateway(config('blik.merchant_id'), config('blik.api_key'));
        } elseif ($method === 'transfer') {
            $gateway = new BankTransferGateway(config('psp.endpoint'));
        } else {
            throw new \InvalidArgumentException("Unknown payment method: {$method}");
        }

        return $gateway->charge($request->input('amount'), $request->input('currency'));
    }
}

This is readable with two options. When we added a fourth, the same if-elseif chain existed in the controller, in the refund handler, in the webhook router, and in the admin reconciliation job. Adding a fifth gateway meant finding all four locations. Classic architecture held together by spit and duct tape, and the duct tape was a global search for elseif.

The extracted factory

interface PaymentGatewayInterface
{
    public function charge(Money $amount, string $currency, array $metadata): ChargeResult;
    public function refund(string $chargeId, Money $amount): RefundResult;
    public function parseWebhook(array $payload, string $signature): WebhookEvent;
}

final class PaymentGatewayFactory
{
    private array $resolvers = [];

    public function __construct(
        private readonly ContainerInterface $container,
    ) {}

    public function register(string $method, string $gatewayClass): void
    {
        $this->resolvers[$method] = $gatewayClass;
    }

    public function make(string $method): PaymentGatewayInterface
    {
        if (!isset($this->resolvers[$method])) {
            throw new UnsupportedPaymentMethodException($method);
        }

        // Container resolves the gateway's own dependencies (credentials, HTTP client, logger)
        return $this->container->make($this->resolvers[$method]);
    }
}
// Registered in a service provider — one place, one time
$factory->register('card',     StripeGateway::class);
$factory->register('blik',     BlikGateway::class);
$factory->register('transfer', BankTransferGateway::class);
$factory->register('financing',FinancingGateway::class);

The controller becomes:

class PaymentController
{
    public function __construct(
        private readonly PaymentGatewayFactory $factory,
    ) {}

    public function charge(Request $request): Response
    {
        $gateway = $this->factory->make($request->input('payment_method'));
        return $gateway->charge(...);
    }
}

Adding a fifth gateway means: implement the interface, register. Nothing else changes.

Two failure modes I see in factory implementations

The first is factories that construct instead of resolve. A factory calling new GatewayClass(config('...')) inline is a factory that will silently break when the gateway gains a new dependency. The factory should delegate construction to the DI container. If you are not on a framework with a container, the minimum is that the factory accepts gateway instances through its constructor rather than building them internally.

The second is factories that return the wrong abstraction. The factory above returns PaymentGatewayInterface. I have seen factories return StripeGateway with an interface that is effectively the Stripe API (createPaymentIntent(), retrieveBalance()) and thin wrappers for other gateways that break under load because BLIK has no concept of a "payment intent". The interface should represent your domain vocabulary, not any particular provider's API.

When to use factory vs. strategy

Confusing Factory Method with Strategy is common enough to address directly. They look similar but solve different problems. Factory Method is about the type of object varying (you create different classes based on runtime input, the caller does not hold a reference to the factory after calling make(), it just gets an abstraction. Strategy is about the algorithm varying) you inject a different implementation of the same interface into a class that uses it, and that class holds a reference to the strategy and calls methods on it directly.

In practice: if the selection happens once at the start of a request and the result is used throughout, that is a factory. If the selection happens repeatedly within a single computation, that is a strategy.

Testing the factory

The factory itself has a trivial test surface: does it return the right type for known inputs and throw for unknown ones. The meaningful tests are on the interface contract, write a shared test that every gateway must pass:

abstract class PaymentGatewayContractTest extends TestCase
{
    abstract protected function makeGateway(): PaymentGatewayInterface;

    public function testChargeReturnsChargeResult(): void
    {
        $gateway = $this->makeGateway();
        $result  = $gateway->charge(
            new Money(1000, 'PLN'),
            'PLN',
            ['order_id' => 'test-123']
        );
        $this->assertInstanceOf(ChargeResult::class, $result);
        $this->assertNotEmpty($result->chargeId);
    }
}

class StripeGatewayTest extends PaymentGatewayContractTest
{
    protected function makeGateway(): PaymentGatewayInterface
    {
        return new StripeGateway(apiKey: 'sk_test_fake', httpClient: $this->mockClient());
    }
}

What I watch for in code review

When I see a factory, my first question is: what triggers the decision?

If the answer is a string from user input or a column in the database (correct use. If it is a compile-time constant or an environment variable that never changes at runtime) wrong tool, use the DI container to bind the concrete type once and inject it directly. If it is a growing if-elseif chain that the team is already nervous about, the factory is overdue, but it is still the right fix.

end of node