Category: Laravel

  • Running Multiple Laravel Mix Hot Reload Servers Simultaneously

    When working on Laravel applications with multiple frontends—think public website, admin dashboard, and client portal—developers often hit a frustrating wall: you can only run one npm run hot process at a time. Switch to working on the admin panel? Restart the entire Node container. Back to the main site? Restart again.

    The culprit? Port conflicts. Every webpack-dev-server instance tries to bind to port 8080 by default, and Docker won’t let two containers claim the same port.

    The Solution: HOT_PORT Environment Variable

    Laravel Mix already supports this use case through the HOT_PORT environment variable. Both your main webpack.mix.js and any project-specific configs check for it:

    // webpack.mix.js
    if (process.env.npm_lifecycle_event === 'hot') {
      mix.webpackConfig({
        devServer: {
          host: '0.0.0.0',
          port: process.env.HOT_PORT || 8080
        }
      });
    }

    This means you can run multiple hot reload servers simultaneously, each on its own port, by defining custom npm scripts:

    // package.json
    {
      "scripts": {
        "hot": "cross-env NODE_ENV=development webpack-dev-server --inline --hot ...",
        "hot:admin": "export HOT_PORT=8081 && cross-env process.env.project='admin' NODE_ENV=development webpack-dev-server --inline --hot ..."
      }
    }

    Docker Setup

    Expose both ports in your docker-compose.yml:

    services:
      node:
        ports:
          - 8080:8080  # Default hot reload
          - 8081:8081  # Admin panel hot reload

    Running Multiple Servers

    Now you can develop on multiple frontends simultaneously:

    # Terminal 1: Default site (port 8080)
    npm run hot
    
    # Terminal 2: Admin panel (port 8081)
    npm run hot:admin

    Each webpack-dev-server watches its own files and serves its own HMR updates. Changes to your public site won’t trigger admin panel rebuilds, and vice versa.

    When This Matters

    This approach shines in monolithic Laravel apps with distinct user interfaces:

    • Customer portal (Vue.js, Tailwind)
    • Admin dashboard (different Vue components, Bootstrap)
    • Partner interface (unique styling, separate Vuex stores)

    Instead of context-switching between npm processes and waiting for container restarts, you keep all your dev servers running. Jump between codebases without losing hot reload state.

    Bonus: Conditional Configs

    Laravel Mix supports loading different webpack configs based on process.env.project:

    // webpack.mix.js
    const project = process.env.project;
    
    if (project) {
      require(`${__dirname}/webpack.mix.${project}.js`);
      return;
    }
    
    // Default config follows...

    This lets you maintain clean, project-specific build configs (e.g., webpack.mix.admin.js, webpack.mix.customer.js) while still leveraging the shared HOT_PORT pattern.

    Key Takeaway

    Laravel Mix’s HOT_PORT support isn’t just a convenience—it’s a workflow multiplier for multi-frontend monoliths. No more container restarts. No more choosing which part of your app gets hot reload. Just parallel dev servers doing what they do best.

  • Debugging Eloquent Relationships with toRawSql() Before Writing Migrations

    You just defined a new Eloquent relationship. Now you need to write the migration. What columns should you create? What should they be named? What foreign keys do you need?

    Stop guessing. Use toRawSql() to see exactly what Eloquent expects before you write a single line of migration code.

    The Problem: Eloquent’s Naming Conventions

    Laravel has conventions for relationship columns:

    • hasMany expects parent_id in the child table
    • morphToMany expects taggable_id and taggable_type
    • hasManyThrough expects specific columns in both intermediate and target tables

    If you get any of these wrong, your queries fail at runtime. The fix: inspect the raw SQL before you migrate.

    Using toRawSql() in Tinker

    Define your relationship in the model:

    // app/Models/Order.php
    public function items()
    {
        return $this->hasMany(Item::class);
    }

    Before writing the migration, open php artisan tinker and run:

    Order::has('items')->toRawSql();

    Output:

    select * from `orders` where exists (
        select * from `items`
        where `orders`.`id` = `items`.`order_id`
    )

    Boom. Eloquent expects an order_id column in the items table. Now you know what to create:

    Schema::create('items', function (Blueprint $table) {
        $table->id();
        $table->foreignId('order_id')->constrained();
        $table->string('name');
        $table->timestamps();
    });

    Example: Polymorphic Relationships

    Let’s say you’re building a tagging system. Products can have tags:

    // app/Models/Product.php
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }

    What columns does this need? Check Tinker:

    Product::has('tags')->toRawSql();

    Output:

    select * from `products` where exists (
        select * from `tags`
        inner join `taggables` on `tags`.`id` = `taggables`.`tag_id`
        where `products`.`id` = `taggables`.`taggable_id`
        and `taggables`.`taggable_type` = 'App\\Models\\Product'
    )

    Now you know:

    • Pivot table must be named taggables
    • It needs tag_id, taggable_id, and taggable_type columns
    • The taggable_type column will store the full model class name

    Write the migration:

    Schema::create('taggables', function (Blueprint $table) {
        $table->id();
        $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
        $table->morphs('taggable'); // Creates taggable_id and taggable_type
        $table->timestamps();
        
        $table->unique(['tag_id', 'taggable_id', 'taggable_type']);
    });

    Debugging Existing Relationships

    If a relationship query is failing, toRawSql() shows you what Eloquent is actually looking for:

    // This query is failing. Why?
    $orders = Order::with('customer')->get();
    
    // Check the raw SQL
    Order::with('customer')->toRawSql();

    Output shows it’s looking for customer_id in the orders table, but your column is named user_id. Fix it in the relationship definition:

    public function customer()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    Advanced: Nested Relationships

    For complex queries with nested eager loading, toRawSql() reveals the entire join chain:

    Order::with('items.product')->toRawSql();

    This shows you all the foreign keys Eloquent expects across the entire relationship chain. Invaluable when working with hasManyThrough or deeply nested relationships.

    Pro Tips

    1. Use toRawSql() before migrate: Define relationships first, inspect SQL, then write migrations. Catches naming mismatches immediately.
    2. Works with any query builder method: whereHas, with, has, withCount—if it generates SQL, toRawSql() shows it.
    3. Copy-paste into a database client: Run the raw SQL directly to test if the schema matches your expectations.
    4. Laravel 9+: In earlier versions, use toSql() instead (shows SQL with ? placeholders).

    Bonus: Debugging Performance Issues

    toRawSql() also helps spot N+1 queries and missing indexes:

    // Are we eager loading properly?
    Product::with('category', 'tags')->toRawSql();
    
    // Check if the query uses the right index
    Product::where('status', 'active')->orderBy('created_at', 'desc')->toRawSql();

    Paste the SQL into EXPLAIN to see if your indexes are being used.

    Stop Guessing, Start Inspecting

    Eloquent’s conventions are predictable, but when you’re dealing with polymorphic relationships, custom foreign keys, or deep nesting, toRawSql() removes all guesswork. Define the relationship, inspect the SQL, write the migration. No runtime surprises.

  • Polymorphic Bridge Pattern: Connecting Laravel Models to External Systems

    When integrating Laravel with an external system—a headless CMS, a legacy database, an analytics platform—you often need multiple Laravel models to link to records in that external system. The naive approach is adding a cms_post_id column to each model. But that creates tight coupling and makes future changes painful.

    The better pattern: use a polymorphic pivot table as a bridge layer between your domain models and the external system.

    The Problem

    You have Product, Category, and Tag models. Each can link to posts in an external CMS. The obvious solution:

    // products table
    cms_post_id (bigint)
    
    // categories table
    cms_post_id (bigint)
    
    // tags table
    cms_post_id (bigint)

    Now every model needs CMS-specific logic. Testing requires the CMS. Migrating to a different CMS means updating every model. You’ve tightly coupled your domain to infrastructure.

    The Solution: Polymorphic Bridge

    Create an intermediate pivot table that maps any model to external records:

    // Migration: create_cms_links_table.php
    Schema::create('cms_links', function (Blueprint $table) {
        $table->id();
        $table->morphs('linkable'); // linkable_id, linkable_type
        $table->unsignedBigInteger('cms_post_id');
        $table->timestamps();
        
        $table->unique(['linkable_id', 'linkable_type', 'cms_post_id']);
    });

    Now your models use morphToMany relationships to connect through the bridge:

    // app/Models/Product.php
    public function cmsPosts()
    {
        return $this->morphToMany(
            CmsPost::class,
            'linkable',
            'cms_links',
            'linkable_id',
            'cms_post_id'
        );
    }
    
    // app/Models/Category.php
    public function cmsPosts()
    {
        return $this->morphToMany(
            CmsPost::class,
            'linkable',
            'cms_links',
            'linkable_id',
            'cms_post_id'
        );
    }

    Use it like any relationship:

    $product->cmsPosts()->attach($cmsPostId);
    $product->cmsPosts; // Collection of CmsPost models

    Enforce Clean Type Names with Morph Maps

    By default, Laravel stores the full class name in linkable_type: App\Models\Product. If you ever refactor namespaces or rename models, those database values break.

    Fix this with Relation::enforceMorphMap() in a service provider:

    // app/Providers/AppServiceProvider.php
    use Illuminate\Database\Eloquent\Relations\Relation;
    
    public function boot()
    {
        Relation::enforceMorphMap([
            'product' => Product::class,
            'category' => Category::class,
            'tag' => Tag::class,
        ]);
    }

    Now linkable_type stores product instead of App\Models\Product. Your database is decoupled from your code structure.

    Converting to One-to-One Relationships

    If a model should only link to one CMS post, use ofOne() to convert the many-to-many into a one-to-one:

    public function cmsPost()
    {
        return $this->morphToMany(
            CmsPost::class,
            'linkable',
            'cms_links',
            'linkable_id',
            'cms_post_id'
        )->one();
    }

    Now $product->cmsPost returns a single CmsPost instance (or null), not a collection.

    Why This Pattern Wins

    • Clean domain models: No CMS-specific columns polluting your core tables.
    • Flexible: Need to link a new model? Just add the relationship—no migration to add columns.
    • Swappable: Migrating from WordPress to Contentful? Update the CmsPost model and the bridge table, your domain models stay untouched.
    • Testable: Mock the relationship, no need for the external CMS in tests.

    Real-World Example: Analytics Platform

    Suppose you’re tracking user actions in an external analytics platform. Each User, Order, and Event can have an analytics profile ID.

    // Migration
    Schema::create('analytics_links', function (Blueprint $table) {
        $table->id();
        $table->morphs('trackable');
        $table->string('analytics_profile_id');
        $table->timestamps();
        
        $table->unique(['trackable_id', 'trackable_type']);
    });
    
    // Models
    class User extends Model
    {
        public function analyticsProfile()
        {
            return $this->morphToMany(
                AnalyticsProfile::class,
                'trackable',
                'analytics_links',
                'trackable_id',
                'analytics_profile_id'
            )->one();
        }
    }
    
    // Usage
    $user->analyticsProfile()->attach($profileId);
    $profileId = $user->analyticsProfile->id;

    Your core models stay focused on your business logic. The analytics integration is isolated to the bridge table and the AnalyticsProfile model.

    When Not to Use This

    If the foreign ID is part of your domain—like user_id on an Order—put it directly in the table. This pattern is for external systems that your domain doesn’t inherently care about.

    But when you’re integrating with CMSs, analytics platforms, search engines, or any third-party system where multiple models need to link out, the polymorphic bridge pattern keeps your domain clean and your code flexible.

  • Draft-Then-Publish Pattern for Transactional Safety with External Systems

    When your Laravel app integrates with external systems—third-party APIs, CMS platforms via CLI, payment gateways—standard database transactions can’t protect you. If your Laravel transaction succeeds but the external call fails (or vice versa), you’re left with inconsistent state across systems.

    The solution: create external records in a draft or pending state, complete all Laravel operations within a transaction, and only promote the external resource to published/active after the transaction commits.

    The Pattern

    public function handle(): void {
        $createdExternalIds = [];
        
        foreach ($this->items as $item) {
            try {
                // Step 1: Create in external system as DRAFT
                $externalId = $this->createExternalResource($item, status: 'draft');
                $createdExternalIds[] = $externalId;
                
                // Step 2: Laravel DB transaction
                DB::transaction(function () use ($item, $externalId) {
                    $record = Record::create([
                        'external_id' => $externalId,
                        'title' => $item['title'],
                        'status' => 'pending'
                    ]);
                    
                    $record->tags()->sync($item['tags']);
                    // ... other database operations
                });
                
                // Step 3: Success! Publish the external resource
                $this->publishExternalResource($externalId);
                
            } catch (Throwable $e) {
                report($e);
                $this->error(sprintf('%s: %s', $item['title'], $e->getMessage()));
                continue; // Keep processing other items
            }
        }
    }

    Why This Works

    If the Laravel transaction fails for any reason—validation, constraint violation, or application error—the external record remains in draft state. You can clean it up later, retry the import, or manually investigate. Nothing went live that shouldn’t have.

    If the external API call succeeds but Laravel fails, you still have a draft record in the external system. Your database stays clean.

    Only when both sides succeed does the external record become visible to users.

    When to Use This

    • Content management systems: Create posts/pages as drafts, link them in Laravel, then publish.
    • Third-party APIs: If the API supports draft/pending states (Stripe payment intents, Shopify draft orders, etc.).
    • Batch imports: Processing hundreds or thousands of records where you want partial success rather than all-or-nothing.

    Implementation Tips

    1. Abstract the external calls into methods

    private function createExternalResource(array $data, string $status): string
    {
        $result = Http::post('https://api.example.com/resources', [
            'title' => $data['title'],
            'status' => $status,
        ]);
        
        return $result->json('id');
    }
    
    private function publishExternalResource(string $id): void
    {
        Http::patch("https://api.example.com/resources/{$id}", [
            'status' => 'published'
        ]);
    }

    2. Use continue on errors to process remaining items

    In batch operations, don’t let one failure kill the entire import. Log it, report it, move on.

    3. Consider cleanup jobs for orphaned drafts

    If your process crashes halfway through, you might have draft records in the external system with no corresponding Laravel record. Schedule a daily cleanup job that checks for drafts older than 24 hours and deletes them.

    Real-World Example: CMS Integration

    Let’s say you’re building a product catalog in Laravel that syncs to a headless CMS for public display. Each Laravel Product needs a corresponding CMS post.

    DB::transaction(function () use ($productData) {
        // Create CMS post as draft
        $cmsPostId = $this->cms->createPost([
            'title' => $productData['name'],
            'status' => 'draft',
            'content' => $productData['description']
        ]);
        
        // Create Laravel product
        $product = Product::create([
            'name' => $productData['name'],
            'sku' => $productData['sku'],
            'cms_post_id' => $cmsPostId,
        ]);
        
        $product->categories()->sync($productData['category_ids']);
        
        // Publish the CMS post
        $this->cms->publishPost($cmsPostId);
    });

    If anything inside the transaction fails—duplicate SKU, missing category, whatever—the CMS post stays as a draft. Your public site never shows broken data.

    When You Can’t Use Drafts

    Not every external system supports draft states. In those cases:

    • Do the Laravel work first, then make the external call after the transaction commits.
    • Use queued jobs for the external call—if it fails, retry logic kicks in.
    • Store the external state in a pending column in Laravel, update it to synced after success.

    But if the external system does support drafts or pending states, use them. It’s the cleanest way to maintain consistency across both systems.

  • Debugging Vue Component Show/Hide State

    The Problem

    You’re debugging a Vue component where clicking a button should toggle a panel’s visibility, but nothing happens. The button renders, the click handler fires, but the panel stays hidden (or visible). Where’s the disconnect?

    The Debug Checklist

    When Vue show/hide logic breaks, check these four spots in order:

    1. The State Variable Name

    Is your state variable named consistently? Check your data() function:

    data() {
      return {
        showAdvanced: false  // ← Is this the exact name you're using everywhere?
      }
    }

    Common mistake: defining showAdvanced but using show_advanced in the template.

    2. The Directive Reference

    Does your v-show or v-if reference the correct variable?

    <div v-show="showAdvanced" class="advanced-panel">
      <!-- content -->
    </div>

    Check for typos, wrong case (camelCase vs snake_case), or accidentally referencing a different variable.

    3. The Toggle Handler

    Is the button actually changing the state?

    <button @click="showAdvanced = !showAdvanced">
      Advanced Options
      <i :class="showAdvanced ? 'fa-angle-up' : 'fa-angle-down'"></i>
    </button>

    Use Vue DevTools to watch the state change in real-time. If the state toggles but the UI doesn’t update, you might have a reactivity issue.

    4. Parent-Child State Sharing

    If your toggle button is in one component and the panel is in another, are they sharing state correctly?

    Bad: Each component has its own local copy of showAdvanced

    Good: Use props down, $emit up, or shared state (Vuex/Pinia)

    Full Working Example

    <template>
      <div>
        <button @click="showAdvanced = !showAdvanced" class="btn btn-secondary">
          {{ showAdvanced ? 'Hide' : 'Show' }} Advanced Options
          <i :class="showAdvanced ? 'fa-angle-up' : 'fa-angle-down'"></i>
        </button>
        
        <transition name="fade">
          <div v-show="showAdvanced" class="advanced-panel mt-3">
            <!-- Your advanced options here -->
            <div class="form-group">
              <label>Advanced Setting 1</label>
              <input type="text" class="form-control">
            </div>
            <div class="form-group">
              <label>Advanced Setting 2</label>
              <input type="text" class="form-control">
            </div>
          </div>
        </transition>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          showAdvanced: false
        }
      }
    }
    </script>
    
    <style scoped>
    .fade-enter-active, .fade-leave-active {
      transition: opacity 0.3s;
    }
    .fade-enter, .fade-leave-to {
      opacity: 0;
    }
    </style>

    Why This Pattern Works

    • Single source of truth: One state variable controls everything
    • Clear naming: showAdvanced is unambiguous
    • Tight coupling: Button and panel reference the same variable
    • Visual feedback: Icon changes to reflect state

    Pro Tip: Use Vue DevTools

    Install the Vue DevTools browser extension and watch your component’s state in real-time. When you click the button, you should see showAdvanced flip from false to true. If it doesn’t, your click handler isn’t working. If it does but the UI doesn’t update, you have a reactivity problem.

    Next time your Vue show/hide logic breaks, work through this checklist. Nine times out of ten, it’s a variable name mismatch or a missing reactivity binding.

  • Debugging 403 API Errors: When Authorization Headers Go Wrong

    Your Laravel app has been calling a third-party API for months. Suddenly: 403 Forbidden.

    Error message: “Invalid key=value pair (missing equal-sign) in Authorization header”.

    Nothing changed on your side. What gives?

    The Problem

    403 errors are often subtle formatting issues, not permission problems.

    APIs expect Authorization headers in exact formats. One missing space, one wrong prefix, and you’re locked out.

    Debugging Steps

    Step 1: Log the Actual Header Being Sent

    Don’t assume your code is building it correctly. Log it.

    use Illuminate\Support\Facades\Http;
    use Illuminate\Support\Facades\Log;
    
    $token = config('services.external_api.token');
    
    Http::withHeaders([
            'Authorization' => 'Bearer ' . $token,
        ])
        ->beforeSending(function ($request) {
            Log::info('API Request Headers', [
                'authorization' => $request->header('Authorization'),
            ]);
        })
        ->post($apiUrl, $data);
    

    Step 2: Compare with API Docs

    Check for recent updates. APIs change auth schemes without warning (or bury it in a changelog).

    Step 3: Verify Format

    Is it Bearer? Basic? Custom scheme?

    Step 4: Check for Hidden Characters

    Extra whitespace, newlines, URL-encoding.

    Common Causes

    1. Missing ‘Bearer’ Prefix

    // ❌ Wrong
    'Authorization' => $token
    
    // ✅ Right
    'Authorization' => 'Bearer ' . $token
    

    2. Concatenation Without Space

    // ❌ Wrong (missing space after Bearer)
    'Authorization' => 'Bearer' . $token
    
    // ✅ Right
    'Authorization' => 'Bearer ' . $token
    

    3. Double-Encoding or URL-Encoding

    // ❌ Wrong (token got URL-encoded somehow)
    Authorization: Bearer abc%20123
    
    // ✅ Right
    Authorization: Bearer abc123
    

    4. API Changed Auth Scheme

    Old format (deprecated):

    Authorization: ApiKey abc123
    

    New format (required):

    Authorization: Bearer abc123
    

    Solution: Check API changelog, update to new format.

    Real-World Example

    An API I integrated with suddenly started rejecting requests. Logs showed:

    Sent: "Authorization: Bearer abc123xyz"
    Expected: "Authorization: Bearer abc123xyz" (identical!)
    

    Turned out: API updated validation to reject trailing whitespace. My token had \n at the end from .env.

    Fix:

    $token = trim(config('services.external_api.token'));
    

    Debugging Checklist

    1. Log the exact header string being sent
    2. Compare byte-by-byte with API docs
    3. Check for extra whitespace (leading/trailing)
    4. Verify prefix/scheme matches API requirements
    5. Test with curl to isolate Laravel vs API issue

    Testing with curl

    curl -X POST https://api.example.com/endpoint \
      -H "Authorization: Bearer YOUR_TOKEN_HERE" \
      -H "Content-Type: application/json" \
      -d '{"test": true}' \
      -v
    

    If curl works but Laravel doesn’t, the issue is in your header construction.

    The Takeaway

    403 errors ≠ permission denied. Often it’s formatting.

    • Log the actual header, don’t assume
    • Compare byte-by-byte with API docs
    • Check for hidden characters (whitespace, newlines)
    • Test with curl to isolate the issue

    One missing space can cost you hours of debugging. Log first, guess later.

  • Vue.js Gotcha: Single-Option Dropdowns Don’t Trigger v-model Updates

    You’ve built a beautiful form. User selects “Project Manager” from a dropdown. Form submits. Backend throws: role_id is required.

    What happened? The dropdown had only one option. Vue’s v-model never fired.

    The Problem

    Vue’s v-model on <select> elements only updates when a @change event fires.

    Single-option dropdowns never fire @change because the user can’t change anything.

    What the User Sees

    <select v-model="form.role_id">
      <option value="3">Project Manager</option>
    </select>
    

    UI shows: “Project Manager” (looks selected).

    Reality: form.role_id = null.

    Why It Breaks

    1. Component initializes with form.role_id = null
    2. Dropdown renders with one option
    3. Browser displays that option as “selected” (visual default)
    4. No user interaction = no @change event
    5. v-model never updates
    6. Form submits null

    The Solutions

    Option 1: Auto-Select in Lifecycle Hook (Recommended)

    mounted() {
      // Auto-select if only one option
      if (this.roleOptions.length === 1) {
        this.form.role_id = this.roleOptions[0].id;
      }
    }
    

    Pros: Clean, explicit, works with any UI library.

    Cons: Needs lifecycle hook in every component with single-option dropdowns.

    Option 2: Backend Defensive Handling

    public function assignRole(Request $request)
    {
        // Fallback to default if frontend didn't send value
        $roleId = $request->input('role_id') 
            ?? Role::getDefaultForContext($request->input('department_id'));
        
        // Validate the final value
        $request->merge(['role_id' => $roleId]);
        $validated = $request->validate([
            'role_id' => 'required|exists:roles,id',
        ]);
        
        // ... proceed with assignment
    }
    

    Pros: Fails gracefully, works even if frontend changes.

    Cons: Backend needs context to know which default to use.

    Option 3: Disable Dropdown When Single Option

    <select 
      v-model="form.role_id" 
      :disabled="roleOptions.length === 1"
    >
      <option 
        v-for="role in roleOptions" 
        :key="role.id" 
        :value="role.id"
      >
        {{ role.name }}
      </option>
    </select>
    

    Pros: Visual clarity that choice is locked.

    Cons: Still need lifecycle hook or backend fallback.

    Recommended Approach: Defense in Depth

    Combine Option 1 + Option 2:

    1. Frontend: Auto-select in mounted()
    2. Backend: Defensive null handling with context-aware defaults

    This way, if the frontend breaks (JS error, race condition, browser quirk), the backend still handles it gracefully.

    The Lesson

    Never trust frontend validation alone. Single-option dropdowns are a UI/UX trap—they look selected but aren’t.

    Always validate and default on the backend. Your future self (and your error logs) will thank you.

  • Eloquent Aggregates in HAVING Clauses for Complex Partner Filtering

    When building reports with complex partner relationship logic, you can use selectRaw() with aggregate functions combined with havingRaw() to filter results based on multiple relationship conditions.

    Instead of loading all relationships into memory and filtering in PHP, let the database do the heavy lifting by calculating relationship counts in your SELECT and filtering in HAVING.

    This is especially powerful when you need to check:

    • “Is this exclusive TO this partner?” (count exclusive = 1)
    • “Is this excluded FROM this partner?” (count excluded < 1)
    • “Is this exclusive to ANYONE?” (count exclusive for others > 0)
    // Count partner relationships at different levels
    $query->selectRaw(
        'count(if(partners.excluded = 0 AND partners.org_id = ?, 1, null)) as is_exclusive_for_me',
        [$currentOrgId]
    )
    ->selectRaw(
        'count(if(partners.excluded = 1 AND partners.org_id = ?, 1, null)) as is_excluded_from_me',
        [$currentOrgId]
    )
    ->selectRaw(
        'count(if(partners.excluded = 0, 1, null)) as exclusive_for_anyone'
    )
    ->havingRaw(
        '(is_exclusive_for_me >= 1 OR (is_excluded_from_me < 1 AND exclusive_for_anyone < 1))'
    );
  • Content Whitelist Pattern: Handle Empty Lists with Explicit Intent

    When implementing content whitelists with opt-in flags, always handle the empty whitelist case explicitly. An empty whitelist combined with a “whitelist required” flag should return NO results, not bypass the filter.

    This pattern prevents accidental data leakage when a partner has whitelist requirements but hasn’t configured items yet. Match your internal reports to your API behavior – if the API returns nothing, the report should too.

    if ($partner->getContentWhitelist()->isEmpty()) {
        if ($partner->requiresWhitelist()) {
            // Empty whitelist + requirement = return nothing
            return $query->whereIn('products.id', []);
        }
        // No requirement, skip filtering
        return $query;
    }
    
    // Has whitelist items, apply filter
    return $query->whereIn(
        'products.id',
        $partner->getContentWhitelist()->pluck('product_id')->toArray()
    );
  • Prevent Sentry Noise by Normalizing External API Errors

    When integrating with third-party APIs that return dynamic error references, you can end up with dozens of fragmented Sentry issues for the same underlying problem. The fix: catch the exception, parse the actual error code, and rethrow using your own exception type.

    The Problem: External APIs often include unique request IDs or timestamps in error messages. Sentry treats each variation as a separate issue, creating noise.

    The Solution: Extract the stable error code from the response body and use it to decide how to handle the exception.

    This groups all occurrences under a single Sentry issue and lets you apply domain-specific handling (like stopping retries for permanently unavailable resources).

    // Before: Each API error creates a separate Sentry issue
    $product = $apiClient->getProduct($id);
    
    // After: Parse error code and rethrow normalized exception
    try {
        $product = $apiClient->getProduct($id);
    } catch (ApiException $e) {
        $errorCode = json_decode(
            (string) $e->getPrevious()?->getResponse()->getBody(),
            true
        )['error'] ?? null;
    
        if (in_array($errorCode, ['PRODUCT_EXPIRED', 'PRODUCT_DISABLED'])) {
            // Throw domain exception that stops retries
            throw new InvalidResourceException(
                resourceId: $id,
                previous: $e
            );
        }
    
        throw $e; // Re-throw other errors as-is
    }