Building a Flexible Plugin Architecture in Laravel Commands

๐Ÿ“– 5 minutes read

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

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 *