Category: PHP

  • Stop Swallowing Exceptions in PHP

    Stop Swallowing Exceptions in PHP

    You inherit a codebase. Every API call wrapped in try-catch. Every exception swallowed. Every error returns an empty collection.

    And nobody knows when things break.

    The Anti-Pattern

    Here’s what I found in a legacy integration:

    public function getProducts(): Collection
    {
        try {
            $response = Http::get($this->apiUrl . '/products');
            return collect($response->json('data'));
        } catch (Exception $e) {
            // "Handle" the error by pretending it didn't happen
            return collect([]);
        }
    }

    What happens when the API is down? Nothing. The method returns empty. The caller thinks there are no products. The error disappears into the void.

    Weeks later, you’re debugging why users see blank pages. The real error? A 500 from the API three weeks ago that nobody noticed because the logs were clean.

    Let It Fail

    The fix is brutal: delete the try-catch.

    public function getProducts(): Collection
    {
        $response = Http::get($this->apiUrl . '/products');
        return collect($response->json('data'));
    }

    Now when the API fails, the exception bubbles up. Your error tracking (Sentry, Bugsnag, whatever) catches it. You get alerted. You fix it.

    But What About Graceful Degradation?

    If you genuinely want to fail gracefully, make it explicit:

    public function getProducts(): Collection
    {
        try {
            $response = Http::timeout(5)->get($this->apiUrl . '/products');
            return collect($response->json('data'));
        } catch (RequestException $e) {
            // Log it so you know it happened
            Log::warning('Product API failed', [
                'error' => $e->getMessage(),
                'url' => $this->apiUrl
            ]);
            
            // Return cached/stale data as fallback
            return Cache::get('products.fallback', collect([]));
        }
    }

    Now you’re:

    • Logging the failure (visibility)
    • Using a fallback strategy (resilience)
    • Not silently lying to callers (honesty)

    When to Catch

    Catch exceptions when you have a recovery strategy:

    • Retry logic: API hiccup? Try again.
    • Fallback data: Cache, defaults, partial results.
    • User-facing context: Transform technical errors into user messages.

    If you’re just catching to return empty, you’re hiding problems.

    Laravel’s Global Exception Handler

    Laravel already has a system for this. Exceptions bubble to App\Exceptions\Handler, where you can:

    // app/Exceptions/Handler.php
    public function register()
    {
        $this->reportable(function (RequestException $e) {
            // Log to Sentry/Slack/etc
        });
        
        $this->renderable(function (RequestException $e) {
            return response()->json([
                'error' => 'External service unavailable'
            ], 503);
        });
    }

    Now all API failures follow the same pattern. No more scattered try-catch blocks pretending everything’s fine.

    The Takeaway

    Empty catch blocks are lies. If you can’t handle the error meaningfully, let it bubble. Your future self — the one debugging at 2 AM — will thank you.

  • Before/After API Testing: Compare Bytes, Not Just Objects

    Before/After API Testing: Compare Bytes, Not Just Objects

    When refactoring code that talks to external APIs, how do you know you didn’t break something subtle?

    Compare both the parsed response AND the raw wire format:

    // Test old implementation
    $oldClient = new OldApiClient($config);
    $oldParsed = $oldClient->fetchData($params);
    $oldRaw = $oldClient->getLastRawResponse();
    
    // Test new implementation  
    $newClient = new NewApiClient($config);
    $newParsed = $newClient->fetchData($params);
    $newRaw = $newClient->getLastRawResponse();
    
    // Compare both
    assert($oldParsed == $newParsed);  // Functional behavior
    assert($oldRaw === $newRaw);       // Wire-level compatibility

    Why both? Because:

    • Parsed objects verify functional correctness
    • Raw responses catch encoding issues, header differences, whitespace handling
    • Some APIs are picky about request formatting even if the parsed result looks identical

    This approach caught cases where new code was adding HTTP headers the old code didn’t send, and another where namespace handling differed slightly. Both “worked” but matching exact wire format means safer deployment.

    When refactoring integrations, test the bytes on the wire, not just the objects in memory.

  • Keep DTOs Thin — Move Logic to Services

    Keep DTOs Thin — Move Logic to Services

    When building API integrations, it’s tempting to put helper methods on your DTOs. I learned this creates problems.

    Started with “fat” DTOs:

    class Product {
        public function getUniqueVariants() { ... }
        public function isVariantAvailable($type) { ... }
        public function getDisplayType() { ... }
    }
    

    Seemed convenient — call $product->getUniqueVariants() anywhere. But issues emerged:

    1. Serialization problems: DTOs with methods are harder to cache/serialize
    2. Testing complexity: need to mock entire DTO structures just to test one method
    3. Coupling: business logic tied to the external API’s data structure

    The fix — move all logic to a service class:

    class ApiClient {
        public function getUniqueVariants(Product $product) { ... }
        public function isVariantAvailable(Product $product, string $type) { ... }
    }
    

    Now DTOs are pure data containers — just public properties or readonly classes. Business logic lives in services where it belongs.

    Feels less convenient at first (passing DTOs as arguments), but far more maintainable. DTOs stay simple and serializable. Services become testable in isolation.

  • Auto-Generate DTOs from JSON API Responses

    Auto-Generate DTOs from JSON API Responses

    Working with third-party APIs means writing a lot of DTOs. So I built a quick code generator that turns JSON fixtures into typed PHP classes.

    The workflow:

    1. Capture fixtures: add HTTP middleware to dump API responses to JSON files
    2. Analyze schema: scan fixtures, merge similar structures, infer types
    3. Generate DTOs: output properly typed classes with serializer annotations
    php artisan generate:dtos \
      --output=app/Services/SDK/ThirdParty \
      --namespace="App\\Services\\SDK\\ThirdParty"
    

    The generator handles:

    • Type inference from JSON values (string/int/float/bool/null)
    • Nullability detection: if a field is missing in any fixture, it’s nullable
    • Nested objects: creates separate classes for complex types
    • Array types: uses Str::singular() to name item classes (products → Product)

    This saved hours of manual DTO writing. The key insight: API responses are the source of truth. Let the actual data structure define your types, not the other way around.

  • When Old Code Fixes Are Actually Bugs

    When Old Code Fixes Are Actually Bugs

    Sometimes the best fix is deleting old fixes.

    While consolidating API client code, I found this “normalization” logic in production:

    $normalized = str_replace(
        ['SOAP-ENV:', 'xmlns:SOAP-ENV', 'xmlns:ns1'],
        ['soapenv:', 'xmlns:soapenv', 'xmlns'],
        $request
    );

    It looked intentional. Maybe the API was picky about namespace prefixes? Nope. It was breaking everything.

    Removing the normalization fixed server errors that had been happening silently. The API worked fine with standard SOAP envelopes — the str_replace was stripping namespace declarations and causing “undeclared prefix” errors on the server side.

    This kind of code survives because: (1) it was probably copy-pasted from a StackOverflow answer, (2) maybe it worked once on a different API, (3) nobody questioned it because it was already there.

    When refactoring, actually test the old code path independently. Sometimes those “necessary” workarounds are just ancient bugs in disguise.

  • SoapClient Timeouts Don’t Work the Way You Think

    SoapClient Timeouts Don’t Work the Way You Think

    You’d think adding a timeout to PHP’s SoapClient would be straightforward. Maybe a constructor option like 'timeout' => 30?

    Nope. Welcome to PHP SOAP hell.

    The only way to set a request timeout on native SoapClient is ini_set():

    ini_set('default_socket_timeout', 30);
    $client = new SoapClient($wsdl, $options);

    The connection_timeout constructor option? That’s only for the initial TCP handshake, not the actual SOAP call. It won’t save you from slow API responses.

    But there’s a cleaner approach — extend SoapClient and override __doRequest() to use Guzzle:

    class GuzzleSoapClient extends SoapClient
    {
        private GuzzleClient $guzzle;
    
        public function __construct($wsdl, array $options = [])
        {
            parent::__construct($wsdl, $options);
            $this->guzzle = new GuzzleClient([
                'timeout' => $options['timeout'] ?? 30,
            ]);
        }
    
        public function __doRequest(
            $request, $location, $action, $version, $oneWay = 0
        ): ?string {
            $response = $this->guzzle->post($location, [
                'body' => $request,
                'headers' => ['SOAPAction' => $action],
            ]);
            return (string)$response->getBody();
        }
    }

    Drop-in replacement. Actual timeout support. No ini_set() hacks.

  • Use Named Parameters for Boolean Flags

    Use Named Parameters for Boolean Flags

    Quick quiz: what does processOrder($order, true, false, true, false, false) do?

    You have no idea. Neither do I. And neither will you in three months when you come back to debug this code.

    I used to think this was just “how PHP worked” — positional parameters, take it or leave it. Then PHP 8.0 dropped named parameters, and suddenly that cryptic boolean soup became self-documenting code.

    Here’s the before and after:

    // Before: positional boolean hell
    function processOrder(
        Order $order,
        bool $validateStock = true,
        bool $sendEmail = false,
        bool $applyDiscount = true,
        bool $updateInventory = false,
        bool $logActivity = false
    ) {
        // ...
    }
    
    // What does this even mean?
    processOrder($order, true, false, true, false, false);
    
    // After: PHP 8 named parameters
    processOrder(
        order: $order,
        validateStock: true,
        applyDiscount: true
    );
    
    // Or when you need to flip a flag deep in the parameter list:
    processOrder(
        order: $order,
        sendEmail: true,
        logActivity: true
    );

    The beauty of named parameters is that you skip the defaults you don’t need. No more passing null, null, null, true just to reach the parameter you actually want to change.

    This isn’t just about readability (though that’s huge). It’s about maintenance. When you add a new optional parameter, existing calls don’t break. When you reorder parameters (carefully!), named calls stay stable.

    Rule of thumb: if you have more than two boolean parameters, or any boolean parameter after the first argument, use named parameters at the call site. Your code reviewers will love you.

    PHP 8 is six years old now. If you’re not using named parameters yet, you’re missing out on one of the best DX improvements PHP has ever shipped.

  • Nullable Typed Properties: The PHP Gotcha That Bites During API Deserialization

    Nullable Typed Properties: The PHP Gotcha That Bites During API Deserialization

    Here’s a PHP gotcha that’s bitten me more than once when working with typed properties and external data sources like API responses or deserialized objects.

    In PHP 7.4+, when you declare a typed property like this:

    class ApiResponse
    {
        public ResponseData $data;
    }

    You might assume that $data defaults to null if it’s never assigned. It doesn’t. It’s in an uninitialized state — which is different from null. Try to access it and you’ll get:

    TypeError: Typed property ApiResponse::$data must not be accessed before initialization

    This is especially common when deserializing API responses. If the external service returns a malformed payload missing expected fields, your deserializer creates the object but never sets the property. PHP then explodes when you try to read it.

    The fix is straightforward — make the property nullable and give it a default:

    class ApiResponse
    {
        public ?ResponseData $data = null;
    }

    Two things changed: the ? prefix makes the type nullable, and = null provides an explicit default. Both are required — even a ?Type property without = null stays uninitialized.

    Then add a null-safe check where you access it:

    if (!$response->data?->items) {
        // Handle missing data gracefully
        return [];
    }

    The ?-> operator (PHP 8.0+) short-circuits to null instead of throwing an error. Clean and defensive.

    The takeaway: Any typed property that might not get initialized — especially in DTOs, API response objects, or anything populated by external data — should be nullable with an explicit = null default. Don’t assume your data sources will always send complete payloads.

  • Stop Error Tracking Sprawl: Keep Exception Messages Static

    Stop Error Tracking Sprawl: Keep Exception Messages Static

    If you use Sentry (or any error tracking tool) with a Laravel app, you’ve probably noticed this problem: one error creates dozens of separate entries instead of grouping into one.

    The culprit is almost always dynamic data in the exception message.

    The Problem

    // ❌ This creates a NEW error entry for every different order ID
    throw new \RuntimeException(
        "Failed to sync pricing for order {$order->id}: API returned error {$response->error_code}"
    );
    

    Sentry groups issues by their stack trace and exception message. When the message changes with every occurrence — because it includes an ID, a timestamp, or an API reference code — each one becomes its own entry.

    Instead of seeing “Failed to sync pricing — thousands of occurrences” neatly grouped, you get a wall of individual entries. Good luck triaging that.

    The Fix

    Keep exception messages static. Pass the dynamic parts as context instead:

    // ✅ Static message — Sentry groups these together
    throw new SyncFailedException(
        'Failed to sync pricing: API returned error',
        previous: $exception,
        context: [
            'order_id' => $order->id,
            'error_code' => $response->error_code,
            'error_ref' => $response->error_reference,
        ]
    );
    

    The dynamic data still gets captured — you can see it in Sentry’s event detail view. But since the message is identical across occurrences, they all group under one entry.

    Using Sentry’s Context API

    If you can’t change the exception class, use Sentry’s scope to attach context before the exception is captured:

    use Sentry\State\Scope;
    
    \Sentry\configureScope(function (Scope $scope) use ($order, $response) {
        $scope->setContext('sync_details', [
            'order_id' => $order->id,
            'error_code' => $response->error_code,
        ]);
    });
    
    throw new \RuntimeException('Pricing sync failed');
    

    Or even simpler in Laravel, use report() with context in your exception handler:

    try {
        $this->syncPricing($order);
    } catch (ApiException $e) {
        report($e->setContext([
            'order_id' => $order->id,
        ]));
    }
    

    The Rule of Thumb

    If you’re interpolating a variable into an exception message, ask yourself: will this string be different for each occurrence?

    If yes — pull it out. Static messages, dynamic context. Your Sentry dashboard (and your on-call engineer at 3 AM) will thank you.

  • Catch ConnectException Before RequestException in Guzzle

    Catch ConnectException Before RequestException in Guzzle

    When you catch Guzzle exceptions, you’re probably writing something like this:

    use GuzzleHttp\Exception\RequestException;
    
    try {
        $response = $client->request('POST', '/api/orders');
    } catch (RequestException $e) {
        // Handle error
        Log::error('API call failed: ' . $e->getMessage());
    }
    

    This works, but it treats every failure the same way. A connection timeout is fundamentally different from a 400 Bad Request — and your error handling should reflect that.

    Guzzle has a clear exception hierarchy:

    • TransferException (base class)
      • RequestException (server returned a response, but it was an error)
        • ConnectException (couldn’t connect at all — timeouts, DNS failures, refused connections)

    The key insight: ConnectException extends RequestException, so if you catch RequestException first, you’ll catch both. But if you catch ConnectException first, you can handle connection issues differently:

    use GuzzleHttp\Exception\ConnectException;
    use GuzzleHttp\Exception\RequestException;
    
    try {
        $response = $client->request('POST', '/api/orders', [
            'json' => $payload,
        ]);
    } catch (ConnectException $e) {
        // The server is unreachable — retry later, don't log the payload
        throw new ServiceTemporarilyUnavailableException(
            'Could not reach external API: ' . $e->getMessage(),
            $e->getCode(),
            $e
        );
    } catch (RequestException $e) {
        // We got a response, but it was an error (4xx, 5xx)
        $statusCode = $e->getResponse()?->getStatusCode();
        Log::error("API returned {$statusCode}", [
            'body' => $e->getResponse()?->getBody()->getContents(),
        ]);
        throw $e;
    }
    

    Why does this matter? Because the recovery strategy is different:

    • Connection failures are transient — retry with backoff, mark the service as temporarily unavailable, or delete the job from the queue so it doesn’t burn through retry attempts
    • HTTP errors (4xx/5xx) might be permanent — a 422 means your payload is wrong, retrying won’t help

    In queue jobs, this distinction is especially powerful. You can catch ConnectException and throw a domain-specific exception that your job’s failed() method handles differently — maybe releasing the job back to the queue with a delay instead of marking it as permanently failed.

    Next time you’re writing a try/catch around an HTTP call, ask yourself: “Am I handling connection failures differently from response errors?” If not, add that ConnectException catch block. Your future debugging self will thank you.