Author: Daryle De Silva

  • Fixing 504 Gateway Timeout in Docker Development

    The Problem

    You’re running a Laravel app in Docker (nginx + PHP-FPM), and you keep hitting 504 Gateway Timeout errors on pages that work fine in production. Long-running reports, imports, and exports all fail locally but succeed on the live server.

    This is a configuration mismatch: your local Docker setup has default timeouts, but production doesn’t.

    The Root Cause

    Two layers have timeout settings that need to align:

    1. PHP execution limits: How long PHP will run before killing a script
    2. nginx FastCGI timeouts: How long nginx will wait for PHP-FPM to respond

    When either of these times out before your code finishes, you get a 504.

    Check Production Settings First

    SSH into your production server and check what’s actually running:

    php -i | grep -E 'max_execution_time|max_input_time|memory_limit'

    You’ll probably see something like:

    max_execution_time => 0
    max_input_time => -1
    memory_limit => -1

    0 and -1 mean unlimited. Production doesn’t kill long-running scripts. Your local Docker setup probably has defaults like 30s for execution time and 60s for nginx FastCGI timeout.

    Fix 1: PHP Timeout Settings

    Create or update your PHP overrides file:

    ; docker/php/php-overrides.ini
    max_execution_time = 0
    max_input_time = -1
    memory_limit = -1
    upload_max_filesize = 50M
    post_max_size = 50M

    Mount this file in your docker-compose.yml:

    services:
      php:
        image: php:8.2-fpm
        volumes:
          - ./docker/php/php-overrides.ini:/usr/local/etc/php/conf.d/99-overrides.ini
          - ./:/var/www/html

    Fix 2: nginx FastCGI Timeouts

    Update your nginx site config:

    # docker/nginx/site.conf
    server {
        listen 80;
        root /var/www/html/public;
        index index.php;
    
        location ~ \.php$ {
            fastcgi_pass php:9000;
            fastcgi_index index.php;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include fastcgi_params;
    
            # Add these timeout settings
            fastcgi_read_timeout 300s;
            fastcgi_connect_timeout 300s;
            fastcgi_send_timeout 300s;
        }
    }

    Mount this in docker-compose.yml:

    services:
      nginx:
        image: nginx:alpine
        volumes:
          - ./docker/nginx/site.conf:/etc/nginx/conf.d/default.conf
          - ./:/var/www/html
        ports:
          - "8080:80"

    Why These Numbers Matter

    Default Behavior (Broken)

    • PHP max_execution_time: 30s
    • nginx fastcgi_read_timeout: 60s
    • Your report generation: 120s

    Result: PHP kills the script at 30s → nginx waits until 60s → 504 error at 60s

    Production Behavior (Works)

    • PHP max_execution_time: 0 (unlimited)
    • nginx fastcgi_read_timeout: 300s (5 minutes)
    • Your report generation: 120s

    Result: PHP finishes at 120s → nginx receives response → success

    Full docker-compose.yml Example

    version: '3.8'
    
    services:
      nginx:
        image: nginx:alpine
        ports:
          - "8080:80"
        volumes:
          - ./docker/nginx/site.conf:/etc/nginx/conf.d/default.conf
          - ./:/var/www/html
        depends_on:
          - php
    
      php:
        image: php:8.2-fpm
        volumes:
          - ./docker/php/php-overrides.ini:/usr/local/etc/php/conf.d/99-overrides.ini
          - ./:/var/www/html
        environment:
          - DB_HOST=mysql
          - DB_DATABASE=laravel
          - DB_USERNAME=root
          - DB_PASSWORD=secret
    
      mysql:
        image: mysql:8.0
        environment:
          MYSQL_ROOT_PASSWORD: secret
          MYSQL_DATABASE: laravel
        volumes:
          - mysql_data:/var/lib/mysql
    
    volumes:
      mysql_data:

    After Making Changes

    Restart your containers:

    docker-compose down
    docker-compose up -d

    Verify PHP settings took effect:

    docker-compose exec php php -i | grep max_execution_time

    You should see max_execution_time => 0.

    When to Use Unlimited vs Fixed Timeouts

    Development: Unlimited (What We Just Did)

    • Mirrors production behavior
    • Prevents false negatives (things that work in prod fail locally)
    • Easier debugging (long operations don’t timeout mid-execution)

    Production: Consider Limits

    Unlimited timeouts in production can be dangerous:

    • Runaway scripts can hang forever
    • Resource exhaustion under load
    • Harder to detect infinite loops

    If your production has unlimited timeouts and you’re seeing issues, consider:

    max_execution_time = 300  ; 5 minutes
    memory_limit = 512M        ; Generous but not unlimited

    The Takeaway

    When you get 504 errors in Docker that don’t happen in production, check timeout alignment between:

    1. PHP execution limits (php.ini or php-overrides.ini)
    2. nginx FastCGI timeouts (fastcgi_read_timeout)

    Mirror your production settings locally. Don’t debug phantom timeout issues—just make the environments match.

  • Reusable DTO Parse Methods in PHP

    The Problem

    You’re working with DTOs (Data Transfer Objects) that need to be deserialized from JSON or arrays. Every time you need to deserialize, you’re copy-pasting the same serializer setup code:

    $serializer = SerializerBuilder::create()->build();
    $config = $serializer->deserialize($jsonString, ConfigDTO::class, 'json');

    This gets old fast. Let’s fix it.

    The Solution: Static parse() Methods

    Encapsulate the serialization logic inside the DTO itself with a static parse() method:

    class ConfigDTO
    {
        public string $apiKey;
        public int $timeout;
        public bool $enableDebug;
    
        public static function parse(array $data): self
        {
            $serializer = SerializerBuilder::create()->build();
            $jsonString = json_encode($data);
            return $serializer->deserialize($jsonString, self::class, 'json');
        }
    }

    Now instead of:

    $serializer = SerializerBuilder::create()->build();
    $config = $serializer->deserialize(
        json_encode($requestData),
        ConfigDTO::class,
        'json'
    );

    You write:

    $config = ConfigDTO::parse($requestData);

    Why This is Better

    1. Single Source of Truth

    Your DTO knows how to deserialize itself. If you need to change serializer configuration (add normalizers, change naming strategy, etc.), you change it in one place.

    2. Cleaner Call Sites

    Controllers, services, and tests become dramatically cleaner:

    // Before
    public function store(Request $request, SerializerInterface $serializer)
    {
        $config = $serializer->deserialize(
            json_encode($request->all()),
            ConfigDTO::class,
            'json'
        );
        // ...
    }
    
    // After
    public function store(Request $request)
    {
        $config = ConfigDTO::parse($request->all());
        // ...
    }

    3. Easier to Test

    Testing becomes straightforward because the DTO handles its own deserialization:

    public function test_config_parsing()
    {
        $config = ConfigDTO::parse([
            'api_key' => 'test-key',
            'timeout' => 30,
            'enable_debug' => true
        ]);
    
        $this->assertEquals('test-key', $config->apiKey);
        $this->assertEquals(30, $config->timeout);
        $this->assertTrue($config->enableDebug);
    }

    4. Validation in One Place

    You can add validation logic to the parse() method:

    public static function parse(array $data): self
    {
        if (empty($data['api_key'])) {
            throw new InvalidArgumentException('API key is required');
        }
    
        $serializer = SerializerBuilder::create()->build();
        $jsonString = json_encode($data);
        return $serializer->deserialize($jsonString, self::class, 'json');
    }

    Real-World Example: API Response DTO

    class ApiResponseDTO
    {
        public bool $success;
        public string $message;
        public ?array $data;
        public ?int $errorCode;
    
        public static function parse(array $responseData): self
        {
            // Custom validation before deserialization
            if (!isset($responseData['success'])) {
                throw new MalformedResponseException('Missing success field');
            }
    
            $serializer = SerializerBuilder::create()
                ->setPropertyNamingStrategy(
                    new SerializedNameAnnotationStrategy(
                        new CamelCaseNamingStrategy()
                    )
                )
                ->build();
    
            return $serializer->deserialize(
                json_encode($responseData),
                self::class,
                'json'
            );
        }
    }
    
    // Usage in an API client
    $response = $client->get('/api/users');
    $dto = ApiResponseDTO::parse($response->json());
    
    if ($dto->success) {
        return $dto->data;
    }
    
    throw new ApiException($dto->message, $dto->errorCode);
    

    Bonus: Support Multiple Formats

    You can add format-specific parse methods:

    class ConfigDTO
    {
        // ... properties ...
    
        public static function parse(array $data): self
        {
            // Same as before
        }
    
        public static function fromJson(string $json): self
        {
            $serializer = SerializerBuilder::create()->build();
            return $serializer->deserialize($json, self::class, 'json');
        }
    
        public static function fromXml(string $xml): self
        {
            $serializer = SerializerBuilder::create()->build();
            return $serializer->deserialize($xml, self::class, 'xml');
        }
    }

    When NOT to Use This Pattern

    This pattern works great for JMS Serializer or Symfony Serializer. If you’re using Laravel’s native JSON casting or Eloquent models, stick with Laravel’s conventions instead.

    The Takeaway

    Don’t scatter serializer instantiation across your codebase. Put it in the DTO where it belongs. Your future self will thank you when you need to change how deserialization works.

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

  • Strategic Sentry Filtering: Finding Quick Wins in Production Bugs

    When Sentry shows hundreds of unresolved issues, how do you find the bugs worth fixing without drowning in noise?

    The key is strategic filtering—exclude performance and architectural problems, surface defensive coding gaps.

    The Filtering Strategy

    Start with your baseline query:

    • is:unresolved
    • environment:production
    • statsPeriod:14d (last 14 days)

    Then exclude the noise:

    !issue.type:performance_n_plus_one_db_queries
    !issue.type:performance_slow_db_query
    !title:"External API timeout"
    

    Why exclude these?

    • N+1 queries → Need query optimization, not bug fixes
    • Slow queries → Architectural problem (indexing, caching)
    • Third-party errors → External dependency, can’t fix on your end

    What You’re Left With: Quick Wins

    After filtering, you’ll surface bugs that are actually fixable:

    • Defensive coding gaps: Null checks, division by zero, type validation
    • Edge case handling: Missing data, unexpected input formats
    • Race conditions: Duplicate queue entries, concurrency issues

    Example Quick Wins

    // Before: Division by zero crash
    $average = $total / $count;
    
    // After: Defensive guard
    $average = $count > 0 ? $total / $count : 0;
    
    // Before: Type error when null
    $data = json_decode($response->body);
    
    // After: Null coalescing + validation
    $data = json_decode($response->body ?? '{}') ?: [];
    
    // Before: Duplicate queue entries
    ProcessReport::dispatch($reportId);
    
    // After: Idempotency key
    ProcessReport::dispatch($reportId)
        ->onQueue('reports')
        ->withChain([
            new MarkReportProcessed($reportId)
        ]);
    

    The Mindset Shift

    Don’t try to fix everything. Filter out architectural problems (performance, scalability, external APIs). Focus on defensive coding (validation, null safety, edge cases).

    Quick wins = small PRs with immediate impact. Fix 5 crashes in an hour instead of chasing one slow query for a week.

    Your Action Items

    1. Build your exclusion filter template (save it as a bookmark)
    2. Run it weekly to surface new defensive coding gaps
    3. Prioritize by event count + affected users
    4. Create small, focused PRs

    Bonus tip: If an issue keeps recurring after a “fix”, it’s probably architectural. Exclude it and move on.

  • 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
    }
  • Auto-Generating Test Fixtures from Real API Responses with Guzzle Middleware

    Auto-Generating Test Fixtures from Real API Responses with Guzzle Middleware

    When integrating with external APIs, you need test fixtures. Manually crafting them is tedious and error-prone. Here’s a better way: use Guzzle middleware to automatically dump real API responses to files.

    The Problem

    You’re building a service provider for a third-party API. You need:

    • Realistic JSON fixtures for unit tests
    • Coverage of all API endpoints you use
    • Responses that match the actual API structure (not guesswork)

    Manually creating these is painful. The API response structure might have dozens of fields, nested objects, edge cases you haven’t seen yet.

    The Solution: Temporary Dumper Middleware

    Add a Guzzle middleware to your HTTP client that intercepts responses and saves them to disk:

    use GuzzleHttp\HandlerStack;
    use Illuminate\Support\Facades\Storage;
    
    // In your service provider's register/boot method
    $http = $this->app->make(HttpClientFactory::class)->make($logger);
    
    /** @var HandlerStack $handler */
    $handler = $http->getConfig('handler');
    
    $handler->push(function (callable $next) {
        return function ($request, array $options) use ($next) {
            return $next($request, $options)->then(function ($response) use ($request) {
                // Build organized directory structure based on endpoint
                $path = trim($request->getUri()->getPath(), '/');
                $dir = base_path("temp/api-fixtures/{$path}");
    
                if (!is_dir($dir)) {
                    mkdir($dir, 0755, true);
                }
    
                // Filename based on request body (for POST) or 'get' for GET requests
                $body = (string) $request->getBody();
                $filename = $body ? md5($body) : 'get';
    
                // Decode, pretty-print, and save
                $json = json_decode((string) $response->getBody(), true);
                file_put_contents(
                    "{$dir}/{$filename}.json",
                    json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
                );
    
                // IMPORTANT: Rewind the stream so the original code can still read it
                $response->getBody()->rewind();
    
                return $response;
            });
        };
    }, 'fixture_dumper');
    

    How It Works

    Guzzle’s middleware system uses a chain-of-responsibility pattern. This middleware:

    1. Intercepts the response after the HTTP request completes
    2. Extracts the endpoint path from the request URI
    3. Creates an organized directory structure mirroring your API (e.g., temp/api-fixtures/users/profile/)
    4. Names files by request body hash (or ‘get.json’ for GET requests)
    5. Saves pretty-printed JSON to disk
    6. Rewinds the response body so your actual code can still read it

    Example Output

    After running your integration locally, you’ll have a fixture library like:

    temp/api-fixtures/
    ├── products/
    │   ├── get.json
    │   └── abc123.json (POST /products with specific body)
    ├── orders/
    │   ├── get.json
    │   ├── def456.json
    └── users/
        └── profile/
            └── get.json
    

    Each file contains the exact JSON structure returned by the real API.

    Using The Fixtures In Tests

    Copy the generated fixtures to your test directory:

    // tests/Fixtures/ExternalApi/products/get.json
    // tests/Fixtures/ExternalApi/orders/get.json
    
    // In your tests:
    $mockClient = new MockHandler([
        new Response(200, [], file_get_contents(
            __DIR__ . '/../Fixtures/ExternalApi/products/get.json'
        )),
    ]);
    
    $this->app->instance(ApiClient::class, new ApiClient(
        new Client(['handler' => HandlerStack::create($mockClient)])
    ));
    

    When To Remove It

    This is temporary development tooling, not production code. Remove the middleware once you have your fixtures:

    // ❌ Remove before committing
    $handler->push(function (callable $next) { ... }, 'fixture_dumper');
    

    Or make it conditional:

    if (config('app.debug') && config('api.dump_fixtures')) {
        $handler->push($fixtureD umperMiddleware, 'fixture_dumper');
    }
    

    Benefits

    • Realistic test data – Exact structure from the real API
    • Comprehensive coverage – Hit every endpoint once, get perfect fixtures
    • No manual JSON crafting – Let the API do the work
    • Update fixtures easily – Re-run with middleware enabled when API changes
    • Organized by endpoint – Easy to find the fixture you need

    The Takeaway

    Guzzle middleware isn’t just for adding auth headers or logging. It’s a powerful tool for development workflows. Use it to auto-generate test fixtures from real API responses, then remove it before production. Your tests get realistic data, and you save hours of manual JSON wrangling.