Blog

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

  • Laravel’s Hidden Safety Net: What Happens When Cache TTL is Zero

    Laravel’s Hidden Safety Net: What Happens When Cache TTL is Zero

    While working on caching implementation for an API client, I discovered an interesting Laravel behavior that isn’t widely documented: what happens when you try to cache data with a TTL of zero or less?

    The Discovery

    I had code that looked like this:

    $ttl = $this->authSession->getRemainingTtl(); // Could be 0 if expired
    $this->cache->put($cacheKey, $result, $ttl);
    

    My concern was: what if getRemainingTtl() returns 0 because the auth session expired? Would Laravel:

    • Store the data anyway (bad – invalid cache)
    • Throw an exception (annoying but safe)
    • Silently ignore it (confusing)
    • Do something smarter?

    The Answer: Laravel Deletes The Key

    Diving into vendor/laravel/framework/src/Illuminate/Cache/Repository.php, lines 197-220:

    public function put($key, $value, $ttl = null)
    {
        if (is_array($key)) {
            return $this->putMany($key, $value);
        }
    
        if ($ttl === null) {
            return $this->forever($key, $value);
        }
    
        $seconds = $this->getSeconds($ttl);
    
        if ($seconds <= 0) {
            return $this->forget($key);  // ← The magic line
        }
    
        $result = $this->store->put($this->itemKey($key), $value, $seconds);
    
        if ($result) {
            $this->event(new KeyWritten($key, $value, $seconds));
        }
    
        return $result;
    }
    

    When TTL ≤ 0, Laravel calls forget($key) – it actively deletes the cache entry.

    Why This Is Brilliant

    This behavior prevents a class of bugs:

    • If the key doesn’t exist yet → nothing happens (safe, no-op)
    • If the key already exists → it gets deleted (removes stale data)
    • No invalid cache entries with expired TTL ever get stored

    This means you don’t need defensive code like:

    // ❌ Unnecessary guard
    if ($ttl > 0) {
        $this->cache->put($cacheKey, $result, $ttl);
    }
    

    You can safely do:

    // ✅ Laravel handles it gracefully
    $this->cache->put($cacheKey, $result, $this->authSession->getRemainingTtl());
    

    Practical Applications

    This is particularly useful when caching API responses with dynamic TTLs tied to auth tokens:

    public function fetchData(string $endpoint): array
    {
        $cacheKey = "api:{$endpoint}";
    
        $cached = $this->cache->get($cacheKey);
        if ($cached) {
            return $cached;
        }
    
        $this->ensureAuthenticated();
        $data = $this->request('GET', $endpoint);
    
        // Cache expires when auth token expires
        // If token is already expired (TTL = 0), Laravel auto-deletes the key
        $this->cache->put(
            $cacheKey,
            $data,
            $this->authToken->getExpiresIn()
        );
    
        return $data;
    }
    

    The Takeaway

    Laravel’s cache implementation has thoughtful edge-case handling. When you pass TTL ≤ 0 to put(), it doesn’t fail or store invalid data – it actively cleans up by deleting the key. This makes caching with dynamic TTLs safer and requires less defensive code.

    Small framework details like this add up to more robust applications.

  • Hybrid State Management: Database Triggers + Eloquent Casts

    For complex state machines involving multiple boolean flags (like is_canceled, is_on_hold, etc.), using MySQL triggers to compute a single status column is a powerful pattern. This approach prevents data inconsistencies while keeping database queries fast and efficient.

    The Problem: Managing Competing States

    If an order has is_canceled = 1 and is_on_hold = 0, what should the status be? If you compute this logic in PHP every time you query the record, it adds overhead. Storing it redundantly in the database can lead to “drift” where the status column becomes out of sync with the boolean flags.

    The Solution: MySQL Triggers

    By using a database trigger, you can automatically compute the correct status based on the boolean flags whenever a record is inserted or updated. This ensures that your source of truth (the flags) is always reflected in your queryable column (the status).

    Implementation

    // Migration
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->boolean('is_canceled')->default(false);
        $table->boolean('is_on_hold')->default(false);
        $table->boolean('is_archived')->default(false);
        $table->string('status')->default('active'); // Auto-computed column
        $table->datetime('status_changed_at')->nullable();
        $table->timestamps();
    });
    
    // Create trigger in migration
    DB::unprepared("
        CREATE TRIGGER orders_status_update BEFORE UPDATE ON orders
        FOR EACH ROW
        BEGIN
            DECLARE status_val VARCHAR(50);
            
            SET status_val = CASE
                WHEN NEW.is_canceled = 1 THEN 'canceled'
                WHEN NEW.is_on_hold = 1 THEN 'on_hold'
                WHEN NEW.is_archived = 1 THEN 'archived'
                ELSE 'active'
            END;
            
            IF NEW.status != status_val THEN
                SET NEW.status = status_val;
                SET NEW.status_changed_at = NOW();
            END IF;
        END
    ");
    

    Leveraging in Laravel

    Your business logic can continue using simple, expressive boolean flags while remaining confident that the queryable status is always correct.

    class Order extends Model
    {
        protected $casts = [
            'status' => OrderStatus::class, // Backed Enum
            'is_canceled' => 'boolean',
            'is_on_hold' => 'boolean',
        ];
        
        public function cancel(): void
        {
            $this->is_canceled = true;
            $this->save();
            // The MySQL trigger automatically sets status='canceled'
            // and status_changed_at=NOW()
        }
    }
    

    This hybrid approach combines the best of both worlds: highly expressive business logic in PHP and consistent, high-performance querying at the database layer.

  • Type-Safe Status Management with PHP 8.1+ Backed Enums

    PHP 8.1’s backed enums are perfect for managing database statuses with compile-time safety. Unlike magic strings, enums provide IDE autocomplete, prevent typos, and centralize status logic.

    Why It Matters

    In production applications, hardcoded strings for statuses like 'pending' or 'in_progress' are prone to typos and difficult to refactor. Backed enums solve this by providing a single source of truth for all possible states.

    Implementation

    enum OrderStatus: string
    {
        case PENDING = 'pending';
        case PROCESSING = 'processing';
        case COMPLETED = 'completed';
        case CANCELED = 'canceled';
        case ON_HOLD = 'on_hold';
        
        public function getLabel(): string
        {
            return match($this) {
                self::PENDING => 'Awaiting Review',
                self::PROCESSING => 'In Progress',
                self::COMPLETED => 'Successfully Completed',
                self::CANCELED => 'Order Canceled',
                self::ON_HOLD => 'Paused',
            };
        }
        
        public static function toArray(): array
        {
            return collect(self::cases())
                ->mapWithKeys(fn($status) => [
                    $status->value => $status->getLabel()
                ])
                ->toArray();
        }
        
        public function canTransitionTo(self $newStatus): bool
        {
            return match($this) {
                self::PENDING => in_array($newStatus, [self::PROCESSING, self::CANCELED, self::ON_HOLD]),
                self::PROCESSING => in_array($newStatus, [self::COMPLETED, self::CANCELED]),
                self::ON_HOLD => in_array($newStatus, [self::PROCESSING, self::CANCELED]),
                default => false,
            };
        }
    }
    

    Integrating with Eloquent

    Laravel makes it incredibly easy to use enums by casting the database value directly to the enum class.

    class Order extends Model
    {
        protected $casts = [
            'status' => OrderStatus::class,
        ];
    }
    
    // Usage
    $order->status = OrderStatus::COMPLETED;  // Type-safe!
    $order->status->getLabel();  // "Successfully Completed"
    OrderStatus::toArray();  // Perfect for