# Building a Flexible Plugin Architecture in Laravel Commands
When building Laravel applications that need to integrate with multiple external services, a well-designed plugin architecture can save you months of refactoring. Here’s a pattern I use for artisan commands that need to work with different service providers.
## The Problem
You’re building an import system that needs to sync data from multiple third-party APIs. Each provider has different endpoints, authentication, and data formats. You want to:
– Add new providers without modifying core code
– Share common import logic across all providers
– Run imports for specific providers or all at once
– Test providers independently
## The Solution: Interface-Based Plugin Architecture
### Step 1: Define the Contract
“`php
namespace App\Services\Integrations\Contracts;
use App\Models\DataRecord;
use Illuminate\Support\Collection;
interface ImportsData
{
public function getMaxSyncRange(): int;
/**
* @return Collection
*/
public function importData(DataRecord $record): Collection;
}
“`
### Step 2: Create an Abstract Importer
“`php
namespace App\Services\Integrations;
use App\Models\DataRecord;
use App\Services\Integrations\Contracts\ImportsData;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
abstract class AbstractImporter
{
protected bool $verbose = false;
public function __construct(
protected PluginFactory $plugins,
protected Logger $logger,
protected Dispatcher $dispatcher
) {}
abstract protected function getPlugins(): Collection;
abstract protected function getName(): string;
abstract protected function getJobClassName(): string;
abstract protected function validateRecord(DataRecord $record): bool;
public function synchronize(?string $pluginClassname = null, ?int $recordId = null, bool $sync = false): void
{
$this->logger->info(“Run {$this->getName()} import”);
if ($pluginClassname && $recordId) {
$this->synchronizeSingleRecord($pluginClassname, $recordId, $sync);
return;
}
$plugins = $this->getPluginsCollection($pluginClassname);
$plugins->each(function (string $pluginName, string $pluginClassname) use ($sync) {
$this->logger->info(“Processing {$pluginClassname}”);
$this->getRecords($pluginClassname)->each(function ($record) use ($sync) {
if ($this->validateRecord($record)) {
$this->logger->info(“Processing record: {$record->id}”);
$this->handle($record, $sync);
}
});
});
}
protected function handle(DataRecord $record, bool $sync): void
{
$job = $this->getJobClassName();
if ($sync) {
$job::dispatchSync($record, true);
return;
}
$job::dispatch($record);
}
protected function getRecords(string $pluginClassname): LazyCollection
{
return DataRecord::with([‘related_data’])
->where(‘integration_plugin’, $pluginClassname)
->where(‘active’, true)
->cursor(); // Use cursor() for memory efficiency
}
}
“`
### Step 3: Concrete Importer
“`php
namespace App\Services\Integrations;
use App\Models\DataRecord;
use App\Services\Integrations\Contracts\ImportsData;
use App\Services\Integrations\Jobs\SynchronizeDataJob;
use Illuminate\Support\Collection;
class DataImporter extends AbstractImporter
{
protected function getName(): string
{
return __CLASS__;
}
protected function getJobClassName(): string
{
return SynchronizeDataJob::class;
}
protected function getPlugins(): Collection
{
return collect($this->plugins->getInterfaceImplementingPlugins(ImportsData::class));
}
protected function validateRecord(DataRecord $record): bool
{
return $record->settings
->where(‘sync_enabled’, true)
->where(‘status’, ‘active’)
->isNotEmpty();
}
}
“`
### Step 4: Abstract Command Base
“`php
namespace App\Console\Commands\Integrations;
use App\Services\Integrations\AbstractImporter;
use Illuminate\Console\Command;
class AbstractImportCommand extends Command
{
public function __construct(protected AbstractImporter $importer)
{
parent::__construct();
}
public function handle(): int
{
if ($this->option(‘verbose’)) {
$this->importer->setVerbose();
}
$this->importer->synchronize(
$this->getPluginClassName(),
$this->option(‘recordId’),
$this->option(‘sync’)
);
return 0;
}
public function getPluginClassName(): ?string
{
$pluginName = $this->option(‘plugin’);
if (empty($pluginName)) {
return null;
}
// Auto-add namespace prefix if missing
if (!str_starts_with($pluginName, ‘App\\Services\\Integrations\\Plugins\\’)) {
$pluginName = ‘App\\Services\\Integrations\\Plugins\\’ . $pluginName;
}
// Auto-add “Plugin” suffix if missing
if (!str_ends_with($pluginName, ‘Plugin’)) {
$pluginName = $pluginName . ‘Plugin’;
}
return $pluginName;
}
}
“`
### Step 5: Concrete Command
“`php
namespace App\Console\Commands\Integrations;
use App\Services\Integrations\DataImporter;
class ImportDataCommand extends AbstractImportCommand
{
protected $signature = ‘integrations:import:data
{–plugin=}
{–recordId=}
{–s|sync}’;
protected $description = ‘Imports data from external APIs’;
public function __construct(DataImporter $importer)
{
parent::__construct($importer);
}
}
“`
### Step 6: Sample Plugin Implementation
“`php
namespace App\Services\Integrations\Plugins;
use App\Models\DataRecord;
use App\Services\Integrations\Contracts\ImportsData;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class StripePlugin implements ImportsData
{
public function getMaxSyncRange(): int
{
return 90; // days
}
public function importData(DataRecord $record): Collection
{
$response = Http::withToken(config(‘services.stripe.key’))
->get(‘https://api.stripe.com/v1/charges’, [
‘limit’ => 100,
‘customer’ => $record->external_id,
]);
return collect($response->json(‘data’))
->pluck(‘created’)
->map(fn($timestamp) => date(‘Y-m-d’, $timestamp));
}
}
“`
## Usage Examples
“`bash
# Import all plugins
php artisan integrations:import:data
# Import specific plugin (short name)
php artisan integrations:import:data –plugin=Stripe
# Import specific plugin (full class name)
php artisan integrations:import:data –plugin=”App\\Services\\Integrations\\Plugins\\StripePlugin”
# Import single record synchronously (useful for debugging)
php artisan integrations:import:data –plugin=Stripe –recordId=123 –sync
# Verbose output
php artisan integrations:import:data –plugin=Stripe -v
“`
## Key Benefits
1. **Flexible Plugin Resolution**: Accepts short names (`Stripe`), partial paths, or full class names
2. **Memory Efficient**: Uses `cursor()` instead of `get()` for large datasets
3. **Sync/Async Toggle**: Debug synchronously, run async in production
4. **Interface-Driven**: Plugins implement contracts, core code stays unchanged
5. **Clean Separation**: Command → Importer → Job → Plugin (each layer has one job)
## Testing Tip
Mock the plugin factory in tests to inject fake plugins:
“`php
public function test_import_processes_valid_records()
{
$mockPlugin = Mockery::mock(ImportsData::class);
$mockPlugin->shouldReceive(‘importData’)
->andReturn(collect([‘2024-01-01’, ‘2024-01-02’]));
$mockFactory = Mockery::mock(PluginFactory::class);
$mockFactory->shouldReceive(‘getInterfaceImplementingPlugins’)
->andReturn(collect([‘Stripe’ => StripePlugin::class]));
$importer = new DataImporter($mockFactory, $logger, $dispatcher);
$importer->synchronize();
// Assertions…
}
“`
This pattern scales from 2 providers to 200 without architectural changes. Each new integration is just a new plugin class implementing the interface.