Author: Daryle De Silva

  • Inject Event Dispatcher for Testability in Laravel Services

    When dispatching events in Laravel services, inject the Dispatcher instead of using the event() helper function. This makes your code more testable and explicit about its dependencies.

    The Problem with event()

    Most Laravel code uses the event() helper to dispatch events:

    class OrderProcessor
    {
        public function process($order)
        {
            // ... processing logic ...
            
            event(new OrderCompleted($order));
        }
    }

    This works fine, but it makes testing harder. You can’t easily mock the event system, and it’s not immediately clear that this class has a dependency on Laravel’s event system.

    The Solution: Dependency Injection

    Instead, inject the Illuminate\Events\Dispatcher:

    use Illuminate\Events\Dispatcher;
    
    class OrderProcessor
    {
        public function __construct(
            private Dispatcher $dispatcher
        ) {}
        
        public function process($order)
        {
            // ... processing logic ...
            
            $this->dispatcher->dispatch(
                new OrderCompleted($order)
            );
        }
    }

    Why This Matters

    Testing becomes trivial:

    public function test_processing_dispatches_event()
    {
        $dispatcher = Mockery::mock(Dispatcher::class);
        $dispatcher->shouldReceive('dispatch')
            ->once()
            ->with(Mockery::type(OrderCompleted::class));
        
        $processor = new OrderProcessor($dispatcher);
        $processor->process($order);
    }

    Dependencies are explicit: Anyone reading the constructor knows immediately that this class dispatches events. No hidden global dependencies.

    Framework agnostic: You could swap the event system by implementing the same interface. The event() helper ties you to Laravel’s globals.

    When to Use Which

    Use event() in:

    • Controllers (already framework-coupled)
    • Blade views (convenience matters more)
    • Quick scripts and seeders

    Use injected Dispatcher in:

    • Services that need testing
    • Domain logic classes
    • Anywhere you want explicit dependencies

    The pattern extends to other Laravel facades too: inject Illuminate\Contracts\Cache\Repository instead of using Cache::get(), inject Illuminate\Contracts\Queue\Queue instead of Queue::push(), and so on.

    Your tests will thank you.

  • Combining Multiple Data Sources with Union Queries in Laravel

    The Problem

    Sometimes you need to merge data from different tables or queries into a single result set. Maybe you’re combining default configuration settings with user-specific overrides, or pulling together related records from separate tables. Writing separate queries and merging results in PHP is messy and inefficient.

    The Pattern

    Laravel’s union() method lets you combine multiple queries into one result set at the database level:

    use Illuminate\Support\Facades\DB;
    
    $defaultSettings = DB::table('system_settings')
        ->select('id', 'category', 'label')
        ->where('active', true);
    
    $userSettings = DB::table('user_preferences')
        ->select('id', 'category', 'name as label')
        ->where('user_id', $userId);
    
    $combined = $defaultSettings
        ->union($userSettings->toBase())
        ->get();
    

    Both queries must return the same number of columns with compatible data types. Use toBase() to convert Eloquent queries to query builder instances before the union.

    Real-World Example

    Building a dropdown of available templates: show built-in system templates plus user-created custom templates.

    $systemTemplates = DB::table('system_templates')
        ->selectRaw('id')
        ->selectRaw('name')
        ->selectRaw('? as source', ['System'])
        ->where('enabled', true);
    
    $userTemplates = DB::table('custom_templates')
        ->select('id', 'name')
        ->selectRaw('? as source', ['Custom'])
        ->where('created_by', auth()->id());
    
    $allTemplates = $systemTemplates
        ->union($userTemplates->toBase())
        ->orderBy('name')
        ->get();
    

    When to Use It

    • Merging defaults + overrides: System configs + user-specific settings
    • Multi-table aggregation: Combining similar data from legacy + current tables
    • Fallback chains: Primary source + backup source in one query

    Watch Out For

    • Column count must match: Use selectRaw('? as placeholder', [null]) for missing columns
    • Type compatibility: MySQL will coerce types, but keep them consistent
    • Performance: Union deduplicates by default (like SQL UNION). Use unionAll() if you want duplicates and better performance

    Quick Tip

    If you need to apply filters or pagination to the unified result, wrap it in a subquery:

    $union = $defaultSettings->union($userSettings->toBase());
    
    $results = DB::table(DB::raw("({$union->toSql()}) as combined"))
        ->mergeBindings($union)
        ->where('category', 'notifications')
        ->paginate(20);
    

    Union queries keep your data merging at the database level where it belongs—fast, efficient, and clean.

  • Timeline-Based Query Scoping for Data Corruption Fixes





    Timeline-Based Query Scoping for Data Corruption Fixes

    Timeline-Based Query Scoping for Data Corruption Fixes

    When a bug corrupts data, you need to fix only the affected records—not the entire table. Here’s how to use timestamp-based scoping to target exactly the right rows.

    The Problem

    A code change introduced a bug that incorrectly populated a field. The bug ran in production for 5 days before being caught. You need to fix the corrupted data without touching records created before or after that window.

    The Pattern

    Use created_at or audit timestamps to scope your data migration to the exact corruption window:

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Support\Facades\DB;
    
    class FixCorruptedCategoryAssignments extends Migration
    {
        public function up()
        {
            // Bug deployed: Feb 20, 2026 at 14:30 UTC
            // Bug fixed: Feb 25, 2026 at 09:15 UTC
            
            $bugStartTime = '2026-02-20 14:30:00';
            $bugEndTime = '2026-02-25 09:15:00';
            
            // Only fix records created during the corruption window
            DB::table('products')
                ->whereBetween('created_at', [$bugStartTime, $bugEndTime])
                ->where('category_id', 0)  // The corrupted value
                ->update([
                    'category_id' => DB::raw('(
                        SELECT categories.id 
                        FROM categories 
                        WHERE categories.slug = products.category_slug
                        LIMIT 1
                    )')
                ]);
        }
    }
    

    How to Find Your Timeline

    Reconstruct the bug timeline from these sources:

    1. Deployment logs — When was the bad code deployed?
    2. Git history — When was the buggy commit merged?
    3. Error monitoring — When did related errors start appearing?
    4. User reports — When did people first complain?
    5. Fix deployment — When was the patch deployed?

    Your scope window is: bug deployedbug fixed.

    Why This Matters

    Scoping by timestamp prevents two disasters:

    • Over-fixing: Applying “fixes” to records that were never broken
    • Under-fixing: Missing corrupted records because your scope was too narrow

    For example, if you only fix records with category_id = 0 without the timestamp scope, you might accidentally “fix” legitimate records that intentionally have no category (like drafts or templates).

    Testing Your Scope

    Before running the migration:

    // Count affected records
    $count = DB::table('products')
        ->whereBetween('created_at', [$bugStartTime, $bugEndTime])
        ->where('category_id', 0)
        ->count();
    
    echo "Will fix {$count} records\n";
    

    Compare this count against your expectations. If the bug affected ~100 orders per day for 5 days, you’d expect ~500 records. If you see 10,000, your scope is wrong.

    Edge Cases

    Bug Affected Updates, Not Inserts

    If the bug corrupted existing records (not new ones), use updated_at instead:

    DB::table('products')
        ->whereBetween('updated_at', [$bugStartTime, $bugEndTime])
        ->where('category_id', 0)
        ->update(['category_id' => /* fix logic */]);
    

    Multiple Deployment Windows

    If the bug was deployed, rolled back, then re-deployed, use multiple scopes:

    $windows = [
        ['2026-02-20 14:30:00', '2026-02-20 18:00:00'],  // First deployment
        ['2026-02-24 10:00:00', '2026-02-25 09:15:00'],  // Second deployment
    ];
    
    foreach ($windows as [$start, $end]) {
        DB::table('products')
            ->whereBetween('created_at', [$start, $end])
            ->where('category_id', 0)
            ->update(['category_id' => /* fix logic */]);
    }
    

    Verification

    After running the migration, verify the fix:

    // Should return 0
    $remaining = DB::table('products')
        ->whereBetween('created_at', [$bugStartTime, $bugEndTime])
        ->where('category_id', 0)
        ->count();
    
    if ($remaining > 0) {
        echo "WARNING: {$remaining} records still corrupted\n";
    }
    

    Timeline-based scoping turns a risky bulk update into a surgical fix. Analyze first, scope precisely, verify after.


  • Maintaining Forked Laravel Packages: Release Workflow





    Maintaining Forked Laravel Packages: Release Workflow

    Maintaining Forked Laravel Packages: Release Workflow

    Sometimes you need to fork a Laravel package to fix bugs or add features the maintainer won’t merge. Here’s a clean workflow for maintaining your fork while keeping your app’s composer dependencies sane.

    The Scenario

    You’re using a third-party admin panel package, but it has bugs. The upstream maintainer is inactive. You need to fix it yourself and use the fixes in your app.

    Fork Workflow

    # 1. Fork the package on GitHub
    # github.com/original-author/admin-package → github.com/your-org/admin-package
    
    # 2. Create a branch for your fixes
    git checkout -b fix/column-filters
    # ... make your changes ...
    git push origin fix/column-filters
    
    # 3. Create PR in YOUR fork (not upstream)
    # This gives you a place to review and discuss changes internally
    
    # 4. After PR approved, merge to your fork's master
    git checkout master
    git merge fix/column-filters
    git push origin master
    
    # 5. Tag a release
    git tag v2.1.5
    git push origin v2.1.5
    

    Composer Configuration

    In your app’s composer.json, point to your fork:

    {
        "repositories": [
            {
                "type": "vcs",
                "url": "https://github.com/your-org/admin-package"
            }
        ],
        "require": {
            "original-author/admin-package": "^2.1"
        }
    }
    

    Composer will automatically use your fork when you run composer update, because it sees a newer version (v2.1.5) in your repository.

    Version Constraints

    Before tagging, check if your app’s constraint will auto-update:

    • "^2.1" will pick up v2.1.5 automatically ✅
    • "~2.1.0" will pick up v2.1.5 automatically ✅
    • "2.1.0" exact version won’t auto-update ❌

    If using exact versions, you’ll need to manually bump the constraint in composer.json.

    Testing Before Release

    Before tagging, test your changes in staging:

    1. Update composer to use your fork’s master branch (pre-tag)
    2. Test the specific admin pages that use the package
    3. Verify filters, columns, and any features you touched
    4. Only then tag the release and update production

    Documentation

    In your PR description, document:

    • What bug you fixed
    • Which pages/features to test in staging
    • Any breaking changes

    This helps your team review and deploy confidently, especially when the original package documentation is lacking.

    When to Fork vs. Patch

    Fork when:

    • The upstream is abandoned
    • Your fixes are app-specific and won’t be accepted upstream
    • You need long-term control over the package

    Use Composer patches (cweagans/composer-patches) when:

    • The fix is small and temporary
    • You expect upstream to merge it soon
    • You don’t want to maintain a full fork


  • Pause Your Asset Compilation Container Before Frontend Changes

    # Pause Your Asset Compilation Container Before Frontend Changes

    When working on a Laravel application with separate containers for asset compilation (Vite, Mix, or Webpack), pause the compilation container before editing frontend files, then unpause after your changes are complete.

    **Why this matters:**

    Hot reload watchers can:
    – Lock files mid-edit
    – Trigger partial recompilations
    – Create race conditions with your IDE’s file writes
    – Generate confusing browser cache states

    **The workflow:**

    “`bash
    # Before editing JS/CSS/Vue files
    docker pause myapp-vite

    # Make your frontend changes
    # … edit components, styles, scripts …

    # After all changes are saved
    docker unpause myapp-vite
    “`

    **What to tell your users:**

    Instead of checking Docker logs after unpause, give them:
    – **Which page to view:** “Check `/dashboard/reports` page”
    – **What to test:** “Click ‘Export’ button, verify CSV downloads”
    – **What should change:** “The table should now be sortable”

    This keeps testing focused and avoids the “it compiled, now what?” confusion.

    **When to skip this:** If you’re only editing a single file and want live reload, keep the container running. But for multi-file refactors or component restructures, pause first.

    The two seconds spent pausing/unpausing saves minutes of debugging phantom reload issues.

    **Category:** DevOps

  • Make Shell Script Hooks Visible with Stderr Redirection

    I was setting up Git hooks to auto-sync my project workspace on exit, but I couldn’t see any output. The hook ran silently—no success messages, no errors, nothing. I had no idea if it was even working.

    The problem? Hook scripts inherit their parent’s stdout/stderr, but they often run in contexts where those streams are redirected to /dev/null. So even if you echo messages, you’ll never see them.

    The Solution: Redirect stderr to stdout, Then Format

    To make hook output visible, you need to:

    1. Redirect stderr to stdout (2>&1)
    2. Pipe through sed to add indentation
    3. Redirect back to stderr (>&2) so it appears in the terminal

    Here’s the pattern:

    #!/bin/bash
    
    # Run your command and make output visible
    my-sync-script.sh 2>&1 | sed 's/^/  /' >&2
    

    What’s Happening Here?

    • 2>&1 — Merge stderr into stdout (captures all output)
    • | sed 's/^/ /' — Add 2 spaces to the start of each line (formatting)
    • >&2 — Send the formatted output to stderr (visible in terminal)

    Example: Auto-Sync on Git Exit

    I used this pattern in a boot script that syncs project files from Google Drive before running:

    #!/bin/bash
    # ~/.local/bin/sync-workspace.sh
    
    echo "🔄 Syncing workspace from Google Drive..."
    
    rclone sync gdrive:workspace /local/workspace \
        --fast-list \
        --transfers 8 \
        2>&1 | sed 's/^/  /' >&2
    
    echo "✅ Workspace synced"
    

    Now when the script runs, I see:

    🔄 Syncing workspace from Google Drive...
      Transferred: 1.2 MiB / 1.2 MiB, 100%
      Checks: 47 / 47, 100%
    ✅ Workspace synced
    

    When to Use This

    • Git hooks (pre-commit, post-checkout, etc.)
    • Boot scripts that run on system startup
    • Cron jobs where you want output logged
    • Any background process where visibility helps debugging

    Without this pattern, your hooks run silently. With it, you get clear feedback—success messages, error details, everything.

  • Check Parent Classes Before Adding Traits

    I was refactoring some code and extracting a common method into a trait. I added use HasProductMapping; to 15+ plugin classes—then got this error:

    Trait method getProductMapping has not been applied as App\Plugins\BasePlugin::getProductMapping
    has the same visibility and finality
    

    What happened? Some of those plugins extended BasePlugin, which already had use HasProductMapping;. I was adding the trait to both parent and child classes, causing a conflict.

    The Problem with Inheritance + Traits

    When you add a trait to a class hierarchy, PHP doesn’t automatically deduplicate. If both parent and child use the same trait, you’ll get duplicate method declarations:

    // Parent class
    class BasePlugin
    {
        use HasProductMapping; // ✅ Trait added here
    }
    
    // Child class
    class GoogleToursPlugin extends BasePlugin
    {
        use HasProductMapping; // ❌ ERROR! Parent already has this trait
    }
    

    The Fix

    Before adding a trait to a class, check the inheritance chain:

    1. Does the parent class already use this trait? If yes, skip it—child inherits automatically.
    2. Do any base classes higher up the chain use it? Same rule applies.

    In my case, the correct approach was:

    // ✅ Add trait ONLY to base classes that don't inherit it
    class BasePlugin
    {
        use HasProductMapping; // Parent has it
    }
    
    class GoogleToursPlugin extends BasePlugin
    {
        // No trait needed—inherited from BasePlugin
    }
    
    class StandalonePlugin // Doesn't extend BasePlugin
    {
        use HasProductMapping; // ✅ Needs it directly
    }
    

    Quick Check

    When adding a trait across many classes:

    • Group by parent class
    • Add trait to parent or children, never both
    • If a standalone class exists (no parent with the trait), add it there

    This saves you from duplicate declaration errors and keeps your trait usage clean.

  • When Denormalized Data Creates Filter-Display Mismatches

    I was debugging a filter that seemed to work perfectly—until users started reporting missing results. The filter UI said “Show items with USD currency,” but items with USD weren’t appearing.

    The problem? The filter was checking one field (batch_items.currency), but the UI was displaying a different field (batches.cached_currencies)—a denormalized summary column that aggregated currencies from all batch items.

    Here’s what was happening:

    // Filter checked individual batch items
    $query->whereHas('batchItems', function ($q) use ($currency) {
        $q->where('currency', $currency);
    });
    
    // But the UI displayed the denormalized summary
    $batch->cached_currencies; // ["USD", "EUR", "GBP"]
    

    When all batch items were sold out or inactive, the whereHas() check would return nothing—even though cached_currencies still showed “USD” because it hadn’t been refreshed.

    The Fix

    Match the filter logic to what users actually see. If you’re displaying denormalized data, either:

    1. Filter against the same denormalized field:
    // Filter matches what's displayed
    $query->whereJsonContains('cached_currencies', $currency);
    
    1. Or ensure the denormalized field stays in sync:
    // Observer to keep cached data fresh
    class BatchObserver
    {
        public function saved(Batch $batch)
        {
            $batch->update([
                'cached_currencies' => $batch->batchItems()
                    ->distinct('currency')
                    ->pluck('currency')
            ]);
        }
    }
    

    The Lesson

    When users see one thing but your filter checks another, you’ll get confusing bugs. Always verify: Does my filter logic match what’s displayed in the UI?

    If you’re caching/denormalizing data for performance, make sure filters query the same cached field—or keep it strictly in sync.

  • Pass Dynamic Values as Exception Context, Not in the Message

    Pass Dynamic Values as Exception Context, Not in the Message

    When throwing exceptions, avoid including dynamic values (like database IDs, user IDs, or timestamps) directly in the exception message. Instead, pass them as context data. This allows error monitoring tools like Sentry to properly group similar errors together instead of treating each unique ID as a separate issue.

    Why It Matters

    Error monitoring tools use the exception message as the primary grouping key. If your message includes dynamic values, every occurrence creates a new issue, making it impossible to see patterns and track frequency.

    Bad Approach

    throw new Exception("Order {$orderId} could not be processed");
    // Sentry creates separate issues for:
    // "Order 123 could not be processed"
    // "Order 456 could not be processed"
    // "Order 789 could not be processed"
    

    Good Approach

    throw new InvalidOrderException(
        "Order could not be processed",
        ['order_id' => $orderId]
    );
    // All occurrences group under one issue:
    // "Order could not be processed" (123 events)
    

    For HTTP Client Exceptions

    When working with Guzzle or other HTTP clients, pass context as the second parameter:

    try {
        return $this->httpClient->request($method, $url, $requestBody);
    } catch (RequestException $exception) {
        throw ApiClientException::fromGuzzleException(
            $exception,
            [
                'request_data' => json_encode($data),
                'request_body' => json_encode($body),
                'query_string' => json_encode($query)
            ]
        );
    }
    

    Benefits

    • Proper error grouping and frequency tracking
    • Cleaner error reports
    • Easier to identify recurring vs one-off issues
    • Context data is still logged and searchable

    This applies to any exception in your Laravel application—database exceptions, API errors, validation failures, etc.

  • Defensive Coding for Configuration Arrays in PHP 8+

    Defensive Coding for Payment Configuration Arrays in PHP 8+

    PHP 8 and later versions throw errors when you try to access undefined array keys. This stricter behavior is great for catching bugs, but it can cause production issues when dealing with configuration arrays that might be incomplete—especially when those configurations come from databases or are managed by users.

    The Problem

    Consider a payment gateway configuration class that expects both production and testing credentials:

    class PaymentConfig
    {
        private string $apiKey;
        private string $testApiKey;
        private string $webhookSecret;
        private string $testWebhookSecret;
    
        public function __construct(array $config)
        {
            $this->apiKey = $config['api_key'];
            $this->testApiKey = $config['test']['api_key']; // ⚠️ Undefined key!
            
            $this->webhookSecret = $config['webhook_secret'];
            $this->testWebhookSecret = $config['test']['webhook_secret'];
        }
    }
    

    If the $config['test'] array is missing the api_key field, PHP 8 will throw an ErrorException: Undefined array key "api_key" error in production.

    Why This Happens

    Configuration arrays stored in databases or managed through admin UIs can become incomplete over time:

    • Older records might predate new required fields
    • Users might skip optional fields when setting up integrations
    • Different payment providers might require different credential sets
    • Migration scripts might not populate every nested key

    The Solution

    Use the null coalescing operator (??) to provide safe defaults:

    class PaymentConfig
    {
        private string $apiKey;
        private ?string $testApiKey;
        private string $webhookSecret;
        private ?string $testWebhookSecret;
    
        public function __construct(array $config)
        {
            $testConfig = $config['test'] ?? [];
            
            $this->apiKey = $config['api_key'];
            $this->testApiKey = $testConfig['api_key'] ?? null;
            
            $this->webhookSecret = $config['webhook_secret'];
            $this->testWebhookSecret = $testConfig['webhook_secret'] ?? null;
        }
        
        public function getApiKey(): string
        {
            return $this->isTestMode() && $this->testApiKey 
                ? $this->testApiKey 
                : $this->apiKey;
        }
    }
    

    Alternative: Check Before Accessing

    For more explicit control, use array_key_exists() or isset():

    public function __construct(array $config)
    {
        $this->apiKey = $config['api_key'];
        
        if (isset($config['test']['api_key'])) {
            $this->testApiKey = $config['test']['api_key'];
        } else {
            $this->testApiKey = null;
        }
    }
    

    When to Use This Pattern

    • Configuration loaded from databases or external APIs
    • User-managed settings that might be incomplete
    • Optional integration credentials
    • Migrated data that predates schema changes
    • Multi-tenant applications where features vary per tenant

    Production Safety Checklist

    1. Never assume keys exist in user-provided or database-stored configuration
    2. Use nullable types (?string) for optional config fields
    3. Provide sensible defaults with ?? operator
    4. Fail gracefully when required keys are missing
    5. Log warnings for missing expected keys (helps identify configuration drift)

    The Takeaway

    PHP 8’s stricter error handling is a feature, not a bug—but it requires defensive coding practices when dealing with external data. Always use the null coalescing operator or explicit checks when accessing configuration arrays that might be incomplete. Your production errors will thank you.