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. A ShapeFactory that returns Circle or Square based on a string. A AnimalFactory that constructs Dog or Cat. These examples are correct. They are also useless as design guidance because in production, nobody's business domain involves shapes.

The pattern becomes important the moment you have a runtime decision about which implementation to create — and the point at which you have that decision should not be scattered across your codebase. Here is where I have seen it actually matter.

The payment gateway problem

We supported four payment methods: card (Stripe), bank transfer (local PSP), BLIK (Polish mobile payments), 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 when there are two options. By the time we added the 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.

The factory extracted

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 is: implement the interface, register it. Touch nothing else.

The two failure modes I see in factory implementations

1. Factories that construct, not resolve.

A factory that calls 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, at minimum the factory should accept gateway instances via constructor, not build them internally.

2. Factories that return the wrong abstraction.

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

When to use a factory vs. a strategy

The confusion between Factory Method and Strategy is common enough to address directly. They look similar but solve different problems:

  • Factory Method: the type of object varies. You create different classes based on runtime input. The caller does not hold a reference to the factory — it just calls make() and gets an abstraction.
  • Strategy: the algorithm varies. You inject a different implementation of the same interface into a class that uses it. The 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 (sort this list using whichever comparator is configured), that is a strategy.

Testing the factory

The factory itself has a trivial test surface: does it return the correct type for a known input, and does it throw for an unknown one? 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);
    }

    public function testParseWebhookThrowsOnInvalidSignature(): void
    {
        $this->expectException(InvalidWebhookSignatureException::class);
        $this->makeGateway()->parseWebhook(['event' => 'charge.success'], 'bad-sig');
    }
}

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

When you add the fifth gateway, you extend PaymentGatewayContractTest and the contract is verified automatically. The tests encode your interface's promises, not the implementation's details.

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 database column — correct use.

If the answer 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 the answer is a growing if-elseif chain that the team is already nervous about — the factory is late but still the right fix.

end of node