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
activeflag 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.