Blog

  • Building a Flexible Plugin Architecture in Laravel Commands

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

  • Method-Level Dependency Injection for Single-Use Services

    Laravel’s service container supports dependency injection at the method level, not just the constructor. When a service is only used in one controller method, inject it there instead of cluttering the constructor.

    The Problem: Constructor Bloat

    It’s common to see controllers with constructors packed with dependencies that are only used in one or two methods:

    class OrderController extends Controller
    {
        public function __construct(
            private OrderService $orderService,
            private PaymentService $paymentService,
            private ShippingService $shippingService, // Only used in ship()
        ) {}
        
        public function index() {
            return $this->orderService->list();
        }
        
        public function ship(Request $request) {
            $this->shippingService->process(...);
        }
    }

    The ShippingService is instantiated for every request to this controller, even though it’s only used in the ship() method.

    The Solution: Method-Level Injection

    Inject dependencies directly into the methods that use them:

    class OrderController extends Controller
    {
        public function __construct(
            private OrderService $orderService,
            private PaymentService $paymentService,
        ) {}
        
        public function index() {
            return $this->orderService->list();
        }
        
        public function ship(Request $request, ShippingService $shippingService) {
            $shippingService->process(...);
        }
    }

    Laravel automatically resolves method parameters after route parameters. The service container inspects the type hint and provides the appropriate instance.

    Benefits

    • Constructor stays focused on shared dependencies used across multiple methods
    • Clear intent — you can see exactly which methods use which services
    • Easier testing — mock only what that specific method needs
    • No unnecessary instantiation — dependencies are only created when the method is called

    When to Use Which

    Scenario Approach
    Service used in 3+ methods Constructor injection
    Service used in 1 method only Method injection
    Service used in 2 methods Judgment call (lean toward method)

    Route Parameters + Dependency Injection

    When combining route model binding with method injection, Laravel resolves route parameters first, then dependencies:

    // Route: /orders/{order}/ship
    public function ship(Order $order, ShippingService $shippingService) {
        // $order comes from route binding
        // $shippingService comes from container
        $shippingService->processOrder($order);
    }

    Laravel is smart enough to distinguish between route parameters (bound to the route) and type-hinted dependencies (resolved from the container).

    Takeaway

    Don’t default to constructor injection for everything. If a dependency is only used in one method, inject it there. Your constructor will stay clean, your intent will be clearer, and you’ll avoid unnecessary instantiations.

  • Impact Analysis Before Modifying Shared Methods

    When deciding whether to add new fields to a shared transformation method (like toArray() or custom serializers), always check all usages first to understand the blast radius.

    The decision:
    Should you add fields to a shared method, or add them at the call site?

    Step 1: Find all usages

    rg "MyClass::toArray\(\)" app/ --type php -B 2 -A 2
    

    Step 2: Analyze each consumer

    • Does it expect specific keys only?
    • Will extra keys cause issues?
    • Is it part of an API contract?

    Example scenario:

    // Shared method
    public static function toSettings(Product $product): array
    {
        return [
            'name' => $product->name,
            'price' => $product->price,
            // Should 'markup' go here or at call site?
        ];
    }
    
    // Consumer 1: API endpoint (wants markup)
    // Consumer 2: Alert notification (only reads price, ignores extra)
    // Consumer 3: Report transformer (spreads array into larger struct)
    

    Decision criteria:

    • If ALL consumers need it → add to shared method
    • If ONE consumer needs it → add at call site
    • If it breaks an API contract → versioned endpoint or call-site addition

    Pro tip: Use git grep or rg (ripgrep) to search, not IDE search—catches dynamic calls and string references.

  • Conditional Logic with Collection whenNotEmpty

    Laravel collections provide whenNotEmpty() and whenEmpty() methods for conditional logic execution, which is cleaner than manual empty checks.

    Before (verbose):

    if (!$order->items->isEmpty()) {
        $order->items->each(fn($item) => $item->ship());
    }
    

    After (fluent):

    $order->items->whenNotEmpty(function ($items) {
        $items->each(fn($item) => $item->ship());
    });
    

    Bonus—both branches:

    $user->notifications
        ->whenNotEmpty(fn($notifications) => $this->send($notifications))
        ->whenEmpty(fn() => Log::info('No notifications to send'));
    

    This pattern keeps your code fluent and avoids breaking the collection chain. The closure receives the collection as a parameter, so you don’t need to reference the original variable again.

  • Closure Variable Binding with use Keyword

    When passing closures to Laravel collection methods like whenNotEmpty(), each(), or filter(), variables from the outer scope aren’t automatically available inside the closure. You must explicitly bind them using the use keyword.

    Wrong:

    $service = app(ReportGenerator::class);
    $items->whenNotEmpty(function () {
        $service->generate(); // Error: Undefined variable $service
    });
    

    Correct:

    $service = app(ReportGenerator::class);
    $items->whenNotEmpty(function () use ($service) {
        $service->generate(); // Works!
    });
    

    Multiple variables:

    $project->tasks->whenNotEmpty(function () use ($taskService, $project) {
        $taskService->processForProject($project);
    });
    

    This is a fundamental PHP closure behavior that catches many developers coming from JavaScript where closures automatically capture outer scope.

  • SHOW COLUMNS — Trust Nothing, Verify Everything




    SHOW COLUMNS — Trust Nothing, Verify Everything

    Working with an unfamiliar database or inherited codebase? Don’t guess column names — inspect the schema directly with SHOW COLUMNS.

    I recently wasted 20 minutes debugging a query that referenced projects.title when the actual column was projects.name. One SQL command would have caught it:

    SHOW COLUMNS FROM projects;

    This returns the table’s structure:

    +-------------+--------------+------+-----+---------+----------------+
    | Field       | Type         | Null | Key | Default | Extra          |
    +-------------+--------------+------+-----+---------+----------------+
    | id          | int(11)      | NO   | PRI | NULL    | auto_increment |
    | name        | varchar(255) | NO   |     | NULL    |                |
    | status      | varchar(50)  | YES  |     | active  |                |
    | created_at  | timestamp    | YES  |     | NULL    |                |
    +-------------+--------------+------+-----+---------+----------------+

    Use It Programmatically

    In Laravel, you can inspect schemas at runtime:

    $columns = DB::select('SHOW COLUMNS FROM ' . $table);
    
    foreach ($columns as $col) {
        echo "{$col->Field} ({$col->Type})\n";
    }

    This is especially useful for:

    • Schema validation: Verify migrations match production
    • Legacy databases: Document undocumented systems
    • Dynamic queries: Build column lists programmatically
    • Debugging: Confirm column existence before querying

    Alternative: Laravel’s Schema Facade

    Laravel provides a cleaner API for this:

    use Illuminate\Support\Facades\Schema;
    
    // Check if column exists
    if (Schema::hasColumn('projects', 'title')) {
        // Safe to use
    }
    
    // Get all columns
    $columns = Schema::getColumnListing('projects');
    // ['id', 'name', 'status', 'created_at', 'updated_at']

    But SHOW COLUMNS gives you more metadata (type, nullable, defaults) when you need it. Way more reliable than assuming your migrations match production after years of hotfixes.


  • JSON_CONTAINS in MySQL for Schema-Free Array Searches




    JSON_CONTAINS in MySQL for Schema-Free Array Searches

    MySQL’s JSON_CONTAINS function lets you query JSON array columns without rigid schema changes. Need to filter rows based on values inside a JSON array? Use:

    WHERE JSON_CONTAINS(config_json, '"USD"')

    Notice the double quotes inside single quotes — JSON requires double-quoted strings. This searches for "USD" anywhere in the array.

    When to Use JSON Columns

    JSON columns are ideal for flexible metadata that doesn’t need structured querying. For example:

    • Feature flags per user (varies by account type)
    • Configuration options per tenant
    • Tags or labels that change frequently
    -- Find all users with "premium" feature enabled
    SELECT * FROM accounts
    WHERE JSON_CONTAINS(features, '"premium"');

    The Performance Trade-Off

    JSON searches cannot use regular indexes. For frequently-queried paths, consider generated columns with indexes:

    -- Add a generated column for faster searches
    ALTER TABLE accounts 
    ADD feature_list VARCHAR(255) 
    AS (JSON_UNQUOTE(JSON_EXTRACT(features, '$.enabled'))) STORED;
    
    -- Index it
    CREATE INDEX idx_features ON accounts(feature_list);

    This gives you JSON’s flexibility with traditional index performance. Laravel can generate these via migrations:

    Schema::table('accounts', function (Blueprint $table) {
        $table->string('feature_list')
              ->storedAs("JSON_UNQUOTE(JSON_EXTRACT(features, '$.enabled'))")
              ->index();
    });

    Use JSON for truly flexible data, but add generated columns + indexes for hot paths.


  • Debugging Data Mismatches Between UI and Backend Logic




    Debugging Data Mismatches Between UI and Backend Logic

    Ever shown users options in a dropdown that don’t actually work? I recently debugged a classic denormalization issue where the UI displayed 434 currency options based on a cached summary table, but the backend filter logic checked live data in a separate table — and only 79 of those currencies had available inventory.

    The problem occurred because two different data sources were being used:

    • Display logic: Read from a forecasts table that stored pre-computed aggregates
    • Filter logic: Queried the actual orders table for real-time availability

    The fix started with comparing aggregate counts to confirm the mismatch:

    -- Backend filter (real-time availability)
    SELECT COUNT(DISTINCT product_id) 
    FROM orders 
    WHERE status = 'available';
    -- Result: 79
    
    -- Display logic (cached aggregates)
    SELECT COUNT(*) 
    FROM forecasts 
    WHERE JSON_CONTAINS(metadata, '"currency_code"');
    -- Result: 434

    The root cause? The forecast table wasn’t being updated when inventory sold out. Users saw 434 options but could only actually use 79 of them.

    When to Cache vs. Query Live

    Caching computed data is powerful, but it introduces a new problem: cache invalidation. Before building a cache layer, ask:

    • What triggers a cache update? (e.g., order placed, inventory restocked)
    • Can users tolerate stale data? (product listings: yes; checkout: no)
    • Is the performance gain worth the complexity?

    In this case, the better approach was to either:

    1. Build the dropdown from the same real-time query the filter uses
    2. Add proper cache invalidation hooks when inventory changes

    Lesson: When you split display and business logic across different data sources, always verify your cache invalidation strategy matches your actual business rules. The mismatch becomes obvious when you compare the numbers.


  • Use the Elvis Operator for Consistent Null Returns in Laravel

    When building arrays or JSON responses in Laravel, empty strings from operations like implode() or join() can be inconsistent with explicit null values. The Elvis operator (?:) provides a clean way to coalesce empty results to null.

    The Problem

    Mixing explicit nulls with empty strings creates inconsistency:

    $data = [
        'name' => $user->name,  // 'John Doe'
        'email' => $user->email ?: null,  // null if empty
        'tags' => implode(', ', $user->tags),  // '' if no tags (empty string)
    ];
    
    // JSON: {"name":"John Doe","email":null,"tags":""}
    // Inconsistent - some nulls, some empty strings
    

    The Solution

    Use the Elvis operator for consistent null handling:

    $data = [
        'name' => $user->name,
        'email' => $user->email ?: null,
        'tags' => implode(', ', $user->tags) ?: null,  // Consistent null
    ];
    
    // JSON: {"name":"John Doe","email":null,"tags":null}
    // Better - consistent null handling
    

    Practical Examples

    String concatenation:

    'full_address' => trim("{$user->street} {$user->city} {$user->zip}") ?: null,
    

    Array operations:

    'permissions' => implode(', ', $user->permissions) ?: null,
    'roles' => join(' | ', $user->roles->pluck('name')->all()) ?: null,
    

    Filtered collections:

    'active_projects' => $user->projects
        ->where('status', 'active')
        ->pluck('name')
        ->implode(', ') ?: null,
    

    Complex string building:

    'metadata' => collect([
        $user->department,
        $user->title,
        $user->location,
    ])
    ->filter()
    ->implode(' • ') ?: null,
    

    Why This Matters

    1. API Consistency

    Frontend code can check if (value === null) instead of if (!value || value === '').

    2. Database Consistency

    NULL vs empty string handling in database columns is explicit.

    3. Type Safety

    TypeScript/PHP strict typing works better with explicit nulls:

    interface User {
        name: string;
        email: string | null;  // Clear intent
        tags: string | null;   // Not string | null | ''
    }
    

    Combine with Null Coalescing for Defaults

    Chain the Elvis operator with null coalescing for fallback values:

    'display_name' => implode(' ', [
        $user->first_name,
        $user->last_name,
    ]) ?: $user->email ?? 'Unknown User',
    
    // Evaluation order:
    // 1. Try implode (may return '')
    // 2. Elvis converts '' to null
    // 3. Null coalescing provides fallback
    

    Common patterns:

    // Tags with fallback
    'tags_display' => implode(', ', $post->tags) ?: 'No tags',
    
    // Joined data with fallback
    'categories' => join(' / ', $product->categories) ?: 'Uncategorized',
    
    // Filtered list with fallback
    'assigned_to' => $task->assignees
        ->pluck('name')
        ->implode(', ') ?: 'Unassigned',
    

    The pattern makes your API responses predictable and easier to work with on both backend and frontend.

  • Format Long Method Chains for Readability in Laravel

    When working with complex query builders or fluent APIs in Laravel, proper formatting makes a huge difference in code maintainability. Breaking long inline chains into multi-line structures with consistent indentation helps future developers (including you) understand the flow at a glance.

    Before: Hard to Read

    Single-line method chains are difficult to parse:

    $data = Model::fromSub(array_reduce([$query1, $query2, $query3], fn($sub, $q) => $sub ? $sub->union($q->toBase()) : $q->toBase()), 'items')->with('relation1', 'relation2')->get();
    

    After: Much Better

    The same logic with proper line breaks:

    $data = Model::fromSub(
        array_reduce(
            [$query1, $query2, $query3],
            fn($sub, $q) => $sub 
                ? $sub->union($q->toBase()) 
                : $q->toBase()
        ),
        'items'
    )
    ->with('relation1', 'relation2')
    ->get();
    

    Key Formatting Principles

    1. One logical step per line

    // Bad
    $users = User::with('posts')->where('active', true)->orderBy('name')->get();
    
    // Good
    $users = User::with('posts')
        ->where('active', true)
        ->orderBy('name')
        ->get();
    

    2. Indent nested structures consistently

    $results = Report::query()
        ->select([
            'reports.*',
            DB::raw('COUNT(comments.id) as comment_count'),
        ])
        ->leftJoin('comments', function ($join) {
            $join->on('comments.report_id', '=', 'reports.id')
                ->where('comments.approved', true);
        })
        ->groupBy('reports.id')
        ->having('comment_count', '>', 5)
        ->get();
    

    3. Align related parameters vertically

    $data = Task::with([
        'project',
        'assignee.department',
        'comments.author',
        'attachments',
    ])
    ->whereIn('status', [
        'pending',
        'in_progress',
        'review',
    ])
    ->get();
    

    4. Break closures into multiple lines when they contain logic

    // Single-line is fine for simple closures
    $ids = $collection->map(fn($item) => $item->id);
    
    // Multi-line for complex logic
    $formatted = $collection->map(function ($item) {
        return [
            'id' => $item->id,
            'name' => $item->name,
            'status' => $item->getStatusLabel(),
        ];
    });
    

    Real-World Example

    Extract complex nested structures to variables for clarity:

    // Extract the union query for readability
    $unionQuery = array_reduce(
        [
            Report::where('type', 'daily'),
            Report::where('type', 'weekly'),
            Report::where('type', 'monthly'),
        ],
        fn($sub, $query) => $sub 
            ? $sub->union($query->toBase()) 
            : $query->toBase()
    );
    
    // Now the main query is much clearer
    $results = Report::withTrashed()
        ->fromSub($unionQuery, 'reports')
        ->with([
            'author',
            'department',
            'approvals.user',
        ])
        ->orderByDesc('created_at')
        ->paginate(50);
    

    Your IDE’s auto-formatter may not always get this right — sometimes manual formatting wins for clarity. The goal is to make the code’s intent obvious at a glance.