Author: Daryle De Silva

  • Context-Rich Exception Handling for Better Debugging

    The Problem: Generic Exceptions Hide the Real Issue

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

    throw new \RuntimeException('Operation failed');

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

    The Solution: Context-Rich Custom Exceptions

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

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

    Usage in Your Code

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

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

    Reporting to Sentry

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

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

    Benefits in Production

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

    When to Use This Pattern

    Create custom exceptions for:

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

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

    Real-World Impact

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

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

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

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

  • Avoid array_combine() for Data Mapping

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

    The Problem

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

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

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

    The Fix

    Use explicit associative arrays instead:

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

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

    When to Use array_combine()

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

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

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

  • Debugging Missing Records in Laravel Reports: Export the SQL

    When users report “missing records” in generated reports, resist the urge to dive into application logic first. Instead, export the exact SQL query your Laravel app is executing and inspect it directly. Nine times out of ten, the issue is in the query, not your code.

    The Problem

    A user reports that your cancellation report shows only 6 out of 8 expected records. You’ve checked the database manually—all 8 records exist with correct timestamps and status codes. But the report consistently omits 2 specific records. What’s going wrong?

    The Solution: Export and Inspect Raw SQL

    Don’t guess. Export the generated SQL and run it yourself:

    // In your report controller
    public function generateReport(Request $request)
    {
        $query = $this->buildCancellationReportQuery($request->filters);
        
        // Export for debugging
        \Storage::put('temp/debug-query.sql', $query->toSql());
        \Storage::put('temp/debug-bindings.json', json_encode($query->getBindings()));
        
        $results = $query->get();
        
        return Excel::download(new CancellationReportExport($results), 'report.csv');
    }
    

    Then examine the SQL file. Replace placeholders with actual bindings and run it directly in your database client.

    What to Look For

    • JOIN mismatches: Are all necessary tables joined? Check LEFT vs INNER JOINs.
    • WHERE clause conflicts: Multiple ANDs can exclude records unintentionally.
    • UNION logic errors: If your query combines multiple subqueries, each needs identical filtering.
    • Date/time timezone issues: Server timezone != user timezone can shift filter boundaries.
    • Soft delete confusion: Are you accidentally filtering out records with deleted_at IS NULL?

    Real Example

    In one case, a report combined 3 UNION subqueries to gather records from different sources. The main query filtered by supplier_id = 13088, but only 2 of the 3 UNION branches had this filter. The missing records came from the third branch—which returned ALL suppliers’ data, then got filtered out at the GROUP BY stage.

    The fix: add WHERE supplier_id = 13088 to every UNION branch.

    Pro Tips

    • Use DB::enableQueryLog() + DB::getQueryLog() for quick debugging in development
    • Laravel Telescope automatically captures all queries—invaluable for production debugging
    • For complex reports, consider writing raw SQL first, then translating to Query Builder once it works

    This approach saves hours of tracing through service layers, repositories, and scopes. When records are missing, go straight to the source: the SQL.

  • Advanced Laravel Validation: Conditional Rules with Custom Closures

    When building complex forms in Laravel, you’ll often need validation rules that depend on other input values. Laravel’s Rule::requiredIf() combined with custom closure validation gives you powerful control over conditional logic.

    The Challenge

    Imagine you’re building a file upload system where users can choose between uploading files or entering barcodes. The validation rules need to change based on that choice—files are required for one mode, barcodes for another. Hardcoding separate validation paths leads to duplication and brittle code.

    The Solution: Conditional Rules with Closures

    Laravel lets you build validation rules dynamically using Rule::requiredIf() for conditional requirements and custom closures for complex business logic:

    use Illuminate\Validation\Rule;
    
    $uploadType = $request->input("items.{$itemId}.upload_type");
    
    $rules = [
        "items.{$itemId}.upload_type" => 'required|in:file,barcode',
        
        // Files only required when upload_type is 'file'
        "items.{$itemId}.files" => [
            "required_if:items.{$itemId}.upload_type,file",
            'array',
            function ($attribute, $value, $fail) use ($itemId, $maxAllowed) {
                if (count($value) > $maxAllowed) {
                    $fail("Item {$itemId}: Too many files. Max allowed: {$maxAllowed}");
                }
            }
        ],
        
        // Barcodes only required when upload_type is 'barcode'
        "items.{$itemId}.barcodes" => [
            "required_if:items.{$itemId}.upload_type,barcode",
            'string',
            function ($attribute, $value, $fail) use ($repository, $itemId) {
                $codes = array_filter(preg_split('/[\s\n]+/', $value));
                
                // Check for duplicates in database
                $duplicates = $repository->findExisting($codes);
                if ($duplicates->isNotEmpty()) {
                    $fail("Item {$itemId}: Duplicate barcodes found: " . $duplicates->implode(', '));
                }
            }
        ],
    ];
    
    $validated = $request->validate($rules);
    

    Why This Pattern Works

    • Centralized validation: All rules in one place, no scattered if/else branches
    • Flexible conditions: Rule::requiredIf() handles simple dependencies
    • Custom business logic: Closures let you inject services and run complex checks
    • Clear error messages: Customize failures per field and context

    Taking It Further

    You can nest conditions deeper with Rule::when() or combine multiple closure validators for different aspects (format validation, uniqueness, business rules). Laravel’s validation system is expressive enough to handle even the most complex form requirements without leaving the validation layer.

    Pro tip: For very complex validation, consider extracting to a custom Form Request class. But for moderately complex interdependent fields, this inline approach keeps everything readable and maintainable.