php
article··updated ·5 min read

Factory Method in PHP: one place for a runtime choice of implementation

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

A factory answers one question: which implementation to create for this input. It's useful when the answer depends on runtime data, like a request field or a database column, and when the same answer is needed in more than one place. Without both, the factory only adds a layer.

Terminology

In the GoF book, Factory Method is a specific structure: a base class calls an abstract creation method, and subclasses override it to decide the concrete product.

abstract class ReportExporter
{
    abstract protected function createWriter(): ReportWriter;

    public function export(Report $report): string
    {
        $writer = $this->createWriter();
        foreach ($report->rows() as $row) {
            $writer->addRow($row);
        }
        return $writer->finish();
    }
}

final class CsvReportExporter extends ReportExporter
{
    protected function createWriter(): ReportWriter
    {
        return new CsvWriter();
    }
}

In PHP applications you'll more often see a parameterized factory: one object with a make() method that takes a key and returns an implementation of an interface. The rest of this note is about that form, since that's the one that replaces duplicated conditionals.

The problem: the same branch in several places

Say an application accepts card payments, BLIK, bank transfers and installment financing. Every provider has its own API, its own error model and its own webhook format. Without a single decision point, the controller ends up with something like this:

$gateway = match ($request->input('payment_method')) {
    'card' => new StripeGateway(config('services.stripe.secret')),
    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),
    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),
    'financing' => new FinancingGateway(config('services.financing.api_key')),
    default => throw new InvalidArgumentException('Unknown payment method'),
};

Then the same choice shows up again in the refund handler, the webhook endpoint and the reconciliation job, each time based on the method stored with the payment, each time with the constructor arguments copied along. Adding a provider, or a new dependency to an existing one, means hunting down every copy.

Interface, enum, factory

First comes an interface in the application's own terms. Money, ChargeResult, RefundResult and WebhookEvent are the application's value objects.

enum PaymentMethod: string
{
    case Card = 'card';
    case Blik = 'blik';
    case Transfer = 'transfer';
    case Financing = 'financing';
}

interface PaymentGateway
{
    public function charge(Money $amount, array $metadata): ChargeResult;

    public function refund(string $chargeId, Money $amount): RefundResult;

    public function parseWebhook(string $payload, array $headers): WebhookEvent;
}

The factory maps a method to a class and lets the container build it:

use Illuminate\Contracts\Container\Container;

final class PaymentGatewayFactory
{
    /** @param array<string, class-string<PaymentGateway>> $gateways */
    public function __construct(
        private readonly Container $container,
        private readonly array $gateways,
    ) {}

    public function make(PaymentMethod $method): PaymentGateway
    {
        $class = $this->gateways[$method->value]
            ?? throw new UnsupportedPaymentMethod($method->value);

        return $this->container->make($class);
    }
}

It's registered once, in a service provider. What each gateway needs in its own constructor (credentials, HTTP client, logger) is bound separately and the container resolves it.

public function register(): void
{
    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [
        PaymentMethod::Card->value => StripeGateway::class,
        PaymentMethod::Blik->value => BlikGateway::class,
        PaymentMethod::Transfer->value => BankTransferGateway::class,
        PaymentMethod::Financing->value => FinancingGateway::class,
    ]));
}

Call sites now just pass in their own input:

// controller: a FormRequest validates the field with ['required', Rule::enum(PaymentMethod::class)]
$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));
$result = $gateway->charge($amount, ['order_id' => $order->id]);

// refund job: the input is a column cast to PaymentMethod
$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);

A new provider needs a class implementing PaymentGateway, an enum case, one line in the map, and bindings for whatever dependencies the container can't build on its own.

Laravel uses the same idea internally in Illuminate\Support\Manager (driver(), extend()), which backs drivers such as session, hashing and notification channels. Extending Manager is an option when implementations are picked by name in config files. For a domain choice like this one I find a dedicated factory easier to read.

Where factories go wrong

The most common mistake is a factory that builds objects itself. If it calls new StripeGateway(config(...)), it has to change whenever a gateway's constructor changes, and the container never sees the gateway's dependencies. Let the container do the construction. Without a container, pass ready instances, or closures that build them, into the factory's constructor.

The other one is quieter. PaymentGateway gets methods like createPaymentIntent() because the first integration happened to be Stripe, and from then on every other implementation has to translate its model into Stripe's. That translation is where the bugs show up. Name the operations after what the application does (charge, refund, interpret a webhook) and keep the translation to each vendor's model inside its class.

Factory or Strategy

These two often come together. Strategy is an object whose behavior can be swapped behind an interface, and each PaymentGateway is a strategy for processing a payment. The factory is where the decision is made which strategy to use for a given input. If the strategy is already known when the application boots, from configuration, you don't need a factory at all. Bind the interface to the implementation in the container and inject it.

Tests

For the factory itself one test is enough: every enum case resolves to a PaymentGateway. It fails when someone adds a case and forgets the map entry, and also when the container can't build one of the gateways.

public function test_every_payment_method_has_a_gateway(): void
{
    $factory = $this->app->make(PaymentGatewayFactory::class);

    foreach (PaymentMethod::cases() as $method) {
        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));
    }
}

A shared contract test that each gateway's test class extends gives more. Each subclass provides its gateway with a faked transport, for instance Http::fake() with a sample provider response if the gateway uses Laravel's HTTP client.

abstract class PaymentGatewayContractTest extends TestCase
{
    abstract protected function gateway(): PaymentGateway;

    public function test_charge_returns_a_charge_id(): void
    {
        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);

        $this->assertNotSame('', $result->chargeId);
    }
}

When a binding or a match is enough

With one implementation, or a choice that's fixed per deployment, a container binding does the job. If the choice is made in exactly one place, a match on the enum right there is clearer than an extra class.

Sometimes the implementations don't share an interface that callers can use without instanceof checks. A factory won't help with that, and the interface needs fixing first.

end of node