Category: Laravel

  • Subtle Laravel Query Bug: Plucking from the Wrong Table After a Join

    Subtle Laravel Query Bug: Plucking from the Wrong Table After a Join

    Found a sneaky Laravel query bug that silently returns wrong data: plucking from one table when you meant to pluck from another after a join.

    The Bug

    return $this->buildQuery()
        ->select('task_metadata.label')
        ->join('task_metadata', 'tasks.id', '=', 'task_metadata.task_id')
        ->where('task_metadata.is_active', '=', 1)
        ->pluck('task_notes.label')  // ❌ WRONG TABLE!
        ->unique();

    Notice the problem? We’re joining and selecting from task_metadata, but plucking from task_notes. Laravel doesn’t throw an error – it silently returns empty or garbage data because task_notes isn’t even in the query.

    The Fix

    return $this->buildQuery()
        ->distinct()  // Add distinct for one-to-many joins
        ->select('task_metadata.label')
        ->join('task_metadata', 'tasks.id', '=', 'task_metadata.task_id')
        ->where('task_metadata.is_active', '=', 1)
        ->groupBy('task_metadata.label')  // Group to avoid dupes
        ->pluck('task_metadata.label')  // ✅ CORRECT TABLE
        ->unique();

    Why It’s Sneaky

    • No error: Laravel happily runs the query even though the pluck table isn’t in the join
    • Wrong results: You get empty collections or data from a different table with the same column name
    • Hard to spot: The query looks “mostly right” – you have to trace which table you’re actually selecting from

    Bonus: distinct() vs unique()

    When joining one-to-many relationships:

    • ->distinct() = SQL-level deduplication (runs in database)
    • ->unique() = Collection-level deduplication (runs in PHP after fetching)

    Use distinct() or groupBy() to avoid fetching duplicate rows from the database. Then unique() becomes redundant.

    Lesson: When building complex queries with joins, always double-check that your pluck(), select(), and where() clauses reference the correct table. Laravel won’t warn you if you mix them up.

  • Resilient External API Calls: Retry Logic + Circuit Breakers

    When integrating with unreliable external APIs, implement retry logic with exponential backoff and circuit breaker patterns. Laravel’s HTTP client supports retries out of the box. For long-running failures, implement a circuit breaker to stop hitting a dead endpoint and avoid queue buildup. Log full HTTP context (status code, response body, headers) to debug external API issues effectively.

    use Illuminate\Support\Facades\Http;
    use Illuminate\Support\Facades\Log;
    
    // Retry with exponential backoff (100ms, 200ms, 400ms)
    $response = Http::retry(3, 100, throw: false)
        ->timeout(10)
        ->get('https://api.partner.com/data');
    
    if ($response->failed()) {
        Log::error('Partner API failed', [
            'url' => $response->effectiveUri(),
            'status' => $response->status(),
            'body' => $response->body(),
            'headers' => $response->headers(),
        ]);
        
        throw new ExternalServiceException(
            'Partner API error: Failed to fetch data endpoint',
            ['response_status' => $response->status()]
        );
    }
    
    // For circuit breaker pattern, use package:
    // composer require reshadman/laravel-circuit-breaker
  • Centralize Third-Party API Error Handling with Custom Exceptions

    When integrating multiple third-party APIs that serve similar purposes (payment gateways, shipping providers, SMS services), you’ll quickly notice they all handle errors differently. One throws CardException with a specific message, another returns error codes like INSUFFICIENT_FUNDS—same problem, completely different implementations.

    Instead of scattering vendor-specific error handling throughout your Laravel codebase, wrap their exceptions in custom domain exceptions.

    The Problem: Inconsistent Error Handling

    // Payment provider 1 (Stripe)
    try {
        $payment = $stripeClient->charge($amount);
    } catch (\Stripe\Exception\CardException $e) {
        if (str_contains($e->getMessage(), 'insufficient funds')) {
            // Handle insufficient funds
        }
    }
    
    // Payment provider 2 (PayPal)
    try {
        $payment = $paypalClient->charge($amount);
    } catch (\PayPal\Exception\PayPalException $e) {
        if ($e->getCode() === 'INSUFFICIENT_FUNDS') {
            // Handle insufficient funds... differently
        }
    }
    
    // Every integration handles the same error differently

    This approach doesn’t scale. Every time you add a new provider, you need to update error handling logic throughout your application. Testing becomes harder because you’re coupled to vendor exception types.

    The Solution: Domain Exceptions with Adapters

    Create custom exceptions that represent business scenarios (insufficient funds, declined payment, timeout) rather than vendor-specific errors:

    // Define domain exceptions
    namespace App\Exceptions\Payment;
    
    class InsufficientFundsException extends PaymentException
    {
        public function __construct(
            public readonly string $provider,
            public readonly string $transactionId,
            string $message = 'Insufficient funds to complete transaction'
        ) {
            parent::__construct($message);
        }
    }
    
    class PaymentDeclinedException extends PaymentException { /* ... */ }
    class PaymentTimeoutException extends PaymentException { /* ... */ }

    Now wrap vendor exceptions in adapter classes that implement a common interface:

    class StripeAdapter implements PaymentGateway
    {
        public function charge(Order $order): Transaction
        {
            try {
                return $this->client->createCharge([
                    'amount' => $order->total,
                    'currency' => $order->currency,
                ]);
            } catch (\Stripe\Exception\CardException $e) {
                // Map Stripe errors to domain exceptions
                if (str_contains($e->getMessage(), 'insufficient funds')) {
                    throw new InsufficientFundsException(
                        provider: 'stripe',
                        transactionId: $e->getRequestId()
                    );
                }
                
                throw PaymentException::fromVendorException('stripe', $e);
            }
        }
    }
    
    class PayPalAdapter implements PaymentGateway
    {
        public function charge(Order $order): Transaction
        {
            try {
                return $this->client->createPayment([/* ... */]);
            } catch (\PayPal\Exception\PayPalException $e) {
                // Map PayPal errors to same domain exceptions
                if ($e->getCode() === 'INSUFFICIENT_FUNDS') {
                    throw new InsufficientFundsException(
                        provider: 'paypal',
                        transactionId: $e->getCorrelationId()
                    );
                }
                
                throw PaymentException::fromVendorException('paypal', $e);
            }
        }
    }

    Now Handle All Providers Consistently

    class CheckoutService
    {
        public function processPayment(Order $order, PaymentGateway $gateway)
        {
            try {
                $transaction = $gateway->charge($order);
                $order->markAsPaid($transaction);
            } catch (InsufficientFundsException $e) {
                // Same handler for ALL providers
                $this->notifyUser($order->user, 'payment_failed_insufficient_funds');
                $this->logPaymentFailure($e->provider, $e->transactionId);
            } catch (PaymentDeclinedException $e) {
                // ...
            }
        }
    }

    Why This Matters

    • Consistent interface: Your application logic doesn’t care which payment provider is being used
    • Easy to add providers: New integrations just need to implement the PaymentGateway interface and map their errors
    • Easy to switch providers: Change providers without touching your application logic
    • Better testing: Mock domain exceptions instead of vendor-specific ones
    • Provider-specific data: Custom exceptions can carry provider IDs, transaction IDs, and other metadata while presenting a unified interface

    This pattern works for any multi-provider integration: shipping APIs, SMS services, email providers—anything where you need to abstract vendor differences behind a common interface.

  • Build Flexible Plugin Systems with Interfaces and Factories





    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.


  • Idempotent Database Operations with updateOrCreate





    Idempotent Database Operations with updateOrCreate

    Idempotent Database Operations with updateOrCreate

    When syncing data from external APIs or running scheduled jobs, you need operations that are safe to run multiple times without side effects. This is called idempotency.

    Laravel’s updateOrCreate() method makes database operations naturally idempotent by either updating existing records or creating new ones based on match conditions.

    The Problem: Duplicate Records

    // ❌ Bad: Creates duplicates every time
    public function syncFromApi()
    {
        $items = Http::get('/api/products')->json();
        
        foreach ($items as $item) {
            Product::create([
                'external_id' => $item['id'],
                'name' => $item['name'],
                'price' => $item['price'],
            ]);
        }
    }

    Run this twice, and you’ll have two copies of every product. Schedule it to run hourly, and you’re accumulating garbage data.

    The Solution: updateOrCreate()

    // ✅ Good: Safe to run multiple times
    public function syncFromApi()
    {
        $items = Http::get('/api/products')->json();
        
        foreach ($items as $item) {
            Product::updateOrCreate(
                [
                    'external_id' => $item['id'], // Match on this
                ],
                [
                    'name' => $item['name'],       // Update these fields
                    'price' => $item['price'],
                ]
            );
        }
    }

    Now you can run this sync as many times as needed. Existing products update, new products create. No duplicates, no manual cleanup.

    How It Works

    1. Search – Look for a record matching the first array (where clause)
    2. Update – If found, update with the second array
    3. Create – If not found, merge both arrays and insert

    Think of the first array as your “unique identifier” and the second as your “payload to save.”

    Common Patterns

    Composite Keys

    // Match on multiple fields
    ProductVariant::updateOrCreate(
        [
            'product_id' => $productId,
            'variant_code' => $variantCode,
        ],
        [
            'name' => $name,
            'stock' => $stock,
            'price' => $price,
        ]
    );

    Webhook Processing

    // Idempotent webhook handler
    public function handle(array $webhook)
    {
        Order::updateOrCreate(
            ['external_order_id' => $webhook['order_id']],
            [
                'status' => $webhook['status'],
                'total' => $webhook['total'],
                'customer_email' => $webhook['email'],
            ]
        );
    }

    If the webhook fires twice (it happens!), you get the same result. No duplicate orders.

    Scheduled Imports

    // Hourly cron job - always safe
    $items = Cache::remember('inventory_feed', 3600, fn() => 
        Http::get($inventoryUrl)->json()
    );
    
    foreach ($items as $item) {
        InventoryItem::updateOrCreate(
            ['sku' => $item['sku']],
            [
                'quantity' => $item['qty'],
                'warehouse' => $item['location'],
                'synced_at' => now(),
            ]
        );
    }

    Performance Considerations

    updateOrCreate() runs one query to check + one query to update/insert. For bulk operations with thousands of records, consider using upsert() instead:

    // Bulk upsert (Laravel 8+)
    Product::upsert(
        [
            ['external_id' => 1, 'name' => 'Widget', 'price' => 10.00],
            ['external_id' => 2, 'name' => 'Gadget', 'price' => 20.00],
            // ... thousands more
        ],
        ['external_id'], // Unique by
        ['name', 'price'] // Update these
    );

    This runs a single bulk INSERT … ON DUPLICATE KEY UPDATE query, dramatically faster for large datasets.

    When to Use updateOrCreate()

    • API syncing – External data refreshes
    • Webhook handlers – Idempotent event processing
    • Scheduled jobs – Cron tasks that might retry
    • Import scripts – CSV/Excel uploads
    • Cache warming – Rebuild cached aggregates

    Key Takeaway

    Idempotency isn’t just a nice-to-have—it’s essential for reliable systems. Any operation that syncs external data or runs on a schedule should be idempotent by default. updateOrCreate() makes this trivial in Laravel.

    The alternative is manually checking if records exist before deciding whether to create or update. That’s error-prone, verbose, and creates race conditions. Let Eloquent handle it.


  • Use LazyCollection for Memory-Efficient API Pagination





    Use LazyCollection for Memory-Efficient API Pagination

    Use LazyCollection for Memory-Efficient API Pagination

    When working with external APIs that return large datasets, memory management becomes critical. Loading thousands of records into memory at once can quickly exhaust your PHP memory limit.

    Laravel’s LazyCollection solves this elegantly using PHP generators to yield items one at a time instead of loading everything upfront.

    The Problem: Memory Bloat

    // ❌ Bad: Loads all products into memory
    public function syncProducts(): Collection
    {
        $products = $this->apiClient->fetchAllProducts();
        return collect($products); // Could be 10,000+ items
    }

    This approach loads the entire API response into memory. With 10,000 products, you’re looking at hundreds of megabytes of RAM consumption before you’ve even started processing.

    The Solution: LazyCollection with Generators

    // ✅ Good: Yields items one at a time
    public function syncProducts(): LazyCollection
    {
        return LazyCollection::make(function () {
            yield from $this->apiClient->fetchAllProducts();
        });
    }
    
    // Process efficiently
    $this->syncProducts()->each(function ($product) {
        Product::updateOrCreate(
            ['external_id' => $product['id']],
            ['name' => $product['name'], 'price' => $product['price']]
        );
    });

    With LazyCollection, memory usage stays constant regardless of dataset size. Each item is processed and discarded before the next one is loaded.

    Real-World Impact

    Approach 10K Records 100K Records
    Collection ~512MB ~5GB (crashes)
    LazyCollection ~32MB ~32MB

    When to Use LazyCollection

    • API pagination – Syncing data from external services
    • Database exports – Generating large CSV files
    • Batch processing – Working with millions of records
    • File parsing – Reading large log files or CSV imports

    Advanced: Chunked API Calls

    If your API supports pagination, combine LazyCollection with chunking:

    public function fetchAllProducts(): LazyCollection
    {
        return LazyCollection::make(function () {
            $page = 1;
            do {
                $response = Http::get('/api/products', ['page' => $page]);
                $products = $response->json('data');
                
                foreach ($products as $product) {
                    yield $product;
                }
                
                $page++;
            } while (!empty($products));
        });
    }

    This pattern requests one page at a time, yields each item, then fetches the next page only when needed.

    Key Takeaway

    When dealing with large external datasets, LazyCollection isn’t just an optimization—it’s often the difference between a working sync script and a memory exhausted crash. Use it whenever you’re processing data where the full dataset size is unknown or potentially large.


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