Blog

  • PHP Concatenation Pitfall: Initialize Before Using .=

    PHP’s concatenation assignment operator (.=) will throw an ‘Undefined variable’ error if you use it on an uninitialized variable. Always initialize string variables before using .= in loops. This is especially common when conditionally building strings within nested loops where the variable might not exist when the condition first matches.

    // ❌ WRONG: Undefined variable error
    foreach ($records as $record) {
        if ($record->type === 'important') {
            foreach ($record->items as $item) {
                $summary .= $item->text;  // Error!
            }
        }
    }
    
    // ✅ CORRECT: Initialize first
    $summary = '';  // Initialize empty string
    
    foreach ($records as $record) {
        if ($record->type === 'important') {
            foreach ($record->items as $item) {
                $summary .= $item->text;  // Works!
            }
        }
    }
  • Track Code Deployment with Git Tags

    When managing releases via git tags, use git tag --contains <commit-sha> to check if specific code has been deployed yet. Combine with GitHub CLI to trace PR status: gh pr view <pr-number> --json mergeCommit gets the merge SHA, then check git tag --contains <sha> to see which releases include that change. Compare timestamps with git log <tag> -1 --format='%ci' to understand deployment timing. Useful for tracking when fixes reach production.

    # Check if PR #123 is in any release
    $ gh pr view 123 --json mergeCommit
    {
      "mergeCommit": {
        "oid": "abc123..."
      }
    }
    
    # Check which tags contain this commit
    $ git tag --contains abc123
    v2024.03.10.1
    v2024.03.11.1
    
    # Compare timestamps
    $ git log v2024.03.10.1 -1 --format="%H %ci"
    abc123 2024-03-10 15:04:11 +0800
  • 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.

  • Automate Git Branch Management with Shell Scripts

    Managing multiple feature branches in a Laravel project can be tedious—especially when you need to rebase them all onto master before a release. Instead of manually running the same Git commands for each branch, automate it with a shell script.

    The Manual Process

    # For each branch, manually run:
    git checkout master
    git checkout origin/feature/user-dashboard
    git rebase master
    git push origin HEAD:feature/user-dashboard --force
    
    git checkout master
    git checkout origin/feature/api-endpoints
    git rebase master
    git push origin HEAD:feature/api-endpoints --force
    
    # ...repeat for every branch

    This is error-prone and wastes time. You might forget a branch, push to the wrong remote, or leave the repository in a bad state if you hit conflicts.

    The Automated Approach

    Create a rebase-branches.sh script that handles the entire workflow with proper error handling:

    #!/bin/bash
    # rebase-branches.sh
    
    set -e
    
    # Parse arguments
    TARGET_BRANCH="master"
    while [[ $# -gt 0 ]]; do
        case $1 in
            -t|--target)
                TARGET_BRANCH="$2"
                shift 2
                ;;
            *)
                BRANCHES+=("$1")
                shift
                ;;
        esac
    done
    
    # Validate repository
    REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "")
    if [[ "$REMOTE_URL" != "[email protected]:yourcompany/yourapp.git" ]]; then
        echo "Error: Must run from correct repository"
        exit 1
    fi
    
    echo "Fetching from origin..."
    git fetch origin
    
    SKIPPED=()
    SUCCESSFUL=()
    
    for branch in "${BRANCHES[@]}"; do
        echo "Processing: $branch"
        
        # Checkout target branch
        if ! git checkout "$TARGET_BRANCH" 2>/dev/null; then
            SKIPPED+=("$branch (checkout failed)")
            continue
        fi
        
        # Checkout feature branch from origin
        if ! git checkout "origin/$branch" 2>/dev/null; then
            SKIPPED+=("$branch (branch not found)")
            continue
        fi
        
        # Attempt rebase
        if git rebase "$TARGET_BRANCH" 2>/dev/null; then
            # Force push if successful
            if git push origin "HEAD:$branch" --force 2>/dev/null; then
                SUCCESSFUL+=("$branch")
            else
                SKIPPED+=("$branch (push failed)")
                git rebase --abort 2>/dev/null || true
            fi
        else
            # Abort on conflicts
            git rebase --abort 2>/dev/null || true
            SKIPPED+=("$branch (conflicts)")
        fi
    done
    
    # Return to target branch
    git checkout "$TARGET_BRANCH" 2>/dev/null || true
    
    # Report results
    echo "Successfully rebased: ${#SUCCESSFUL[@]}"
    for branch in "${SUCCESSFUL[@]}"; do
        echo "  ✓ $branch"
    done
    
    if [ ${#SKIPPED[@]} -gt 0 ]; then
        echo "Skipped (conflicts/errors): ${#SKIPPED[@]}"
        for branch in "${SKIPPED[@]}"; do
            echo "  ✗ $branch"
        done
    fi

    Usage

    # Make it executable
    chmod +x rebase-branches.sh
    
    # Rebase onto master (default)
    ./rebase-branches.sh feature/user-dashboard feature/api-endpoints feature/admin-tools
    
    # Rebase onto a different branch
    ./rebase-branches.sh -t develop feature/user-dashboard feature/api-endpoints

    What It Does

    • Validates repository: Prevents accidentally running it in the wrong project
    • Fetches from origin: Ensures you’re working with latest remote changes
    • Handles conflicts gracefully: Aborts rebase and continues to next branch instead of leaving you in a broken state
    • Reports results: Shows you exactly which branches succeeded and which had issues
    • Returns to safe state: Always checks out the target branch when done

    Why This Matters

    Automating repetitive Git workflows saves time and reduces errors. This script is idempotent—if something fails, it cleans up and moves on without leaving your repository in a broken state. Perfect for teams managing multiple long-running feature branches that need frequent rebasing onto master or develop.

    The script pattern can be adapted for other batch Git operations: merging multiple branches, deleting stale branches, or updating multiple repositories at once.

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