Author: Daryle De Silva

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

  • Debugging Cannot assign null to property in PHP 8.4 Response Objects

    Debugging “Cannot assign null to property” in PHP 8.4 Response Objects

    Recently ran into a TypeError that PHP 8.4’s strict typing loves to throw: “Cannot assign null to property OrderResponse::$shippingCost of type float”.

    The Setup

    You’re consuming a third-party API. Their response sometimes returns null for optional fields. Your response DTO uses strict typed properties:

    class OrderResponse
    {
        public array $items;
        public float $shippingCost;  // ❌ Problem: not nullable
    }

    When the API returns {"items": [...], "shippingCost": null}, your JSON deserializer (JMS Serializer, Symfony Serializer, etc.) tries to assign null to a float property. PHP 8.4 says no.

    The Fix

    Make it nullable:

    class OrderResponse
    {
        public array $items;
        public ?float $shippingCost;  // ✅ Allows null
    }

    Don’t Forget Usage Checks

    After making a property nullable, audit everywhere you access it:

    // Old code (assumes always has value)
    $total = $response->basePrice + $response->shippingCost;
    
    // Updated (handles null)
    $total = $response->basePrice + ($response->shippingCost ?? 0);

    When NOT to Use Nullable

    If null isn’t semantically valid (like an exchange rate that defaults to 1.0), set a default value in your deserializer config instead of making the property nullable. Nullable should mean “this value is optional and absence is meaningful,” not “the API is inconsistent.”

    Key takeaway: PHP 8.4 strict typing is your friend, but APIs don’t always play nice. Nullable types are the escape hatch – use them intentionally, and always validate assumptions in your code.

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