Blog

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

  • Role-Based Historical Data Migrations with Progress Tracking

    Fixing historical data issues in production is nerve-wracking. You need to update thousands of records, but a single wrong JOIN can cascade the update to user data you shouldn’t touch.

    Here’s a pattern I use: role-based filtering combined with progress tracking to make data migrations safer and more observable.

    The Problem: Broad Updates Are Dangerous

    Say you need to fix a file naming pattern in a generated_reports table. The naive approach:

    public function up(): void
    {
        DB::table('generated_reports')
            ->where('file_name', 'LIKE', 'Old_Format_%')
            ->update([
                'file_name' => DB::raw("REPLACE(file_name, 'Old_Format_', 'New_Format_')")
            ]);
    }
    

    But what if this table has reports generated by customers, internal admins, AND automated systems? You only want to fix admin reports, but this query hits everything.

    The Pattern: Role-Based Scoping

    Use JOINs to filter by user roles, then add progress tracking:

    use Symfony\Component\Console\Output\ConsoleOutput;
    
    public function up(): void
    {
        $output = new ConsoleOutput();
        
        // Step 1: Find affected records with role filtering
        $affected = DB::table('generated_reports')
            ->join('users', 'generated_reports.user_id', '=', 'users.id')
            ->join('role_user', 'users.id', '=', 'role_user.user_id')
            ->join('roles', 'role_user.role_id', '=', 'roles.id')
            ->whereIn('roles.slug', ['admin', 'finance', 'manager'])
            ->where('generated_reports.file_name', 'LIKE', 'Old_Format_%')
            ->where('generated_reports.created_at', '>=', '2024-01-01')
            ->select('generated_reports.id')
            ->distinct()  // Prevent duplicate IDs from multiple role assignments
            ->get();
        
        $output->writeln("Found {$affected->count()} records to update");
        
        // Step 2: Update in chunks with progress indicator
        $affected->chunk(100)->each(function ($chunk) use ($output) {
            DB::table('generated_reports')
                ->whereIn('id', $chunk->pluck('id'))
                ->update([
                    'file_name' => DB::raw("REPLACE(file_name, 'Old_Format_', 'New_Format_')")
                ]);
            
            $output->write('.');  // Progress indicator
        });
        
        $output->writeln("\nMigration complete: {$affected->count()} records updated");
    }
    

    Why This Matters

    This pattern:

    • Scopes updates safely: JOINs to roles table ensure you only touch records created by specific user types
    • Prevents accidental cascade: If roles misconfigured, updates fail instead of hitting wrong records
    • Visible progress: ConsoleOutput lets you watch long-running migrations in real-time
    • Handles duplicates: distinct() prevents row multiplication from many-to-many role relationships
    • Testable in dev: Run on staging with --pretend to see affected IDs without changes

    Key Components Explained

    1. Role-Based JOIN Chain

    ->join('users', 'generated_reports.user_id', '=', 'users.id')
    ->join('role_user', 'users.id', '=', 'role_user.user_id')
    ->join('roles', 'role_user.role_id', '=', 'roles.id')
    ->whereIn('roles.slug', ['admin', 'finance', 'manager'])
    

    This filters to records created by users with specific roles. If your app uses a different permission system (Spatie, custom), adjust accordingly.

    2. ConsoleOutput for Progress

    use Symfony\Component\Console\Output\ConsoleOutput;
    
    $output = new ConsoleOutput();
    $output->writeln("Found X records");  // Green text
    $output->write('.');  // Progress dots
    

    Works in migrations because they run via Artisan. You see progress live in your terminal.

    3. distinct() to Avoid Duplicates

    When a user has multiple roles, JOIN creates duplicate rows. distinct() collapses them:

    ->select('generated_reports.id')
    ->distinct()
    

    Without this, the same report gets updated multiple times (harmless but wasteful).

    When to Use This

    • Historical data fixes that should only affect specific user types (admins, internal users, etc.)
    • Migrations on large tables where you need to see progress
    • When UPDATE scope is safety-critical (don’t want to accidentally touch customer data)
    • When you need to generate an affected-IDs report before applying changes

    Testing Before Running

    Always verify scope on staging first:

    # See the query without executing
    php artisan migrate --pretend
    
    # Or add a dry-run to your migration:
    if (app()->environment('local')) {
        $output->writeln("DRY RUN - would update: " . $affected->pluck('id')->implode(', '));
        return;
    }
    

    Remember: Data migrations in production are one-way. Role-based filtering gives you an extra safety net to ensure you’re only touching the records you intend to fix.

  • Defensive Error Message Extraction from API Responses

    When consuming external APIs, error responses come in all shapes and sizes. Some vendors nest errors deeply (error.response.data.error.message), others put them at the root (error.message), and some just throw generic HTTP status text.

    If you’re not defensive about extracting these messages, your users will see unhelpful errors like “undefined” or “Cannot read property ‘message’ of undefined” instead of the actual problem.

    The Pattern: Fallback Chains with Existence Checks

    Build a chain of fallback attempts, each guarded by existence checks:

    // Frontend API call (Vue.js, React, plain JS)
    try {
      const response = await axios.get('/api/products/123')
      this.product = response.data
    } catch (error) {
      const message = (
        error.response?.data?.error?.message ||  // Try nested first
        error.response?.data?.message ||         // Then less nested
        error.message ||                         // Generic error message
        'Failed to load product data'            // Hardcoded fallback
      )
      
      this.showError(message)
    }
    

    The same pattern works server-side in PHP/Laravel:

    use Illuminate\Support\Facades\Http;
    
    try {
        $response = Http::get('https://api.vendor.com/products/123');
        return $response->json();
    } catch (\Exception $e) {
        $message = $e->response['error']['message'] 
            ?? $e->response['message']
            ?? $e->getMessage()
            ?? 'Failed to fetch product from external API';
        
        Log::error('External API error', ['message' => $message]);
        throw new \RuntimeException($message);
    }
    

    Why This Matters

    This pattern:

    • Prevents runtime errors: Each level is optional-chained (?. or ??), so missing properties don’t crash
    • Provides useful fallbacks: Users see the most specific error available, never “undefined”
    • Works across vendors: Different API error formats are handled gracefully
    • Debuggable: Hardcoded fallback tells you “we got an error but couldn’t extract a message”

    Common API Error Formats

    Here are the patterns you’ll encounter:

    // Format 1: Deeply nested (Stripe-style)
    {
      "error": {
        "type": "invalid_request_error",
        "message": "No such customer: cus_xxxxx"
      }
    }
    
    // Format 2: Root-level message (Laravel-style)
    {
      "message": "The given data was invalid.",
      "errors": { ... }
    }
    
    // Format 3: HTTP status text only
    // (no JSON body, just 500 Internal Server Error)
    

    Your fallback chain handles all three:

    1. Try error.response.data.error.message → catches Format 1
    2. Try error.response.data.message → catches Format 2
    3. Try error.message → catches Format 3 (Axios wraps HTTP status text here)
    4. Use hardcoded fallback → catches unexpected formats

    Laravel HTTP Client Version

    Laravel’s HTTP client throws different exceptions, so adjust your chain:

    use Illuminate\Http\Client\RequestException;
    
    try {
        $response = Http::timeout(10)->get('https://api.vendor.com/data');
        return $response->throw()->json();
    } catch (RequestException $e) {
        // Laravel wraps the response
        $message = data_get($e->response, 'error.message')
            ?? data_get($e->response, 'message')
            ?? $e->getMessage()
            ?? 'API request failed';
        
        throw new \RuntimeException($message);
    }
    

    Using data_get() is cleaner than ?? chains for deeply nested arrays.

    When to Use This

    • Any external API integration (payment gateways, shipping APIs, third-party services)
    • Frontend API calls where you display errors to users
    • Background jobs that need to log meaningful error messages
    • Webhook handlers that receive errors from external systems

    Remember: Never assume error responses will match the API docs. Build fallback chains so your users (and logs) always see a meaningful message, even when the API returns something unexpected.

  • Bypassing Global Query Scopes for Admin/Backend Features

    Global query scopes are great for filtering production data — hiding soft-deleted records, filtering by status, etc. But when you’re building admin tools, those same scopes become obstacles.

    Here’s the problem: you’re building a translation editor for your e-commerce dashboard. Your Product model has a global scope that hides discontinued products. But translators need to update ALL products, including discontinued ones, because those translations might be needed for historical orders or future reactivation.

    The Pattern: Explicit Scope-Bypassing Methods

    Instead of fighting with scopes everywhere, create dedicated methods that explicitly bypass them:

    // In your Order model
    public function all_items()
    {
        return $this->hasMany(OrderItem::class)
            ->withoutGlobalScopes()
            ->get();
    }
    
    // Usage in admin controllers
    $order = Order::find($id);
    $allItems = $order->all_items(); // Gets even soft-deleted items
    

    Compare this to the default relationship:

    // Normal relationship - respects global scopes
    $order->items; // Hides discontinued/soft-deleted items
    
    // Admin relationship - explicit about bypassing
    $order->all_items(); // Shows everything
    

    Why This Matters

    This pattern:

    • Makes intent explicit: all_items() signals “I know what I’m doing, show me everything”
    • Isolates scope-bypassing: Only admin code uses these methods; production code uses normal relationships
    • Prevents bugs: No accidental scope bypassing in customer-facing features
    • Self-documenting: Method name explains why scopes are bypassed

    When to Use This

    • Admin dashboards that need full data visibility
    • Translation/content management interfaces
    • Data export tools
    • Debugging utilities
    • Bulk operations that shouldn’t skip “hidden” records

    Alternative Approaches

    You could use withoutGlobalScopes() inline everywhere:

    $order->items()->withoutGlobalScopes()->get();
    

    But this is:

    • Verbose and repetitive
    • Easy to forget in some places
    • Harder to grep for “where are we bypassing scopes?”

    A named method is cleaner, easier to test, and more maintainable.

    Bonus: Selective Scope Bypassing

    You can also bypass specific scopes while keeping others:

    public function published_items_including_deleted()
    {
        return $this->hasMany(OrderItem::class)
            ->withoutGlobalScope(SoftDeletingScope::class)
            ->get();
    }
    

    This keeps your “published” scope active but removes soft delete filtering — useful when you want partial bypassing.

    Remember: Global scopes exist to protect production users from seeing the wrong data. When you need admin superpowers, make it explicit with dedicated methods. Your future self (and your code reviewers) will thank you.

  • Laravel UX: Silent Success, Loud Failures

    Good UX in Laravel isn’t about confirming every action. It’s about staying silent when things go as expected and being loud only when something breaks.

    The Anti-Pattern

    You’ve seen this: user clicks “Delete”, and a success toast pops up: “Success! Item deleted!” But… the user just clicked delete. They expected it to work. Why confirm the obvious?

    public function delete(Request $request, $id)
    {
        $item = Item::findOrFail($id);
        $item->delete();
    
        // Unnecessary confirmation
        session()->flash('success', 'Item deleted successfully!');
    
        return back();
    }

    This creates notification fatigue. Users start ignoring all flash messages because most are just noise.

    The Better Approach

    Only show messages when something unexpected happens:

    public function delete(Request $request, $id)
    {
        $item = Item::findOrFail($id);
    
        try {
            $item->delete();
            // Silent success - user clicked delete, it deleted
            return back();
        } catch (\Exception $e) {
            // Loud failure - unexpected! User needs to know
            session()->flash('error', 'Could not delete item. Please try again.');
            report($e);
            return back();
        }
    }

    When to Show Success Messages

    Success confirmations are useful in these cases:

    • Long-running operations — “Export complete! Download ready.”
    • Background processing — “We’ll email you when import finishes.”
    • Non-obvious outcomes — “Payment scheduled for next Monday.”
    • Multi-step processes — “Step 2 of 3 complete.”

    But for immediate, synchronous actions where the UI already shows the result? Stay silent.

    Real-World Impact

    In one dashboard, removing unnecessary success confirmations for routine actions reduced notification spam by ~70%. Users reported the interface felt “less noisy” and they actually started noticing error messages when they appeared.

    Key Takeaway

    Expected outcomes should be silent. Unexpected outcomes need alerts. Save your flash messages for exceptions and warnings—that’s when users actually need them.

  • Laravel API Integration: Stop Retrying Permanent Failures

    When integrating with external APIs in Laravel, catching generic exceptions and retrying blindly wastes queue resources. Here’s a better approach: parse error responses and throw domain-specific exceptions.

    The Problem

    Your queue job calls an API. Sometimes it returns 500 Internal Server Error (temporary). Sometimes it returns 400 Bad Request - Invalid Resource ID (permanent config error). If you catch both with a generic exception, the queue retries forever even for permanent failures.

    try {
        $data = $this->apiClient->fetchResource($resourceId);
    } catch (ApiException $exception) {
        // Generic catch = queue retries uselessly for config errors
        throw $exception;
    }

    The Solution

    Parse the API error response and throw appropriate exceptions:

    try {
        $data = $this->apiClient->fetchResource($resourceId);
    } catch (ApiException $exception) {
        $errorCode = json_decode(
            (string)$exception->getPrevious()?->getResponse()->getBody(),
            true
        )['error'] ?? null;
    
        if ($errorCode === 'INTERNAL_SERVER_ERROR') {
            // Temporary failure - queue should retry
            throw new TemporarilyUnavailableException(
                'Cannot fetch data',
                500,
                $exception
            );
        }
    
        if ($errorCode === 'INVALID_RESOURCE_ID') {
            // Permanent config error - stop retrying immediately
            throw new InvalidMappingException(
                $this->getResourceMapping($resourceId),
                previous: $exception
            );
        }
    
        // Unknown error - rethrow original
        throw $exception;
    }

    Why This Works

    Different exception types let your queue orchestrator handle them appropriately:

    • TemporarilyUnavailableException — Queue retries with backoff (API might recover)
    • InvalidMappingException — Queue deletes job immediately (config needs manual fix)
    • Generic exceptions — Default retry behavior

    In one real-world case, this pattern stopped ~20,900 useless retry attempts for permanent mapping errors, freeing queue resources immediately.

    Key Takeaway

    Don’t treat all API failures the same. Parse error responses, throw specific exceptions, and let your queue orchestrator make smart retry decisions.