Category: Laravel

  • Laravel UX: Silent Success, Loud Failures

    Good UX in Laravel isn’t about confirming every action. It’s about staying silent when things go as expected and being loud only when something breaks.

    The Anti-Pattern

    You’ve seen this: user clicks “Delete”, and a success toast pops up: “Success! Item deleted!” But… the user just clicked delete. They expected it to work. Why confirm the obvious?

    public function delete(Request $request, $id)
    {
        $item = Item::findOrFail($id);
        $item->delete();
    
        // Unnecessary confirmation
        session()->flash('success', 'Item deleted successfully!');
    
        return back();
    }

    This creates notification fatigue. Users start ignoring all flash messages because most are just noise.

    The Better Approach

    Only show messages when something unexpected happens:

    public function delete(Request $request, $id)
    {
        $item = Item::findOrFail($id);
    
        try {
            $item->delete();
            // Silent success - user clicked delete, it deleted
            return back();
        } catch (\Exception $e) {
            // Loud failure - unexpected! User needs to know
            session()->flash('error', 'Could not delete item. Please try again.');
            report($e);
            return back();
        }
    }

    When to Show Success Messages

    Success confirmations are useful in these cases:

    • Long-running operations — “Export complete! Download ready.”
    • Background processing — “We’ll email you when import finishes.”
    • Non-obvious outcomes — “Payment scheduled for next Monday.”
    • Multi-step processes — “Step 2 of 3 complete.”

    But for immediate, synchronous actions where the UI already shows the result? Stay silent.

    Real-World Impact

    In one dashboard, removing unnecessary success confirmations for routine actions reduced notification spam by ~70%. Users reported the interface felt “less noisy” and they actually started noticing error messages when they appeared.

    Key Takeaway

    Expected outcomes should be silent. Unexpected outcomes need alerts. Save your flash messages for exceptions and warnings—that’s when users actually need them.

  • Laravel API Integration: Stop Retrying Permanent Failures

    When integrating with external APIs in Laravel, catching generic exceptions and retrying blindly wastes queue resources. Here’s a better approach: parse error responses and throw domain-specific exceptions.

    The Problem

    Your queue job calls an API. Sometimes it returns 500 Internal Server Error (temporary). Sometimes it returns 400 Bad Request - Invalid Resource ID (permanent config error). If you catch both with a generic exception, the queue retries forever even for permanent failures.

    try {
        $data = $this->apiClient->fetchResource($resourceId);
    } catch (ApiException $exception) {
        // Generic catch = queue retries uselessly for config errors
        throw $exception;
    }

    The Solution

    Parse the API error response and throw appropriate exceptions:

    try {
        $data = $this->apiClient->fetchResource($resourceId);
    } catch (ApiException $exception) {
        $errorCode = json_decode(
            (string)$exception->getPrevious()?->getResponse()->getBody(),
            true
        )['error'] ?? null;
    
        if ($errorCode === 'INTERNAL_SERVER_ERROR') {
            // Temporary failure - queue should retry
            throw new TemporarilyUnavailableException(
                'Cannot fetch data',
                500,
                $exception
            );
        }
    
        if ($errorCode === 'INVALID_RESOURCE_ID') {
            // Permanent config error - stop retrying immediately
            throw new InvalidMappingException(
                $this->getResourceMapping($resourceId),
                previous: $exception
            );
        }
    
        // Unknown error - rethrow original
        throw $exception;
    }

    Why This Works

    Different exception types let your queue orchestrator handle them appropriately:

    • TemporarilyUnavailableException — Queue retries with backoff (API might recover)
    • InvalidMappingException — Queue deletes job immediately (config needs manual fix)
    • Generic exceptions — Default retry behavior

    In one real-world case, this pattern stopped ~20,900 useless retry attempts for permanent mapping errors, freeing queue resources immediately.

    Key Takeaway

    Don’t treat all API failures the same. Parse error responses, throw specific exceptions, and let your queue orchestrator make smart retry decisions.

  • Building Dual Vue Version Compatible Components in Legacy Laravel Apps

    When maintaining legacy Laravel dashboards that use Vue 1.x but planning migration to Vue 2.x, you can build forward-compatible components using template literals instead of single-file components.

    The Problem

    Vue 1.x doesn’t support modern single-file component syntax with <template>, <script>, and <style> tags. Direct Vue 2.x components break in Vue 1.x runtimes.

    The Solution

    Export components as JavaScript objects with template literals:

    // Compatible with both Vue 1.x and 2.x
    const DataTableComponent = {
      data() {
        return {
          items: [],
          loading: false
        }
      },
      template: `
        <div class="data-table">
          <div v-if="loading">Loading...</div>
          <table v-else>
            <tr v-for="item in items">
              <td>{{ item.name }}</td>
            </tr>
          </table>
        </div>
      `,
      mounted() {
        this.fetchData()
      },
      methods: {
        fetchData() {
          // API call logic
        }
      }
    }
    
    // Export for global registration
    if (typeof window !== 'undefined') {
      window.DataTableComponent = DataTableComponent
    }

    Integration Pattern

    1. Compile in webpack/mix: resources/assets/js/components.js
    2. Export to window in old dashboard
    3. Register in legacy Vue 1.x app: Vue.component('data-table', window.DataTableComponent)
    4. Use in Blade: <data-table :config="config"></data-table>
    5. Same component works when you upgrade to Vue 2.x later

    Why This Works

    • Template literals are standard ES6 JavaScript: Both Vue versions understand them
    • Plain component object definitions: Vue 1.x and 2.x both support this format
    • Window exports bypass module incompatibilities: No need for different build setups

    Benefits

    You maintain one codebase instead of duplicating components across Vue versions during multi-month migrations. When you finally upgrade to Vue 2.x, these components continue working without any changes.

    Real-World Example

    const ReportBuilderComponent = {
      props: ['initialFilters'],
      data() {
        return {
          filters: this.initialFilters || {},
          results: [],
          loading: false
        }
      },
      template: `
        <div class="report-builder">
          <div class="filters">
            <input v-model="filters.startDate" type="date">
            <input v-model="filters.endDate" type="date">
            <button @click="runReport">Generate</button>
          </div>
          <div v-if="loading" class="loading">Loading...</div>
          <table v-else class="results">
            <tr v-for="row in results">
              <td>{{ row.metric }}</td>
              <td>{{ row.value }}</td>
            </tr>
          </table>
        </div>
      `,
      methods: {
        async runReport() {
          this.loading = true
          const response = await fetch('/api/reports', {
            method: 'POST',
            body: JSON.stringify(this.filters)
          })
          this.results = await response.json()
          this.loading = false
        }
      }
    }
    
    if (typeof window !== 'undefined') {
      window.ReportBuilderComponent = ReportBuilderComponent
    }

    This pattern has saved countless hours during gradual Vue upgrades in legacy Laravel applications.

  • Handling Zero vs Null in API Responses: The != null Pattern

    A common bug in Laravel APIs: numeric fields that can be zero display as empty or dash in the frontend because of JavaScript’s falsy value handling. The fix is simple but often overlooked.

    The Problem

    You have an API that returns configuration with numeric values:

    // Backend returns: {"discount_rate": 0}

    Frontend code using truthy checks:

    // ❌ Displays "—" for zero
    {{ config.discount_rate ? config.discount_rate + '%' : '—' }}
    // Result: "—" (wrong!)

    Why It Fails

    In JavaScript, 0 is falsy, so the ternary condition fails and shows the fallback. But 0 might be a perfectly valid value (like “0% discount”).

    The Fix

    // ✅ Explicit null check
    {{ config.discount_rate != null ? config.discount_rate + '%' : '—' }}
    // Result: "0%" (correct!)

    Backend Considerations

    Make sure your Laravel API distinguishes between null (not set) and zero (explicitly set to 0):

    // ❌ Bad: Converts 0 to null
    public function index()
    {
        return [
            'discount_rate' => $model->discount_rate ?: null
        ];
    }
    
    // ✅ Good: Preserves zero, only null when actually null
    public function index()
    {
        return [
            'discount_rate' => $model->discount_rate
        ];
    }

    Common Scenarios

    • Percentages: 0% is valid (no discount/markup)
    • Counts: 0 items is different from unknown
    • Ratings: 0 stars might be valid vs unrated
    • Prices: $0.00 (free) vs null (price not set)

    Pattern for Multiple Fields

    // Vue/React pattern
    const formatValue = (value) => {
        return value != null ? `${value}%` : '—';
    };
    
    <template>
        <div>Base Rate: {{ formatValue(config.base_rate_markup) }}</div>
        <div>Extra Rate: {{ formatValue(config.extra_rate_markup) }}</div>
    </template>

    Testing Strategy

    Always test these edge cases:

    1. Field is null (not set) → shows fallback
    2. Field is 0 → shows “0” or “0%”
    3. Field is positive number → shows number
    4. Field is negative number (if valid) → shows number

    Database Schema Consideration

    In migrations, be explicit about nullability:

    // If 0 and null have different meanings
    $table->decimal('discount_rate', 5, 2)->nullable();
    // null = not configured, 0 = explicitly set to zero
    
    // If 0 is the default ("no discount")
    $table->decimal('discount_rate', 5, 2)->default(0);
    // Always has a value, never null

    The Rule of Thumb

    • Use value != null when zero is meaningful
    • Use value || default when zero should fallback (rare for numbers)
    • Document the distinction in your API responses

    This small change prevents countless “the UI shows dash instead of zero” bug reports.

  • Enum-Based Validation and Type Casting Pattern

    When working with dynamic configuration or polymorphic data structures, PHP 8.1+ backed enums can do more than just define constants—they can encapsulate validation rules and type casting logic, creating a self-documenting, single-source-of-truth for field definitions.

    The Pattern

    enum ConfigType: string
    {
        case DISCOUNT_RATE = 'discount_rate';
        case MAX_QUANTITY = 'max_quantity';
        case ENABLED = 'enabled';
        
        // Define the expected PHP type for each config
        public function castType(): string
        {
            return match($this) {
                self::DISCOUNT_RATE => 'float',
                self::MAX_QUANTITY => 'int',
                self::ENABLED => 'bool',
            };
        }
        
        // Generate validation rules based on type
        public function validationRules(): array
        {
            return match($this->castType()) {
                'float' => ['numeric', 'min:0', 'max:100'],
                'int' => ['integer', 'min:0'],
                'bool' => ['boolean'],
                default => ['string'],
            };
        }
        
        // Get all enum cases with their cast types
        public static function casts(): array
        {
            return collect(self::cases())
                ->mapWithKeys(fn($case) => [
                    $case->value => $case->castType()
                ])
                ->toArray();
        }
    }

    Using in Models

    class Config extends Model
    {
        protected $casts = [
            'type' => ConfigType::class,
        ];
        
        // Cast the stored string value to the correct type
        public function getValue(): mixed
        {
            return match($this->type->castType()) {
                'float' => (float) $this->value,
                'int' => (int) $this->value,
                'bool' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN),
                default => $this->value,
            };
        }
    }

    Using in Controllers

    public function update(Request $request, $model)
    {
        // Generate validation rules dynamically from enum
        $rules = collect(ConfigType::cases())
            ->mapWithKeys(fn($type) => [
                $type->value => [
                    ...$type->validationRules(),
                    'nullable'
                ]
            ])
            ->toArray();
        
        $validated = $request->validate($rules);
        
        // Save each config with proper type casting
        foreach (ConfigType::cases() as $type) {
            if (array_key_exists($type->value, $validated)) {
                $model->setConfig($type, $validated[$type->value]);
            }
        }
    }

    Why This Works

    • Single Source of Truth: Type definitions, validation, and casting logic live together
    • Self-Documenting: New developers can read the enum to understand all available configuration fields
    • Type-Safe: PHP 8.1+ backed enums with IDE autocomplete
    • Maintainable: Adding a new config type is just one new case
    • Testable: Easy to unit test each enum method independently

    This pattern shines when building admin panels, user preferences, or any polymorphic configuration system where each field has different validation and type requirements.

  • Laravel Webhooks: Complete Side Effects Before Firing

    The Problem: Webhooks Firing with Incomplete Data

    You’ve built a bulk operation tool that processes multiple records—replacing files, updating statuses, generating documents. At the end of the process, you fire a webhook to notify downstream systems. The webhook delivers successfully… but the data is incomplete.

    The tool thinks the job is done. Downstream systems think they have everything. But some operations didn’t actually finish before the webhook fired.

    What Happened

    This pattern emerges when your bulk operation tool handles mixed workflows:

    • Some items are simple file attachments (just attach and done)
    • Other items require generation steps (QR codes, PDFs, consolidated documents)

    The tool was originally built to handle only file attachments. It attaches all the files, then fires the webhook—”from its point of view the flow is complete.”

    But when you start using it for mixed workflows, the generation step gets skipped. The webhook fires too early, and downstream systems receive incomplete records.

    Example: Mixed Document Attachments

    Let’s say you’re building a bulk order processor that handles digital fulfillment:

    // BulkOrderProcessorController.php
    public function process(Request $request)
    {
        $orders = Order::whereIn('uuid', $request->order_ids)->get();
        
        foreach ($orders as $order) {
            // Attach pre-existing PDF files
            foreach ($order->getPreExistingDocuments() as $doc) {
                $order->attachments()->attach($doc->id);
            }
        }
        
        // Send webhook to downstream system
        $this->dispatchWebhook($orders);
        
        return response()->json(['status' => 'complete']);
    }

    This works great when all documents are pre-existing files. But what if some orders need generated documents (e.g., a consolidated invoice PDF or QR codes for ticket verification)?

    The webhook fires after attaching files, but before generating the missing documents. Downstream systems see “Order complete” but some documents are missing.

    The Solution: Complete All Side Effects Before Firing Webhooks

    The fix is simple: don’t send webhooks until every side effect is complete.

    // BulkOrderProcessorController.php
    public function process(Request $request)
    {
        $orders = Order::whereIn('uuid', $request->order_ids)->get();
        
        foreach ($orders as $order) {
            // Step 1: Attach pre-existing files
            foreach ($order->getPreExistingDocuments() as $doc) {
                $order->attachments()->attach($doc->id);
            }
            
            // Step 2: Generate missing documents BEFORE webhook
            app(DocumentGenerator::class)->generateFromOrder($order);
        }
        
        // Step 3: NOW send webhook (all data ready)
        $this->dispatchWebhook($orders);
        
        return response()->json(['status' => 'complete']);
    }

    Key change: Call DocumentGenerator for each order before firing the webhook. This ensures every order has all its attachments—both pre-existing files and generated documents.

    When to Watch Out for This

    This pattern is especially common when:

    • You repurpose a tool for a broader use case than it was originally built for
    • Your workflow has mixed requirements (some simple, some complex)
    • The tool was built iteratively—”it worked fine until we added feature X”
    • Webhooks notify external systems (they can’t easily retry or detect missing data)

    Real-World Indicators

    You might be hitting this issue if:

    • Downstream systems complain about missing data even though your tool reports success
    • Re-running the bulk operation “fixes” the problem (because the second run actually generates the missing items)
    • The tool’s definition of “complete” doesn’t match downstream expectations

    Key Takeaways

    1. Webhooks are promises—don’t send them until you can fulfill every part of the promise
    2. Mixed workflows need mixed handling—check for items that need generation, not just attachment
    3. Test with real-world data—edge cases reveal assumptions about what “complete” means
    4. Idempotency helps—if downstream systems can safely receive duplicate webhooks, recovery is easier

    A webhook saying “job complete” should mean actually complete—not “complete according to the original scope.”

  • Laravel Queue Jobs: Handling External API Timeouts

    The Problem: MaxAttemptsExceededException on External API Calls

    You’ve queued a job that syncs data from an external API. The job processes multiple records in a loop, making HTTP requests for each one. Everything works fine in development, but in production you start seeing this error:

    Illuminate\Queue\MaxAttemptsExceededException: 
    App\Jobs\DataSyncJob has been attempted too many times or run too long. 
    The job may have previously timed out.

    The job hasn’t actually failed—it’s just slow. Laravel thinks it’s taking too long and kills it before it finishes.

    Why This Happens

    When a queue job makes multiple external API calls (especially in a loop over date ranges or collections), three timeout layers can conflict:

    1. HTTP client timeout (default: no limit in Guzzle)
    2. Job timeout (default: 60 seconds in Laravel)
    3. Job retry logic (default: 3 attempts)

    If the external API is slow or your loop has many iterations, the job times out before completing. Laravel marks it as “max attempts exceeded” even though the real issue is timing, not failure.

    The Solution: Set Explicit Timeouts at Every Level

    1. Configure the Job Timeout

    Add a timeout property to your queue job to tell Laravel how long it can run:

    class DataSyncJob implements ShouldQueue
    {
        public int $timeout = 300; // 5 minutes
        public int $tries = 3;
        public int $maxExceptions = 1;
        
        public function __construct(
            protected Report $report,
            protected string $startDate,
            protected string $endDate,
        ) {}
        
        public function handle(ExternalApiService $api): void
        {
            $period = CarbonPeriod::create($this->startDate, $this->endDate);
            
            foreach ($period as $date) {
                $api->fetchData($date->toDateString());
            }
        }
    }

    2. Set HTTP Client Timeouts

    Configure Guzzle (or whatever HTTP client you use) with explicit connect and request timeouts:

    // In your API client class
    protected function makeRequest(string $method, string $url, array $data = [])
    {
        try {
            return $this->http->request($method, $url, [
                'json' => $data,
                'timeout' => 30,        // Total request timeout: 30 seconds
                'connect_timeout' => 5, // Connection timeout: 5 seconds
            ]);
        } catch (RequestException $e) {
            // Handle timeouts gracefully
            throw new ApiException("External API request failed", 0, $e);
        }
    }

    3. Add Redis Rate Limiting (Bonus)

    If your job processes many items and the external API has rate limits, use Laravel’s Redis throttle to avoid hammering their servers:

    use Illuminate\Redis\RedisManager;
    
    public function handle(ExternalApiService $api, RedisManager $redis): void
    {
        $codes = $this->report->getCodes();
        
        foreach ($codes as $code) {
            $redis
                ->throttle('api_sync_' . $api->getName())
                ->allow(10)      // 10 requests
                ->every(60)      // per 60 seconds
                ->then(function () use ($api, $code) {
                    try {
                        $api->fetchData($code);
                    } catch (\Exception $e) {
                        // Log but don't fail the entire job
                        logger()->error("API sync failed for code {$code}", [
                            'exception' => $e->getMessage()
                        ]);
                    }
                }, function () {
                    // Rate limit hit—release job back to queue
                    $this->release($this->attempts());
                });
        }
    }

    When to Use This Pattern

    This approach works well when:

    • Your job makes multiple external API calls (loops, date ranges, batches)
    • The external API can be slow or unreliable
    • You need graceful degradation—one failed request shouldn’t kill the entire job
    • The external API has rate limits

    Key Takeaways

    1. Always set explicit timeout properties on long-running queue jobs
    2. Configure HTTP client timeouts (timeout and connect_timeout)
    3. Use Redis throttling for rate-limited APIs
    4. Catch exceptions inside loops so one failure doesn’t kill the entire job
    5. Monitor Sentry/logs for timeout patterns—they reveal slow external dependencies

    The MaxAttemptsExceededException isn’t always a failure—sometimes it’s just a sign your timeouts need tuning.

  • Subtle Laravel Query Bug: Plucking from the Wrong Table After a Join

    Subtle Laravel Query Bug: Plucking from the Wrong Table After a Join

    Found a sneaky Laravel query bug that silently returns wrong data: plucking from one table when you meant to pluck from another after a join.

    The Bug

    return $this->buildQuery()
        ->select('task_metadata.label')
        ->join('task_metadata', 'tasks.id', '=', 'task_metadata.task_id')
        ->where('task_metadata.is_active', '=', 1)
        ->pluck('task_notes.label')  // ❌ WRONG TABLE!
        ->unique();

    Notice the problem? We’re joining and selecting from task_metadata, but plucking from task_notes. Laravel doesn’t throw an error – it silently returns empty or garbage data because task_notes isn’t even in the query.

    The Fix

    return $this->buildQuery()
        ->distinct()  // Add distinct for one-to-many joins
        ->select('task_metadata.label')
        ->join('task_metadata', 'tasks.id', '=', 'task_metadata.task_id')
        ->where('task_metadata.is_active', '=', 1)
        ->groupBy('task_metadata.label')  // Group to avoid dupes
        ->pluck('task_metadata.label')  // ✅ CORRECT TABLE
        ->unique();

    Why It’s Sneaky

    • No error: Laravel happily runs the query even though the pluck table isn’t in the join
    • Wrong results: You get empty collections or data from a different table with the same column name
    • Hard to spot: The query looks “mostly right” – you have to trace which table you’re actually selecting from

    Bonus: distinct() vs unique()

    When joining one-to-many relationships:

    • ->distinct() = SQL-level deduplication (runs in database)
    • ->unique() = Collection-level deduplication (runs in PHP after fetching)

    Use distinct() or groupBy() to avoid fetching duplicate rows from the database. Then unique() becomes redundant.

    Lesson: When building complex queries with joins, always double-check that your pluck(), select(), and where() clauses reference the correct table. Laravel won’t warn you if you mix them up.

  • Resilient External API Calls: Retry Logic + Circuit Breakers

    When integrating with unreliable external APIs, implement retry logic with exponential backoff and circuit breaker patterns. Laravel’s HTTP client supports retries out of the box. For long-running failures, implement a circuit breaker to stop hitting a dead endpoint and avoid queue buildup. Log full HTTP context (status code, response body, headers) to debug external API issues effectively.

    use Illuminate\Support\Facades\Http;
    use Illuminate\Support\Facades\Log;
    
    // Retry with exponential backoff (100ms, 200ms, 400ms)
    $response = Http::retry(3, 100, throw: false)
        ->timeout(10)
        ->get('https://api.partner.com/data');
    
    if ($response->failed()) {
        Log::error('Partner API failed', [
            'url' => $response->effectiveUri(),
            'status' => $response->status(),
            'body' => $response->body(),
            'headers' => $response->headers(),
        ]);
        
        throw new ExternalServiceException(
            'Partner API error: Failed to fetch data endpoint',
            ['response_status' => $response->status()]
        );
    }
    
    // For circuit breaker pattern, use package:
    // composer require reshadman/laravel-circuit-breaker
  • Centralize Third-Party API Error Handling with Custom Exceptions

    When integrating multiple third-party APIs that serve similar purposes (payment gateways, shipping providers, SMS services), you’ll quickly notice they all handle errors differently. One throws CardException with a specific message, another returns error codes like INSUFFICIENT_FUNDS—same problem, completely different implementations.

    Instead of scattering vendor-specific error handling throughout your Laravel codebase, wrap their exceptions in custom domain exceptions.

    The Problem: Inconsistent Error Handling

    // Payment provider 1 (Stripe)
    try {
        $payment = $stripeClient->charge($amount);
    } catch (\Stripe\Exception\CardException $e) {
        if (str_contains($e->getMessage(), 'insufficient funds')) {
            // Handle insufficient funds
        }
    }
    
    // Payment provider 2 (PayPal)
    try {
        $payment = $paypalClient->charge($amount);
    } catch (\PayPal\Exception\PayPalException $e) {
        if ($e->getCode() === 'INSUFFICIENT_FUNDS') {
            // Handle insufficient funds... differently
        }
    }
    
    // Every integration handles the same error differently

    This approach doesn’t scale. Every time you add a new provider, you need to update error handling logic throughout your application. Testing becomes harder because you’re coupled to vendor exception types.

    The Solution: Domain Exceptions with Adapters

    Create custom exceptions that represent business scenarios (insufficient funds, declined payment, timeout) rather than vendor-specific errors:

    // Define domain exceptions
    namespace App\Exceptions\Payment;
    
    class InsufficientFundsException extends PaymentException
    {
        public function __construct(
            public readonly string $provider,
            public readonly string $transactionId,
            string $message = 'Insufficient funds to complete transaction'
        ) {
            parent::__construct($message);
        }
    }
    
    class PaymentDeclinedException extends PaymentException { /* ... */ }
    class PaymentTimeoutException extends PaymentException { /* ... */ }

    Now wrap vendor exceptions in adapter classes that implement a common interface:

    class StripeAdapter implements PaymentGateway
    {
        public function charge(Order $order): Transaction
        {
            try {
                return $this->client->createCharge([
                    'amount' => $order->total,
                    'currency' => $order->currency,
                ]);
            } catch (\Stripe\Exception\CardException $e) {
                // Map Stripe errors to domain exceptions
                if (str_contains($e->getMessage(), 'insufficient funds')) {
                    throw new InsufficientFundsException(
                        provider: 'stripe',
                        transactionId: $e->getRequestId()
                    );
                }
                
                throw PaymentException::fromVendorException('stripe', $e);
            }
        }
    }
    
    class PayPalAdapter implements PaymentGateway
    {
        public function charge(Order $order): Transaction
        {
            try {
                return $this->client->createPayment([/* ... */]);
            } catch (\PayPal\Exception\PayPalException $e) {
                // Map PayPal errors to same domain exceptions
                if ($e->getCode() === 'INSUFFICIENT_FUNDS') {
                    throw new InsufficientFundsException(
                        provider: 'paypal',
                        transactionId: $e->getCorrelationId()
                    );
                }
                
                throw PaymentException::fromVendorException('paypal', $e);
            }
        }
    }

    Now Handle All Providers Consistently

    class CheckoutService
    {
        public function processPayment(Order $order, PaymentGateway $gateway)
        {
            try {
                $transaction = $gateway->charge($order);
                $order->markAsPaid($transaction);
            } catch (InsufficientFundsException $e) {
                // Same handler for ALL providers
                $this->notifyUser($order->user, 'payment_failed_insufficient_funds');
                $this->logPaymentFailure($e->provider, $e->transactionId);
            } catch (PaymentDeclinedException $e) {
                // ...
            }
        }
    }

    Why This Matters

    • Consistent interface: Your application logic doesn’t care which payment provider is being used
    • Easy to add providers: New integrations just need to implement the PaymentGateway interface and map their errors
    • Easy to switch providers: Change providers without touching your application logic
    • Better testing: Mock domain exceptions instead of vendor-specific ones
    • Provider-specific data: Custom exceptions can carry provider IDs, transaction IDs, and other metadata while presenting a unified interface

    This pattern works for any multi-provider integration: shipping APIs, SMS services, email providers—anything where you need to abstract vendor differences behind a common interface.