Blog

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

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