Category: Laravel

  • API Transformer Pattern for Backward Compatibility in Laravel

    You’ve refactored your database schema, rewritten your business logic, and modernized your API internals. Great! Now you just need to make sure every client that’s been hitting your API for the past 3 years doesn’t break.

    This is where transformer classes save you.

    The Problem

    Your new data structure looks like this:

    {
      "product_id": 123,
      "pricing": {
        "base_price": 100,
        "currency": "USD",
        "variants": [
          {"type": "adult", "price": 100},
          {"type": "child", "price": 50}
        ]
      },
      "availability": {
        "slots": [...],
        "max_capacity": 20
      }
    }

    But your old API promised this:

    {
      "id": 123,
      "price": 100,
      "adult_price": 100,
      "child_price": 50,
      "currency": "USD",
      "time_slots": [...],
      "capacity": 20
    }

    You can’t just change the API response. Mobile apps from 2022 are still hitting your endpoints. Breaking them isn’t an option.

    The Solution: Transformer Pattern

    Create a dedicated transformer class that maps your new structure to the legacy format:

    namespace App\Transformers;
    
    class LegacyProductTransformer
    {
        public function transform(Product $product): array
        {
            $base = [
                'id' => $product->id,
                'currency' => $product->pricing->currency,
            ];
    
            // Feature flag: new pricing structure
            if ($product->type->hasVariantPricing()) {
                $base['adult_price'] = $product->pricing->variants->firstWhere('type', 'adult')?->price;
                $base['child_price'] = $product->pricing->variants->firstWhere('type', 'child')?->price;
                $base['price'] = $product->pricing->base_price;
            } else {
                // Fallback to old structure
                $base['price'] = $product->price;
                $base['adult_price'] = $product->price;
                $base['child_price'] = $product->price * 0.5;
            }
    
            // Feature flag: new availability structure
            if ($product->type->hasSlotBasedAvailability()) {
                $base['time_slots'] = $product->availability->slots->map(fn($slot) => [
                    'start' => $slot->start_time,
                    'end' => $slot->end_time,
                    'available' => $slot->remaining_capacity > 0
                ])->toArray();
                $base['capacity'] = $product->availability->max_capacity;
            } else {
                // Fallback: no slots
                $base['time_slots'] = [];
                $base['capacity'] = $product->stock_quantity ?? 0;
            }
    
            return $base;
        }
    }

    Then use it in your controller:

    namespace App\Http\Controllers\Api\V1;
    
    use App\Transformers\LegacyProductTransformer;
    
    class ProductController extends Controller
    {
        public function show(Product $product)
        {
            $transformer = new LegacyProductTransformer();
            return response()->json($transformer->transform($product));
        }
    }

    Why This Works

    Backward compatibility without technical debt. Your internal models use the new structure. The transformer handles the messy mapping logic in one place.

    Feature flags control rollout. Check flags on the model (hasVariantPricing(), hasSlotBasedAvailability()) to gradually migrate products to the new structure without breaking old ones.

    No dual data storage. You’re not maintaining two database schemas. The old format is generated on-the-fly from the new data.

    Easy testing. Transformer is a plain PHP class. Write unit tests that assert old API format from new models.

    When NOT to Use This

    If you only have a handful of API consumers and you can coordinate with them, just version your API (/api/v2) and deprecate v1. Transformers add complexity.

    But if you have hundreds of clients, mobile apps in the wild, or partners integrated years ago – transformers let you evolve your system without breaking the world.

    Bonus: Reverse Transformers

    You can also build reverse transformers for POST/PUT requests that accept the old format and convert it to new models:

    class LegacyProductReverseTransformer
    {
        public function fromArray(array $legacy): Product
        {
            $product = new Product();
            $product->pricing = new Pricing([
                'base_price' => $legacy['price'],
                'currency' => $legacy['currency'],
                'variants' => [
                    ['type' => 'adult', 'price' => $legacy['adult_price']],
                    ['type' => 'child', 'price' => $legacy['child_price']],
                ]
            ]);
            return $product;
        }
    }

    Now your API accepts both old and new formats. Clients migrate at their own pace.

  • Artisan Commands with Dry-Run Mode for Safe Production Bulk Operations

    Production data migrations can be terrifying. One wrong command and you’re restoring from backups. Here’s a pattern I use for critical bulk operations: dry-run mode with database transactions.

    The Pattern

    Build your Artisan command with these 5 features:

    1. Dry-run flag that wraps everything in a transaction and rolls back
    2. Optional arguments to override data (for local testing)
    3. Per-step error handling that doesn’t halt execution
    4. Progress bars for long operations
    5. Detailed table output showing success/failure/skipped per record

    Example Command

    namespace App\Console\Commands;
    
    use Illuminate\Console\Command;
    use Illuminate\Support\Facades\DB;
    
    class ProcessBulkInvoices extends Command
    {
        protected $signature = 'invoices:process-bulk {codes?*} {--dry-run}';
        protected $description = 'Process multiple invoices in bulk';
    
        const EXPECTED_STATUS = 'pending';
    
        public function handle()
        {
            $codes = $this->argument('codes') ?: $this->getDefaultCodes();
            $isDryRun = $this->option('dry-run');
    
            if ($isDryRun) {
                DB::beginTransaction();
                $this->warn('πŸ§ͺ DRY-RUN MODE - Changes will be rolled back');
            }
    
            $results = [];
            $bar = $this->output->createProgressBar(count($codes));
    
            foreach ($codes as $code) {
                $result = [
                    'code' => $code,
                    'validate' => '',
                    'process' => '',
                    'notify' => ''
                ];
    
                // Step 1: Validate status
                try {
                    $invoice = Invoice::where('code', $code)->first();
                    
                    if (!$invoice) {
                        $result['validate'] = 'βœ— Not found';
                        $results[] = $result;
                        $bar->advance();
                        continue;
                    }
    
                    if ($invoice->status !== self::EXPECTED_STATUS) {
                        $result['validate'] = "⊘ Status is {$invoice->status}";
                        $results[] = $result;
                        $bar->advance();
                        continue;
                    }
    
                    $result['validate'] = 'βœ“ OK';
                } catch (\Throwable $e) {
                    $result['validate'] = 'βœ— ' . $e->getMessage();
                    $results[] = $result;
                    $bar->advance();
                    continue;
                }
    
                // Step 2: Process invoice
                try {
                    $invoice->markAsProcessed();
                    $invoice->addNote('Bulk processed via command', auth()->id());
                    $result['process'] = 'βœ“ Processed';
                } catch (\Throwable $e) {
                    $result['process'] = 'βœ— ' . $e->getMessage();
                }
    
                // Step 3: Send notification
                try {
                    $invoice->customer->notify(new InvoiceProcessed($invoice));
                    $result['notify'] = 'βœ“ Sent';
                } catch (\Throwable $e) {
                    $result['notify'] = 'βœ— ' . $e->getMessage();
                }
    
                $results[] = $result;
                $bar->advance();
            }
    
            $bar->finish();
            $this->newLine(2);
    
            // Display results table
            $this->table(
                ['Code', 'Validate', 'Process', 'Notify'],
                collect($results)->map(fn($r) => [
                    $r['code'],
                    $r['validate'],
                    $r['process'],
                    $r['notify']
                ])
            );
    
            if ($isDryRun) {
                DB::rollBack();
                $this->warn('πŸ§ͺ All changes rolled back (dry-run mode)');
            } else {
                $this->info('βœ… Bulk operation complete');
            }
    
            return 0;
        }
    
        private function getDefaultCodes(): array
        {
            // Hard-coded list for production, overridable via argument
            return ['INV-001', 'INV-002', 'INV-003'];
        }
    }

    Usage

    # Test locally with custom codes
    php artisan invoices:process-bulk INV-TEST-001 INV-TEST-002 --dry-run
    
    # Dry-run on production data (safe)
    php artisan invoices:process-bulk --dry-run
    
    # Actually run it (scary, but you tested it first)
    php artisan invoices:process-bulk

    Why This Works

    Transactions prevent mistakes. Dry-run mode wraps everything in a transaction and rolls back at the end. You see exactly what would happen, zero risk.

    Per-step errors don’t halt execution. If invoice #42 fails, invoice #43 still runs. You get partial completion instead of catastrophic failure.

    Status validation prevents double-processing. The EXPECTED_STATUS const ensures you only touch records in the correct state. Already processed? Skip it with ⊘.

    Table output is auditable. You get a clear record of exactly what happened to each item. Screenshot it, paste it in Slack, attach it to the ticket.

    Bonus: Argument Override

    The {codes?*} argument lets you override the default list. This is crucial for local testing – you don’t want to hardcode production invoice codes in your dev database. Pass in test codes instead.

    # Local testing with dev database
    php artisan invoices:process-bulk INV-DEV-001 --dry-run

    This pattern has saved me from production disasters more times than I can count. Dry-run first. Always.

  • The Transformer Pattern: Maintaining API Backward Compatibility During Database Refactoring

    Refactoring your database schema but need to keep old API endpoints working? The transformer pattern saved us when we had to evolve our data model without breaking integrations.

    The Problem

    You’ve built a new, better data structure. But you have external clients consuming your API who expect the old format. Breaking changes mean angry developers and broken integrations.

    The naive solution? Keep two codepaths. The smart solution? Use a transformer.

    The Pattern

    
    namespace App\Transformers;
    
    class ProductDataTransformer
    {
        public static function toLegacyFormat(Product $product): array
        {
            // New structure: flexible, normalized
            $newData = [
                'id' => $product->id,
                'variants' => $product->variants->map(function ($variant) {
                    return [
                        'sku' => $variant->sku,
                        'pricing' => $variant->pricingRules->toArray(),
                        'availability' => $variant->availabilitySlots->toArray(),
                    ];
                }),
            ];
    
            // Transform to old structure for backward compatibility
            if ($product->hasFeature('legacy_format')) {
                return self::transformToV1($newData);
            }
    
            return $newData;
        }
    
        private static function transformToV1(array $newData): array
        {
            // Old API expected flat structure
            $legacyData = [
                'product_id' => $newData['id'],
                'prices' => [],
                'slots' => [],
            ];
    
            foreach ($newData['variants'] as $variant) {
                // Flatten pricing rules
                foreach ($variant['pricing'] as $rule) {
                    $legacyData['prices'][] = [
                        'sku' => $variant['sku'],
                        'amount' => $rule['base_price'],
                        'currency' => $rule['currency'],
                    ];
                }
    
                // Flatten availability
                foreach ($variant['availability'] as $slot) {
                    $legacyData['slots'][] = [
                        'sku' => $variant['sku'],
                        'date' => $slot['start_date'],
                        'available' => $slot['quantity'] > 0,
                    ];
                }
            }
    
            return $legacyData;
        }
    }
    

    The Controller

    
    class ProductApiController extends Controller
    {
        public function show(Request $request, Product $product)
        {
            // New clients get new format
            if ($request->wantsJson() && $request->header('API-Version') === 'v2') {
                return response()->json($product->toArray());
            }
    
            // Old clients get transformed legacy format
            return response()->json(
                ProductDataTransformer::toLegacyFormat($product)
            );
        }
    }
    

    Feature Flag Integration

    The transformer checks a feature flag on the model ($product->hasFeature('legacy_format')) to decide which format to return. This lets you:

    1. Migrate products gradually (not all-at-once)
    2. Test the new format with specific products first
    3. Roll back instantly if something breaks
    
    // In Product model
    public function hasFeature(string $feature): bool
    {
        return $this->features->contains('name', $feature);
    }
    
    // Or simpler: database column
    public function hasFeature(string $feature): bool
    {
        return (bool) $this->{"use_{$feature}"};
    }
    

    Why This Works

    The transformer is a translation layer between your modern data model and legacy API contracts. You get:

    • Single source of truth – New data model is the reality, old format is just a view
    • Gradual migration – Feature flags control which products use new vs old format
    • No code duplication – One data model, multiple representations
    • Clear boundaries – Transformation logic is isolated, not scattered across controllers

    When to Use This

    Apply this pattern when:

    • You have external API clients you can’t coordinate with
    • Breaking changes would cause integration failures
    • You’re refactoring database schema incrementally
    • Different clients need different data formats

    Don’t use it for internal refactoring where you control all consumers – just update the code directly.

    Real-World Results

    We migrated 500+ products from a flat pricing structure to a flexible, variant-based model over 3 months. The transformer kept old integrations working while we gradually moved products to the new format. Zero downtime, zero broken integrations.

  • Laravel Artisan Commands: The Dry-Run Pattern for Production Safety

    Building one-off Artisan commands that touch production data? Here’s a battle-tested pattern that’s saved me from disaster more than once.

    The Pattern

    When you need to bulk-process records in production, implement these six safeguards:

    1. Dry-run flag with transactions – Wrap everything in DB::beginTransaction(), then rollback if --dry-run, commit if live
    2. Optional positional arguments – Let devs pass specific IDs for local testing instead of hardcoding production values
    3. Per-step try-catch – Don’t halt on first error; capture and continue so you get full visibility
    4. Progress bars – Use $this->output->progressStart() for UX during long runs
    5. Detailed result tables – Show success/failure/skipped per step per record with symbols (βœ“ βœ— ⊘)
    6. Validation constants – Define expected ‘before’ states as consts and skip records that don’t match

    The Code

    
    namespace App\Console\Commands;
    
    use Illuminate\Console\Command;
    use Illuminate\Support\Facades\DB;
    
    class ProcessOrdersBulk extends Command
    {
        protected $signature = 'orders:process-bulk {ids?*} {--dry-run}';
        protected $description = 'Process orders in bulk with safety checks';
    
        const EXPECTED_STATUS = 'pending';
    
        public function handle()
        {
            $isDryRun = $this->option('dry-run');
            $orderIds = $this->argument('ids') ?: Order::where('status', self::EXPECTED_STATUS)->pluck('id');
    
            $this->info($isDryRun ? '🟑 DRY RUN MODE' : 'πŸ”΄ LIVE MODE');
            
            DB::beginTransaction();
            
            $results = [];
            $this->output->progressStart(count($orderIds));
    
            foreach ($orderIds as $id) {
                $result = ['step_1' => '', 'step_2' => '', 'step_3' => ''];
                
                try {
                    $order = Order::findOrFail($id);
                    
                    // Validation: skip if not in expected state
                    if ($order->status !== self::EXPECTED_STATUS) {
                        $result['step_1'] = '⊘ Wrong status';
                        $result['step_2'] = '⊘ Skipped';
                        $result['step_3'] = '⊘ Skipped';
                        $results[$id] = $result;
                        $this->output->progressAdvance();
                        continue;
                    }
                    
                    // Step 1: Calculate totals
                    try {
                        $order->calculateTotal();
                        $result['step_1'] = 'βœ“ Calculated';
                    } catch (\Throwable $e) {
                        $result['step_1'] = 'βœ— ' . $e->getMessage();
                    }
                    
                    // Step 2: Send notification
                    try {
                        $order->sendProcessingEmail();
                        $result['step_2'] = 'βœ“ Notified';
                    } catch (\Throwable $e) {
                        $result['step_2'] = 'βœ— ' . $e->getMessage();
                    }
                    
                    // Step 3: Update status
                    try {
                        $order->update(['status' => 'processed']);
                        $result['step_3'] = 'βœ“ Updated';
                    } catch (\Throwable $e) {
                        $result['step_3'] = 'βœ— ' . $e->getMessage();
                    }
                    
                } catch (\Throwable $e) {
                    $result['step_1'] = 'βœ— Order not found';
                    $result['step_2'] = '⊘ Skipped';
                    $result['step_3'] = '⊘ Skipped';
                }
                
                $results[$id] = $result;
                $this->output->progressAdvance();
            }
            
            $this->output->progressFinish();
            
            // Display results table
            $rows = [];
            foreach ($results as $id => $steps) {
                $rows[] = [$id, $steps['step_1'], $steps['step_2'], $steps['step_3']];
            }
            
            $this->table(['Order ID', 'Calculate', 'Notify', 'Update'], $rows);
            
            // Rollback on dry-run, commit on live
            if ($isDryRun) {
                DB::rollback();
                $this->warn('🟑 Rolled back (dry-run mode)');
            } else {
                DB::commit();
                $this->info('βœ“ Committed to database');
            }
            
            return 0;
        }
    }
    

    Why This Works

    The per-step error handling is the key. Instead of aborting when step 1 fails on record 50, you get visibility into all 100 records. Maybe step 1 works but step 2 fails for specific records – now you know exactly which ones and why.

    The dry-run flag lets you test the full execution path with real data without committing changes. Combined with optional ID arguments, you can test locally with production-like scenarios.

    Real-World Usage

    
    # Local testing with specific IDs
    php artisan orders:process-bulk 101 102 103 --dry-run
    
    # Dry-run on staging with production data
    php artisan orders:process-bulk --dry-run
    
    # Live run (no dry-run flag)
    php artisan orders:process-bulk
    

    This pattern prevents catastrophic errors while maintaining full auditability. When you’re touching 100+ production records, you want safety AND visibility – not all-or-nothing execution.

  • Auto-Generate JMS DTOs from API Response Fixtures

    When integrating with third-party APIs, manually writing DTOs (Data Transfer Objects) for every response structure is tedious and error-prone. Here’s a reusable Laravel command pattern that auto-generates JMS Serializer DTOs from captured API responses.

    The Problem

    You’re building an integration with an external API that returns complex nested JSON. Writing DTOs by hand means:

    • Manually mapping every field
    • Maintaining JMS annotations
    • Keeping DTOs in sync when the API changes

    The Solution: DTO Generator + Fixture Middleware

    Step 1: Capture API Responses as Fixtures

    Create a Guzzle middleware that saves raw API responses to fixture files during development:

    // app/Http/Middleware/FixtureDumperMiddleware.php
    namespace App\Http\Middleware;
    
    use GuzzleHttp\Middleware;
    use Psr\Http\Message\RequestInterface;
    use Psr\Http\Message\ResponseInterface;
    
    class FixtureDumperMiddleware
    {
        public static function create(string $fixtureDir): callable
        {
            return Middleware::tap(
                null,
                function (RequestInterface $request, $options, ResponseInterface $response) use ($fixtureDir) {
                    $uri = $request->getUri()->getPath();
                    $filename = $fixtureDir . '/' . str_replace('/', '_', trim($uri, '/')) . '.json';
                    
                    file_put_contents($filename, $response->getBody());
                }
            );
        }
    }
    

    Wire it into your HTTP client:

    // app/Providers/ApiServiceProvider.php
    use GuzzleHttp\HandlerStack;
    use App\Http\Middleware\FixtureDumperMiddleware;
    
    public function register()
    {
        $this->app->singleton(PaymentClient::class, function ($app) {
            $stack = HandlerStack::create();
            
            if (app()->environment('local')) {
                $stack->push(FixtureDumperMiddleware::create(storage_path('api_fixtures')));
            }
            
            return new PaymentClient([
                'handler' => $stack,
                'base_uri' => config('services.payment.base_url'),
            ]);
        });
    }
    

    Step 2: Generate DTOs from Fixtures

    Create an artisan command that reads fixture JSON and outputs PHP DTOs:

    // app/Console/Commands/GenerateJmsDtosCommand.php
    namespace App\Console\Commands;
    
    use Illuminate\Console\Command;
    
    class GenerateJmsDtosCommand extends Command
    {
        protected $signature = 'dto:generate {fixture} {--namespace=App\\DTO}';
        protected $description = 'Generate JMS DTOs from API response fixture';
    
        public function handle()
        {
            $fixturePath = storage_path('api_fixtures/' . $this->argument('fixture'));
            $json = json_decode(file_get_contents($fixturePath), true);
            
            $className = ucfirst(camel_case(basename($fixturePath, '.json')));
            $namespace = $this->option('namespace');
            
            $dto = $this->generateDto($className, $json, $namespace);
            
            $outputPath = app_path('DTO/' . $className . '.php');
            file_put_contents($outputPath, $dto);
            
            $this->info("Generated: {$outputPath}");
        }
    
        protected function generateDto(string $className, array $data, string $namespace): string
        {
            $properties = [];
            
            foreach ($data as $key => $value) {
                $type = $this->inferType($value);
                $properties[] = sprintf(
                    "    /**\n     * @JMS\Type(\"%s\")\n     * @JMS\SerializedName(\"%s\")\n     */\n    public %s $%s;",
                    $type,
                    $key,
                    $this->phpType($type),
                    camel_case($key)
                );
            }
            
            return sprintf(
                "inferType($value[0]) . '>' : 'array';
            }
            
            return match (gettype($value)) {
                'integer' => 'int',
                'double' => 'float',
                'boolean' => 'bool',
                default => 'string',
            };
        }
    
        protected function phpType(string $jmsType): string
        {
            if (str_starts_with($jmsType, 'array')) {
                return 'array';
            }
            return $jmsType;
        }
    }
    

    Usage

    # 1. Make API calls in local environment (fixtures auto-saved)
    php artisan tinker
    >>> app(PaymentClient::class)->getTransaction('12345');
    
    # 2. Generate DTO from captured fixture
    php artisan dto:generate transaction_response.json --namespace=App\\DTO\\Payment
    
    # Output: app/DTO/Payment/TransactionResponse.php
    

    Benefits

    • Speed: Generate 50+ DTOs in seconds instead of hours
    • Accuracy: No typos or missed fields
    • Maintenance: Re-run when API changes to update DTOs
    • Reusable: Works with any JSON API

    Real-World Impact

    This pattern was used to generate 40+ DTOs for a payment gateway integration, reducing what would have been 2-3 days of manual work to 15 minutes of automated generation.

    The generated DTOs work seamlessly with JMS Serializer for automatic JSON deserialization:

    $response = $client->get('/api/transaction/12345');
    $transaction = $serializer->deserialize(
        $response->getBody(),
        TransactionResponse::class,
        'json'
    );
    

    Keep the generator command in your codebase as a reusable tool for future integrations.

  • Laravel Service Provider for Plugin Architecture with HTTP Logging

    When building integrations with multiple external services, structuring them as Laravel service providers creates a clean, reusable plugin architecture. Here’s how to build HTTP clients with automatic request/response logging using service providers.

    The Pattern

    Each integration is a self-contained “plugin” with its own service provider that:

    • Registers the HTTP client with middleware
    • Wires dependencies via DI
    • Configures logging/monitoring
    • Implements capability interfaces

    Structure

    app/
    β”œβ”€β”€ Integrations/
    β”‚   └── ShipmentTracking/
    β”‚       β”œβ”€β”€ ServiceProvider.php          # DI + middleware wiring
    β”‚       β”œβ”€β”€ Client.php                   # High-level client
    β”‚       β”œβ”€β”€ ShipmentTrackingPlugin.php   # Implements capability interfaces
    β”‚       └── SDK/
    β”‚           β”œβ”€β”€ ApiClient.php            # Low-level HTTP client
    β”‚           └── Model/                   # Response DTOs
    

    Implementation

    Step 1: Service Provider with HTTP Logging

    // app/Integrations/ShipmentTracking/ServiceProvider.php
    namespace App\Integrations\ShipmentTracking;
    
    use GuzzleHttp\HandlerStack;
    use GuzzleHttp\Middleware;
    use Illuminate\Support\ServiceProvider as BaseServiceProvider;
    use Psr\Http\Message\RequestInterface;
    use Psr\Http\Message\ResponseInterface;
    
    class ServiceProvider extends BaseServiceProvider
    {
        public function register()
        {
            $this->app->singleton(SDK\ApiClient::class, function ($app) {
                $stack = HandlerStack::create();
                
                // Add request/response logging middleware
                $stack->push($this->loggingMiddleware());
                
                return new SDK\ApiClient([
                    'base_uri' => config('services.shipment_tracking.base_url'),
                    'handler' => $stack,
                    'timeout' => 30,
                ]);
            });
    
            $this->app->singleton(Client::class, function ($app) {
                return new Client(
                    $app->make(SDK\ApiClient::class)
                );
            });
        }
    
        protected function loggingMiddleware(): callable
        {
            return Middleware::tap(
                function (RequestInterface $request) {
                    \Log::info('[ShipmentTracking] Request', [
                        'method' => $request->getMethod(),
                        'uri' => (string) $request->getUri(),
                        'headers' => $request->getHeaders(),
                        'body' => (string) $request->getBody(),
                    ]);
                },
                function (RequestInterface $request, $options, ResponseInterface $response) {
                    \Log::info('[ShipmentTracking] Response', [
                        'status' => $response->getStatusCode(),
                        'body' => (string) $response->getBody(),
                        'duration_ms' => $options['duration'] ?? null,
                    ]);
                }
            );
        }
    }
    

    Step 2: High-Level Client

    // app/Integrations/ShipmentTracking/Client.php
    namespace App\Integrations\ShipmentTracking;
    
    use App\Integrations\ShipmentTracking\SDK\ApiClient;
    
    class Client
    {
        public function __construct(
            private readonly ApiClient $apiClient
        ) {}
    
        public function getShipmentStatus(string $trackingNumber): ShipmentStatus
        {
            $response = $this->apiClient->get("/tracking/{$trackingNumber}");
            
            return $this->serializer->deserialize(
                $response->getBody(),
                ShipmentStatus::class,
                'json'
            );
        }
    
        public function listShipments(array $filters = []): array
        {
            $response = $this->apiClient->get('/shipments', [
                'query' => $filters,
            ]);
            
            return $this->serializer->deserialize(
                $response->getBody(),
                'array',
                'json'
            );
        }
    }
    

    Step 3: Plugin with Multiple Capabilities

    // app/Integrations/ShipmentTracking/ShipmentTrackingPlugin.php
    namespace App\Integrations\ShipmentTracking;
    
    use App\Contracts\TracksShipments;
    use App\Contracts\ImportsInventory;
    
    class ShipmentTrackingPlugin implements TracksShipments, ImportsInventory
    {
        public function __construct(
            private readonly Client $client
        ) {}
    
        public function trackShipment(string $trackingNumber): array
        {
            $status = $this->client->getShipmentStatus($trackingNumber);
            
            return [
                'status' => $status->currentStatus,
                'location' => $status->currentLocation,
                'estimated_delivery' => $status->estimatedDeliveryDate,
                'history' => $status->events,
            ];
        }
    
        public function syncInventory(string $warehouseId): void
        {
            $shipments = $this->client->listShipments([
                'warehouse' => $warehouseId,
                'status' => 'in_transit',
            ]);
            
            foreach ($shipments as $shipment) {
                // Update local inventory records
                Inventory::updateOrCreate(
                    ['tracking_number' => $shipment->trackingNumber],
                    ['quantity' => $shipment->quantity, 'eta' => $shipment->eta]
                );
            }
        }
    }
    

    Step 4: Register the Provider

    // config/app.php
    'providers' => ServiceProvider::defaultProviders()->merge([
        // ...
        App\Integrations\ShipmentTracking\ServiceProvider::class,
    ])->toArray(),
    

    Benefits

    1. Automatic HTTP Logging

    Every API call is logged without manual instrumentation. Perfect for debugging integration issues:

    [2026-03-19 14:30:12] [ShipmentTracking] Request
      method: GET
      uri: https://api.shipmenttracker.com/tracking/ABC123
      duration: 342ms
    
    [2026-03-19 14:30:12] [ShipmentTracking] Response
      status: 200
      body: {"status":"delivered","location":"Singapore"}
    

    2. Testability

    Mock the high-level Client in tests, not Guzzle:

    $mockClient = Mockery::mock(Client::class);
    $mockClient->shouldReceive('getShipmentStatus')
        ->with('ABC123')
        ->andReturn(new ShipmentStatus(['status' => 'delivered']));
    
    $this->app->instance(Client::class, $mockClient);
    

    3. Reusable Pattern

    Copy this structure for every new integration:

    • Payment gateways
    • Shipping providers
    • CRM systems
    • Marketing automation

    4. Interface-Based Architecture

    Plugins implement capability interfaces (TracksShipments, ImportsInventory), allowing multiple providers for the same capability:

    // Swap providers without changing consuming code
    interface TracksShipments
    {
        public function trackShipment(string $trackingNumber): array;
    }
    
    // Use any provider that implements the interface
    $tracker = app(TracksShipments::class); // Could be FedEx, DHL, UPS, etc.
    $status = $tracker->trackShipment('ABC123');
    

    Advanced: Environment-Specific Middleware

    Add different middleware based on environment:

    protected function loggingMiddleware(): callable
    {
        if (app()->environment('production')) {
            // Production: log only errors and slow requests
            return Middleware::tap(
                null,
                function ($request, $options, $response) {
                    if ($response->getStatusCode() >= 400 || ($options['duration'] ?? 0) > 5000) {
                        \Log::warning('[ShipmentTracking] Slow/Error', [
                            'status' => $response->getStatusCode(),
                            'duration_ms' => $options['duration'],
                            'uri' => (string) $request->getUri(),
                        ]);
                    }
                }
            );
        }
    
        // Development/Staging: log everything
        return $this->verboseLoggingMiddleware();
    }
    

    This pattern scales to dozens of integrations while keeping each one isolated, testable, and easy to maintain.

  • Wrap Critical Operations in Production Checks

    Ever accidentally sent test data to a production API or uploaded development files to a live server? Here’s a simple guard rail: wrap critical operations in production environment checks.

    The Problem

    When you’re building workflows that interact with external systems, it’s easy to forget you’re running in development:

    public function generateReport()
    {
        $csv = $this->generateCsv();
        $encrypted = $this->encrypt($csv);
        $this->uploadToSftp($encrypted); // ⚠️ Always uploads!
        $this->sendNotification();
    }

    Run this in staging to test CSV generation? Congratulations, you just uploaded test data to the client’s production SFTP server.

    The Fix

    Use App::environment('production') to gate critical operations:

    use Illuminate\Support\Facades\App;
    
    public function generateReport()
    {
        $csv = $this->generateCsv();
        $encrypted = $this->encrypt($csv);
    
        if (App::environment('production')) {
            $this->uploadToSftp($encrypted);
            $this->output->writeln('βœ… Uploaded to production SFTP');
        } else {
            $this->output->writeln('⏭️  Skipped SFTP upload (non-production)');
        }
    
        $this->sendNotification();
    }

    Now you can test the entire workflow in developmentβ€”generate files, validate data, encrypt payloadsβ€”without triggering the actual external call.

    When to Use This

    Gate anything with external side effects:

    • SFTP/FTP uploads
    • External API calls (payments, third-party services)
    • Email sends to real addresses
    • Webhook deliveries

    Keep the rest of your logic environment-agnostic. This way you catch bugs in staging without impacting production systems.

    Pro Tip

    Add console output when you skip operations. Future-you (debugging why files aren’t uploading in staging) will thank present-you.

  • Centralize Environment Config into a Service

    Scattering env() calls across your codebase creates hidden coupling and makes testing painful. Here’s a better pattern: centralize related configuration into a dedicated service.

    The Problem

    When you need external service credentials, it’s tempting to reach for env() wherever you need them:

    class ReportUploader
    {
        public function upload($file)
        {
            $host = env('CLIENT_SFTP_HOST');
            $user = env('CLIENT_SFTP_USERNAME');
            $pass = env('CLIENT_SFTP_PASSWORD');
            
            $this->sftpClient->connect($host, $user, $pass);
            // ...
        }
    }

    This works, but now your service class is tightly coupled to specific environment variable names. Change a variable name? Hunt down every env() call. Mock config for tests? Good luck.

    The Fix

    Create a settings service that acts as a single source of truth:

    class ClientSettingsService
    {
        public function getSftpHost(): string
        {
            return config('clients.sftp.host');
        }
    
        public function getSftpUsername(): string
        {
            return config('clients.sftp.username');
        }
    
        public function getSftpPassword(): string
        {
            return config('clients.sftp.password');
        }
    }

    Now inject the service instead of calling env() directly:

    class ReportUploader
    {
        public function __construct(
            private ClientSettingsService $settings
        ) {}
    
        public function upload($file)
        {
            $host = $this->settings->getSftpHost();
            $user = $this->settings->getSftpUsername();
            $pass = $this->settings->getSftpPassword();
            
            $this->sftpClient->connect($host, $user, $pass);
            // ...
        }
    }

    Benefits

    • Centralized changes: Rename a config key? Update one service method.
    • Easy testing: Mock the service, not individual env() calls.
    • Type safety: Explicit return types catch config issues at compile time.
    • Domain clarity: $settings->getSftpHost() is more readable than env('CLIENT_SFTP_HOST').

    If you’re reaching for env() in a service class, stop. Create a settings service first.

  • Extending Laravel Scout with Custom JSON:API Pagination

    When you’re building a search API with Laravel Scout and want to follow the JSON:API specification for pagination, you might run into a compatibility problem: packages like spatie/laravel-json-api-paginate don’t work with Scout queries, and packages that do support Scout (like jackardios/scout-json-api-paginate) might not support your Laravel version.

    The good news? You can add this functionality yourself with a simple Scout macro.

    The Problem

    JSON:API uses a specific pagination format:

    GET /api/reports?q=sales&page[number]=2&page[size]=20

    But Scout’s built-in paginate() method expects Laravel’s standard pagination parameters. You need a bridge between these two formats.

    The Solution

    Add a macro to Scout’s Builder class in your AppServiceProvider:

    // app/Providers/AppServiceProvider.php
    use Laravel\Scout\Builder as ScoutBuilder;
    
    public function boot()
    {
        ScoutBuilder::macro('jsonPaginate', function ($maxResults = null, $defaultSize = null) {
            $maxResults = $maxResults ?? 30;
            $defaultSize = $defaultSize ?? 15;
            $numberParam = config('json-api-paginate.number_parameter', 'page[number]');
            $sizeParam = config('json-api-paginate.size_parameter', 'page[size]');
            
            $size = (int) request()->input($sizeParam, $defaultSize);
            $size = min($size, $maxResults);
            
            $number = (int) request()->input($numberParam, 1);
            
            return $this->paginate($size, 'page', $number);
        });

    How to Use It

    Now you can use jsonPaginate() on any Scout search query:

    // In your controller
    public function search(Request $request)
    {
        $query = $request->input('q');
        
        $results = Report::search($query)
            ->query(fn ($builder) => $builder->where('active', true))
            ->jsonPaginate();
        
        return $results;
    }

    Your API will now accept JSON:API pagination parameters:

    GET /api/reports?q=sales&page[number]=2&page[size]=20

    Why This Works

    The macro:

    1. Reads the JSON:API-style page[number] and page[size] parameters
    2. Enforces a max results limit (prevents clients from requesting too many results)
    3. Converts these to Scout’s expected format: paginate($perPage, $pageName, $page)
    4. Returns a standard Laravel paginator that works with Scout

    You get JSON:API-compliant pagination without adding a whole package or creating a custom service provider.

    Configuration

    If you’re using spatie/laravel-json-api-paginate for your Eloquent queries, this macro will automatically use the same configuration keys from config/json-api-paginate.php. If not, it defaults to page[number] and page[size].

    Tip: This pattern works for any Laravel class you want to extend. Macros are a lightweight way to add functionality without modifying vendor code or creating inheritance hierarchies.

  • Cross-Reference Sentry Errors with Domain Records

    When debugging production issues, the gap between your application logs and error monitoring can slow you down. You see an order failed, but finding the related Sentry error means searching by timestamp and guessing which error matches.

    Here’s a better approach: capture the Sentry event ID and store it directly in your domain records.

    The Pattern

    When catching exceptions, grab the Sentry event ID and attach it to your domain object before re-throwing:

    try {
        $order->processPayment();
    } catch (\Throwable $e) {
        if (app()->bound('sentry')) {
            /** @var \Sentry\State\Hub $sentry */
            $sentry = app('sentry');
            $eventId = $sentry->captureException($e);
            
            if ($eventId && isset($order)) {
                $relativePath = str_replace(base_path() . '/', '', $e->getFile());
                
                $order->addNote(sprintf(
                    '%s: %s in %s:%s%s**[View in Sentry](https://sentry.io/issues/?query=%s)**',
                    $e::class,
                    htmlspecialchars($e->getMessage()),
                    $relativePath,
                    $e->getLine(),
                    str_repeat(PHP_EOL, 2),
                    $eventId
                ));
            }
        }
        
        throw $e;
    }

    Why This Works

    The key insight: capture the exception but still throw it. Sentry’s deduplication prevents duplicate events, so you get:

    • A Sentry event with full stack trace and context
    • A direct reference stored in your database
    • Normal error handling flow (the exception still bubbles up)

    Implementation Details

    Relative paths: Strip base_path() from file paths to keep error messages clean and avoid exposing server directory structure.

    HTML escaping: Use htmlspecialchars() on the exception message since it might be displayed in HTML contexts.

    Markdown formatting: The **[View in Sentry](...)** syntax renders as a clickable link if your notes field supports markdown.

    Alternative: Database Table

    If you don’t have a notes/comments feature, create a dedicated error_references table:

    Schema::create('error_references', function (Blueprint $table) {
        $table->id();
        $table->morphs('referenceable'); // order, invoice, etc.
        $table->string('sentry_event_id')->index();
        $table->string('exception_class');
        $table->text('exception_message');
        $table->string('file_path');
        $table->integer('line_number');
        $table->timestamp('occurred_at');
    });
    
    // Usage
    ErrorReference::create([
        'referenceable_id' => $order->id,
        'referenceable_type' => Order::class,
        'sentry_event_id' => $eventId,
        'exception_class' => $e::class,
        'exception_message' => $e->getMessage(),
        'file_path' => str_replace(base_path() . '/', '', $e->getFile()),
        'line_number' => $e->getLine(),
        'occurred_at' => now(),
    ]);

    The Payoff

    When investigating a failed order, you now have a direct link to the exact Sentry event. No timestamp matching, no guessing. Click the link and you’re looking at the full stack trace with all the context Sentry captured.

    This pattern works for any domain object that might fail: orders, payments, imports, scheduled jobs, API calls. Anywhere you catch exceptions and want to maintain a connection to your error monitoring.