Build Flexible Plugin Systems with Interfaces and Factories

📖 5 minutes read





Build Flexible Plugin Systems with Interfaces and Factories

Build Flexible Plugin Systems with Interfaces and Factories

When building systems that integrate with multiple external services (payment gateways, shipping providers, notification channels), hardcoding each provider creates maintenance hell. Every new integration requires touching core code.

The solution: an interface-based plugin architecture with a Factory pattern that discovers and instantiates implementations dynamically.

The Problem: Hardcoded Provider Lists

// ❌ Bad: New providers require code changes
class PaymentService
{
    public function getProviders(): array
    {
        return [
            new StripeProvider(),
            new PayPalProvider(),
            new SquareProvider(),
        ];
    }
    
    public function charge(Order $order, string $provider)
    {
        switch ($provider) {
            case 'stripe':
                return (new StripeProvider())->charge($order);
            case 'paypal':
                return (new PayPalProvider())->charge($order);
            case 'square':
                return (new SquareProvider())->charge($order);
            default:
                throw new Exception('Unknown provider');
        }
    }
}

Adding a fourth provider? Modify multiple methods. Want to disable Square? Edit the switch statement. Testing? Mock every provider manually. This doesn’t scale.

The Solution: Plugin Architecture

Step 1: Define Your Interface

// app/Contracts/PaymentProvider.php
interface PaymentProvider
{
    public function charge(Order $order): Receipt;
    public function refund(string $transactionId): bool;
    public function getName(): string;
}

The interface is your contract. Any class implementing it must provide these methods.

Step 2: Implement Plugins

// app/Providers/Payment/StripeProvider.php
class StripeProvider implements PaymentProvider
{
    public function charge(Order $order): Receipt
    {
        $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
        $paymentIntent = $stripe->paymentIntents->create([
            'amount' => $order->total * 100,
            'currency' => 'usd',
        ]);
        
        return new Receipt($paymentIntent->id, $order->total);
    }
    
    public function refund(string $transactionId): bool
    {
        $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
        $stripe->refunds->create(['payment_intent' => $transactionId]);
        return true;
    }
    
    public function getName(): string
    {
        return 'Stripe';
    }
}

// Similar for PayPalProvider, SquareProvider, etc.

Step 3: Build a Factory

// app/Factories/PaymentFactory.php
class PaymentFactory
{
    public function __construct(
        private ProviderRepository $repository
    ) {}
    
    public function getProviders(): array
    {
        $providers = [];
        
        // Get active providers from database
        $configs = $this->repository->getActive();
        
        foreach ($configs as $config) {
            // Skip if class doesn't exist
            if (!class_exists($config->class)) {
                continue;
            }
            
            // Use reflection to validate
            $reflection = new \ReflectionClass($config->class);
            
            // Skip abstracts and interfaces
            if ($reflection->isAbstract() || $reflection->isInterface()) {
                continue;
            }
            
            // Verify implements required interface
            if (!in_array(PaymentProvider::class, class_implements($config->class))) {
                continue;
            }
            
            // Instantiate via container (handles dependencies)
            $providers[$config->name] = app($config->class);
        }
        
        return $providers;
    }
    
    public function getProvider(string $name): ?PaymentProvider
    {
        return $this->getProviders()[$name] ?? null;
    }
    
    public function getRefundableProviders(): array
    {
        // Filter to only providers that support refunds
        return array_filter(
            $this->getProviders(),
            fn($provider) => method_exists($provider, 'refund')
        );
    }
}

Step 4: Database-Driven Configuration

// database/migrations/xxxx_create_payment_providers_table.php
Schema::create('payment_providers', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();
    $table->string('class'); // Full class namespace
    $table->boolean('active')->default(true);
    $table->json('config')->nullable(); // Provider-specific settings
    $table->timestamps();
});

Seed with your providers:

// database/seeders/PaymentProviderSeeder.php
DB::table('payment_providers')->insert([
    ['name' => 'stripe', 'class' => StripeProvider::class, 'active' => true],
    ['name' => 'paypal', 'class' => PayPalProvider::class, 'active' => true],
    ['name' => 'square', 'class' => SquareProvider::class, 'active' => false],
]);

Step 5: Use the Factory

// In your controller or service
class CheckoutController
{
    public function charge(Request $request, PaymentFactory $factory)
    {
        $providerName = $request->input('payment_method');
        $provider = $factory->getProvider($providerName);
        
        if (!$provider) {
            return back()->withErrors(['payment_method' => 'Invalid payment provider']);
        }
        
        $order = Order::findOrFail($request->order_id);
        $receipt = $provider->charge($order);
        
        return view('checkout.success', compact('receipt'));
    }
}

// Batch refunds across all providers
class RefundService
{
    public function refundAll(array $transactionIds, PaymentFactory $factory)
    {
        foreach ($factory->getRefundableProviders() as $provider) {
            foreach ($transactionIds as $txnId) {
                try {
                    $provider->refund($txnId);
                } catch (\Exception $e) {
                    // Log and continue
                }
            }
        }
    }
}

Advanced: Optional Interfaces

Not all providers support all features. Use optional interfaces:

// Optional capability interfaces
interface SupportsRecurringPayments
{
    public function createSubscription(Order $order, string $plan): Subscription;
}

interface SupportsSplitPayments
{
    public function splitCharge(Order $order, array $recipients): array;
}

// Stripe implements both
class StripeProvider implements 
    PaymentProvider, 
    SupportsRecurringPayments, 
    SupportsSplitPayments
{
    // ... implementations
}

// PayPal only supports recurring
class PayPalProvider implements 
    PaymentProvider, 
    SupportsRecurringPayments
{
    // ... implementations
}

// Factory method to filter
public function getProvidersWithRecurring(): array
{
    return array_filter(
        $this->getProviders(),
        fn($p) => $p instanceof SupportsRecurringPayments
    );
}

Benefits of This Approach

  • Zero code changes to add providers – just create the class and add a database row
  • Enable/disable instantly – flip the active flag in the database
  • Easy testing – mock the interface, not individual providers
  • Type safety – interface guarantees all providers have required methods
  • Reflection validation – factory rejects invalid classes automatically
  • Dependency injection – Laravel container handles construction

Real-World Use Cases

System Interface Plugins
Payment processing PaymentProvider Stripe, PayPal, Square, Authorize.net
Shipping ShippingProvider FedEx, UPS, USPS, DHL
Notifications NotificationChannel Email, SMS, Slack, Push, Discord
Authentication AuthProvider OAuth (Google, GitHub), SAML, LDAP
File storage StorageDriver S3, DigitalOcean Spaces, Local, FTP

Key Takeaway

When you find yourself writing switch statements or if/else chains to handle different service providers, that’s your cue to implement a plugin architecture. Define the contract (interface), let implementations handle the details, and use a factory to wire everything together.

The upfront effort pays off immediately. Adding the fourth provider is as easy as the third. Disabling one during an outage is a database update, not a deployment. And testing becomes trivial because you mock the interface, not the implementations.

This is how Laravel itself is built—drivers for mail, cache, queue, and filesystem all use this exact pattern. Follow the framework’s lead.


Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *