Category: PHP

  • Capture Final URLs After Redirects with Guzzle TransferStats

    Need to know the final URL after following redirects? Don’t parse the response—use Guzzle’s TransferStats callback to capture the effective URI automatically.

    The Use Case

    You’re processing shortened URLs (like bit.ly links) and need to extract the final slug or canonical URL after all redirects resolve.

    The Solution

    use GuzzleHttp\TransferStats;
    use Illuminate\Support\Facades\Http;
    
    $effectiveUrl = null;
    
    Http::withOptions([
        'on_stats' => function (TransferStats $stats) use (&$effectiveUrl) {
            $effectiveUrl = (string) $stats->getEffectiveUri();
        },
    ])->head($shortenedUrl);
    
    // $effectiveUrl is now 'https://example.com/articles/full-guide'
    $slug = basename(parse_url($effectiveUrl, PHP_URL_PATH));
    // $slug = 'full-guide'

    How It Works

    1. on_stats is a Guzzle option that registers a callback
    2. The callback runs after the request completes
    3. TransferStats contains metadata about the request, including the final URI
    4. getEffectiveUri() returns the URL after all redirects

    Why HEAD Instead of GET?

    Using head() instead of get() makes this efficient—you only need the final destination, not the response body. The server follows redirects and returns headers, but skips sending the full content.

    Result: Fast, bandwidth-efficient redirect resolution.

    What Else Is in TransferStats?

    Http::withOptions([
        'on_stats' => function (TransferStats $stats) {
            dump([
                'effective_uri' => $stats->getEffectiveUri(),
                'transfer_time' => $stats->getTransferTime(),  // seconds
                'redirect_count' => $stats->getRedirectCount(),
            ]);
        },
    ])->head($url);

    You can also access:

    • getHandlerStats() — low-level curl stats (DNS lookup time, connect time, etc.)
    • getRequest() — the original request object
    • getResponse() — the final response object (if available)

    Real-World Applications

    • Canonical URL discovery: Resolve short links to full URLs for storage
    • Slug extraction: Extract clean slugs from user-submitted URLs
    • Redirect chain analysis: Track how many hops it took to reach the final destination
    • Performance monitoring: Log transfer times and redirect counts for external APIs

    Bonus: Caching Redirects

    Since redirect resolution is deterministic, you can cache the effective URL:

    $cacheKey = 'redirect_'.md5($shortenedUrl);
    
    return Cache::remember($cacheKey, now()->addDay(), function () use ($shortenedUrl) {
        $effectiveUrl = null;
        Http::withOptions([
            'on_stats' => fn(TransferStats $stats) => 
                $effectiveUrl = (string) $stats->getEffectiveUri()
        ])->head($shortenedUrl);
        
        return $effectiveUrl;
    });

    Now you only resolve each shortened URL once, even across multiple requests.

  • The Commented-Out Feature Flag: A Silent Bug That Breaks Gradual Rollouts

    Feature flags are supposed to give you control. But a commented-out check can silently break that control across your entire application. Here’s the bug that took us 3 hours to find.

    The Setup

    We had a database column for a feature flag:

    
    // Migration
    Schema::table('products', function (Blueprint $table) {
        $table->boolean('enable_advanced_pricing')->default(false);
    });
    

    And a method on the model to check it:

    
    class Product extends Model
    {
        public function hasAdvancedPricing(): bool
        {
            // return $this->enable_advanced_pricing;
            return true; // TODO: Remove after testing
        }
    }
    

    See the problem?

    The Bug

    That “TODO” never got removed. The actual database check was commented out, and the method always returned true.

    This meant:

    1. The feature flag column in the database was completely ignored
    2. All products behaved as if advanced pricing was enabled
    3. Setting the flag to false in the database had zero effect
    4. We thought we had gradual rollout control – we didn’t

    How We Found It

    We were debugging why pricing wasn’t working correctly for certain products. We checked the database:

    
    SELECT id, name, enable_advanced_pricing 
    FROM products 
    WHERE id = 12345;
    
    -- Result: enable_advanced_pricing = 0
    

    Flag was off. But the code was still running advanced pricing logic. We dumped the method output:

    
    dd($product->hasAdvancedPricing()); // bool(true)
    

    Wait, what? The column says false, but the method returns true?

    That’s when we found the commented-out check.

    The Fix

    Remove the hardcoded return value. Actually read the database:

    
    class Product extends Model
    {
        public function hasAdvancedPricing(): bool
        {
            return (bool) $this->enable_advanced_pricing;
        }
    }
    

    Or use a cast for cleaner syntax:

    
    class Product extends Model
    {
        protected $casts = [
            'enable_advanced_pricing' => 'boolean',
        ];
    
        public function hasAdvancedPricing(): bool
        {
            return $this->enable_advanced_pricing;
        }
    }
    

    The Lesson

    Never hardcode feature flag return values. Not even for testing. If you need to force a value temporarily:

    1. Use environment variables: return config('features.force_advanced_pricing', $this->enable_advanced_pricing);
    2. Use a dedicated testing flag: if (app()->environment('testing')) return true;
    3. Better yet: use a proper feature flag service like Laravel Pennant

    Catching This in Code Review

    Look for these patterns in PRs:

    
    // RED FLAG 1: Hardcoded return
    public function hasFeature(): bool
    {
        return true; // or false
    }
    
    // RED FLAG 2: Commented database check
    public function hasFeature(): bool
    {
        // return $this->feature_enabled;
        return config('features.default');
    }
    
    // RED FLAG 3: TODO with hardcoded value
    public function hasFeature(): bool
    {
        return true; // TODO: Fix this
    }
    

    Testing Feature Flags

    Write tests that verify the flag actually controls behavior:

    
    /** @test */
    public function advanced_pricing_respects_database_flag()
    {
        $product = Product::factory()->create([
            'enable_advanced_pricing' => false
        ]);
        
        $this->assertFalse($product->hasAdvancedPricing());
        
        $product->update(['enable_advanced_pricing' => true]);
        $product->refresh();
        
        $this->assertTrue($product->hasAdvancedPricing());
    }
    

    This test would have caught the commented-out check immediately.

    Real Impact

    This bug meant we couldn’t control the rollout of advanced pricing. All products got the new behavior simultaneously, instead of the gradual rollout we planned. It worked fine in development (where we wanted it on), but broke the production deployment strategy.

    The fix took 2 minutes. Finding it took 3 hours.

  • Context-Rich Exception Handling for Better Debugging

    The Problem: Generic Exceptions Hide the Real Issue

    You’ve probably seen this pattern in your Laravel logs:

    throw new \RuntimeException('Operation failed');

    When this fires in production, you get a Sentry alert with… basically nothing useful. No context about which operation, what data caused it, or why it failed. Just a vague error message scattered across hundreds of similar failures.

    The Solution: Context-Rich Custom Exceptions

    Instead of throwing generic exceptions, create specific exception classes that include detailed context. This groups related errors in your monitoring tools and provides actionable debugging information.

    class UnsupportedFeatureException extends \RuntimeException
    {
        public function __construct(string $feature, array $context = [])
        {
            $message = sprintf(
                'Unsupported feature: %s',
                $feature
            );
            
            parent::__construct($message);
            
            $this->context = array_merge(['feature' => $feature], $context);
        }
        
        public function context(): array
        {
            return $this->context;
        }
    }

    Usage in Your Code

    When you need to throw an exception, pass in all the relevant context:

    // Instead of this:
    if (!$this->supportsFeature($request->feature)) {
        throw new \RuntimeException('Feature not supported');
    }
    
    // Do this:
    if (!$this->supportsFeature($request->feature)) {
        throw new UnsupportedFeatureException($request->feature, [
            'account_id' => $account->id,
            'plan' => $account->plan,
            'requested_at' => now()->toDateTimeString(),
        ]);
    }

    Reporting to Sentry

    Wire up the context in your exception handler so it flows to Sentry:

    // app/Exceptions/Handler.php
    public function report(Throwable $exception)
    {
        if (method_exists($exception, 'context')) {
            \Sentry\configureScope(function (\Sentry\State\Scope $scope) use ($exception) {
                $scope->setContext('exception_details', $exception->context());
            });
        }
        
        parent::report($exception);
    }

    Benefits in Production

    • Grouped in Sentry: All “UnsupportedFeatureException” errors are grouped together, making it easy to see patterns.
    • Actionable context: You see exactly which feature was requested, which account, and what plan they’re on.
    • Faster debugging: No more digging through logs trying to reproduce the issue. The context is right there.
    • Better alerts: You can set up Sentry alerts based on specific exception types instead of vague error messages.

    When to Use This Pattern

    Create custom exceptions for:

    • Business rule violations (unsupported features, invalid states)
    • Integration failures (API timeouts, authentication errors)
    • Resource issues (missing files, exceeded quotas)

    Keep using generic exceptions for truly unexpected errors where you don’t have meaningful context to add.

    Real-World Impact

    After implementing context-rich exceptions in our e-commerce platform, we reduced average debugging time from 30+ minutes to under 5 minutes. Instead of searching logs for order IDs and customer details, everything we needed was in the Sentry alert.

  • Avoid array_combine() for Data Mapping

    Here’s a subtle PHP gotcha that can corrupt your data: array_combine() creates order dependencies that break silently when you refactor.

    The Problem

    When building arrays for CSV exports or API responses, you might be tempted to use array_combine() to zip field names with values:

    const COLUMNS = ['username', 'email_address', 'account_status'];
    
    $row = array_combine(self::COLUMNS, [
        $account->username,
        $account->email_address,
        $account->account_status,
    ]);

    This looks clean, but it’s brittle. If someone reorders COLUMNS later (say, alphabetically), your data silently gets mapped to the wrong fields. Username becomes email, email becomes status—and you won’t notice until it’s in production.

    The Fix

    Use explicit associative arrays instead:

    $row = [
        'username' => $account->username,
        'email_address' => $account->email_address,
        'account_status' => $account->account_status,
    ];

    Now the mapping is self-documenting and order-independent. Refactor COLUMNS all you want—the data stays correct.

    When to Use array_combine()

    It’s fine when you’re zipping two runtime arrays (like database column names with row values). The problem is mixing static constants with procedural values—that creates hidden coupling.

    If you see array_combine(self::FIELDS, [...]) in a code review, flag it. Explicit beats clever every time.

  • Extract Nested Closures into Private Methods for Maintainability

    Laravel's collection fluent interface is powerful, but deeply nested closures quickly become unreadable. Here's when and how to refactor.

    ## The Problem: Triple-Nested Closures

    “`php
    return $dates->map(function($date) use ($variants, $allPricing, $defaults) {
    return new DatePrice(
    $variants->map(function($variant, $key) use ($date, $allPricing, $defaults) {
    $match = $allPricing->filter(function($pricing) use ($date, $variant) {
    return $pricing->date === $date && $pricing->variant === $variant;
    })->first();
    return $match?->price ?? $defaults[$key];
    }),
    $date
    );
    });
    “`

    This is doing too much in one statement:

    1. Filtering pricing by date and variant
    2. Falling back to default if not found
    3. Mapping across all variants
    4. Creating the final structure

    ## The Solution: Extract Inner Logic

    Pull the innermost logic into a private method:

    “`php
    private function findPriceForVariantOnDate(
    Collection $allPricing,
    array $variant,
    string $date,
    ?Price $defaultPrice
    ): ?Price {
    return $allPricing
    ->filter(fn($p) => $p->date === $date && $p->matchesVariant($variant))
    ->first()?->price ?? $defaultPrice;
    }
    “`

    Now the main logic becomes clear:

    “`php
    // Restructure: one DatePrice per date, containing prices for all variants
    return $dates->map(function($date) use ($variants, $allPricing, $defaults) {
    return new DatePrice(
    $variants->mapWithKeys(function($variant, $key) use ($date, $allPricing, $defaults) {
    $price = $this->findPriceForVariantOnDate(
    $allPricing,
    $variant,
    $date,
    $defaults[$key]
    );
    return [$key => $price];
    }),
    $date
    );
    });
    “`

    ## When to Extract

    **Extract when:**

    – 3+ levels of nesting
    – Inner logic is reused elsewhere
    – The closure has complex conditionals
    – You need to unit test the inner logic separately

    **Keep inline when:**

    – 1-2 levels of nesting with simple operations
    – The closure is very short (1-2 lines)
    – Extraction would require passing 5+ parameters

    ## Naming Matters

    The extracted method name should read like documentation:

    – `findPriceForVariantOnDate()` – immediately clear what it does
    – `matchesVariant()` – better than `in_array($variant, $pricing->variants)`
    – `getDefaultPrices()` – better than `$fallbacks`

    Future developers (including you) will thank you.

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

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

  • 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