Blog

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

  • Backend Sorting with GET Parameters Instead of JavaScript

    When building sortable lists, the temptation is to handle sorting in JavaScript—intercept clicks, reorder the DOM, maybe use a library like DataTables. But there’s a simpler, more robust approach: let the backend handle it via GET parameters.

    The Backend Sorting Pattern

    Instead of JavaScript, pass sorting preferences through the URL:

    $allowedSorts = ['created_at', 'updated_at', 'name', 'price'];
    $sort = request('sort', 'created_at');
    $direction = request('direction', 'desc');
    
    if (!in_array($sort, $allowedSorts)) {
        $sort = 'created_at';
    }
    
    $items = Item::orderBy($sort, $direction)->paginate(20);

    Then in your view, generate sort links:

    <select onchange="window.location.href=this.value">
        <option value="?sort=created_at&direction=desc" {{ request('sort') == 'created_at' ? 'selected' : '' }}>
            Newest First
        </option>
        <option value="?sort=name&direction=asc" {{ request('sort') == 'name' ? 'selected' : '' }}>
            Name (A-Z)
        </option>
        <option value="?sort=price&direction=asc" {{ request('sort') == 'price' ? 'selected' : '' }}>
            Price (Low to High)
        </option>
    </select>

    Why Backend Sorting Wins

    State in the URL: Users can bookmark a sorted view, share links, and browser back/forward works correctly. With JS sorting, the URL doesn’t change—the state lives only in memory.

    Works with pagination: Client-side sorting breaks when you paginate (you’re only sorting the current page). Backend sorting applies across all records.

    No Vue template issues: If you’re using Vue, putting <script> tags in templates causes parsing errors. Backend sorting keeps JavaScript out of your Blade/Vue templates entirely.

    RESTful and simple: The URL describes the resource state. It’s the way the web was designed to work.

    Whitelist Validation is Critical

    Notice the $allowedSorts array? This prevents SQL injection via column names:

    if (!in_array($sort, $allowedSorts)) {
        $sort = 'created_at'; // fallback to default
    }

    Without this check, a malicious user could inject arbitrary SQL by crafting a URL like ?sort=malicious_column. Always validate sort columns against a whitelist.

    Handling Relationships

    You can even sort by related model columns using joins:

    $allowedSorts = ['created_at', 'name', 'category_name'];
    $sort = request('sort', 'created_at');
    
    if ($sort === 'category_name') {
        $items = Item::join('categories', 'items.category_id', '=', 'categories.id')
            ->select('items.*')
            ->orderBy('categories.name', $direction)
            ->paginate(20);
    } else {
        $items = Item::orderBy($sort, $direction)->paginate(20);
    }

    Preserving Other Query Parameters

    If your page has filters or search, append them to sort links using request()->except():

    $queryParams = request()->except('sort', 'direction');
    $queryParams['sort'] = 'name';
    $queryParams['direction'] = 'asc';
    
    <a href="?{{ http_build_query($queryParams) }}">Sort by Name</a>

    Or use Laravel’s appends method on paginated results:

    {{ $items->appends(request()->except('page'))->links() }}

    When JavaScript Sorting Makes Sense

    There are still valid use cases for client-side sorting:

    • Small datasets (< 100 rows) that fit on one page
    • Real-time data that updates frequently via WebSocket
    • Interactive tables where instant response is critical (trading dashboards, etc.)

    But for most admin panels and user-facing lists, backend sorting with GET parameters is simpler, more robust, and plays nicely with pagination, bookmarking, and server-side rendering.

    Let the URL do the work. It’s already there for a reason.

  • Eloquent Aggregate Filtering with selectRaw and havingRaw

    When you need to filter records based on aggregate calculations—like “find all products with more than 5 reviews”—you might reach for a subquery. But Eloquent’s selectRaw and havingRaw combo offers a cleaner, more performant alternative.

    The Pattern

    Instead of a subquery, combine aggregate calculation with filtering in a single query:

    Product::joinRelationship('reviews')
        ->select('products.*')
        ->selectRaw('count(reviews.id) as review_count')
        ->groupBy('products.id')
        ->havingRaw('review_count > 5')
        ->orderByDesc('reviews.id')
        ->take(50)
        ->pluck('review_count', 'products.id');

    This query:

    1. Joins the relationship (using a package like kirschbaum-development/eloquent-power-joins)
    2. Selects all product columns
    3. Adds a calculated column via selectRaw
    4. Groups by product ID
    5. Filters on the aggregate using havingRaw
    6. Sorts and limits results

    Why This Works Better Than Subqueries

    Readability: The query reads top-to-bottom like natural language: “join reviews, count them, group by product, keep only products with count > 5”

    Performance: Single query execution instead of a nested subquery that MySQL has to materialize separately

    Flexibility: Easy to add multiple aggregates (count, sum, avg) and filter on any of them

    Understanding HAVING vs WHERE

    The key difference:

    • WHERE filters rows before grouping (operates on individual records)
    • HAVING filters after grouping (operates on aggregate results)

    You can’t use WHERE review_count > 5 because review_count doesn’t exist yet—it’s calculated during aggregation. That’s where havingRaw comes in.

    The havingRaw Caveat

    Note that we’re using havingRaw, not having. Laravel’s having method expects specific arguments and doesn’t support this pattern cleanly. With havingRaw, you write the HAVING clause exactly as you would in SQL:

    ->havingRaw('review_count > 5')
    ->havingRaw('SUM(amount) > 1000')
    ->havingRaw('AVG(rating) >= 4.0')

    Combining Multiple Aggregates

    You can add multiple calculated columns and filter on any of them:

    Product::joinRelationship('reviews')
        ->select('products.*')
        ->selectRaw('count(reviews.id) as review_count')
        ->selectRaw('avg(reviews.rating) as avg_rating')
        ->groupBy('products.id')
        ->havingRaw('review_count > 5')
        ->havingRaw('avg_rating >= 4.0')
        ->get();

    This finds products with at least 6 reviews AND an average rating of 4.0 or higher—all in one query.

    When to Use This Pattern

    Reach for selectRaw + havingRaw when you need to:

    • Filter on counts, sums, averages, or other aggregates
    • Include the aggregate value in your results (useful for display or sorting)
    • Avoid subquery complexity while keeping queries readable

    It’s a powerful pattern that keeps your Eloquent queries expressive while generating efficient SQL.

  • Using Laravel Tinker for Safe Production Data Creation

    When you need to manually create data in production, Laravel Tinker provides a safe, interactive way using Eloquent models. Instead of writing database migrations for one-off data, you can use Tinker’s REPL to create records with full Eloquent features.

    The Single-Line Pattern

    Format your Tinker commands as single lines (no newlines) for easy paste execution in the production console:

    $city = new \App\Models\City(); $city->name = 'Springfield'; $city->state_id = 42; $city->save(); echo "City created: ID {$city->id}, Slug: {$city->slug}\n";

    This pattern gives you:

    • Instant feedback — Echo the ID and auto-generated slug to confirm success
    • Eloquent features — Automatic slugging, timestamps, events, and model hooks all fire
    • Easy execution — Copy/paste into php artisan tinker without line break issues
    • Audit trail — Terminal output serves as a record of what was created

    When to Use This Approach

    Tinker is ideal for production scenarios where you need to:

    • Create reference data (cities, categories, tags) that’s missing
    • Fix data relationships that require model logic to execute correctly
    • Test a create flow before building a full admin interface
    • Handle urgent production issues without deploying code

    Why Not Raw SQL?

    While you could use DB::insert(), using Eloquent in Tinker means:

    • Auto-generated fields (slugs, UUIDs) are handled automatically
    • Model events and observers fire (useful for audit logs, cache clearing, etc.)
    • Relationships can be attached using Eloquent methods
    • Validation and mutators apply if defined in your model

    For hierarchical data (like cities belonging to states, which belong to countries), Tinker lets you create the entire chain while respecting foreign key constraints and model logic.

    Pro Tips

    Save the commands: Keep a text file of successful Tinker commands for documentation and future reference.

    Use transactions: For multi-step operations, wrap commands in DB::transaction(function() { ... }) within Tinker.

    Check before creating: Always verify the record doesn’t exist first: City::where('name', 'Springfield')->exists()

    Tinker is a powerful tool for production data management when used carefully. Keep your commands single-line, echo confirmations, and you’ll have a safe, traceable way to handle one-off data needs.