Blog

  • Use Nullable Class Constants for Flexible Fallback Behavior

    When building class hierarchies where child classes may or may not override certain values, nullable class constants offer a clean pattern for fallback behavior.

    By setting a parent constant to null and using PHP’s null coalescence operator (??), you create flexible inheritance without forcing every child class to define the same constant:

    abstract class BaseReportJob
    {
        public const NAME = null;
        
        abstract public function reportType(): string;
        
        protected function getDisplayName(): string
        {
            // Falls back to reportType() if NAME is null
            return static::NAME ?? $this->reportType();
        }
    }
    
    class SalesReportJob extends BaseReportJob
    {
        public const NAME = 'Q4 Sales Report';
        
        public function reportType(): string
        {
            return 'sales_report';
        }
        // getDisplayName() returns 'Q4 Sales Report'
    }
    
    class DataExportJob extends BaseReportJob
    {
        // Inherits NAME = null
        
        public function reportType(): string
        {
            return 'data_export';
        }
        // getDisplayName() returns 'data_export'
    }

    Why this works:

    • Child classes can optionally override NAME for custom display values
    • Classes without a specific name fall back to their reportType()
    • No need for multiple conditionals or checking defined()
    • The static:: keyword ensures late static binding resolves the correct child constant

    This pattern is especially useful for systems where some entities need custom branding while others use generic identifiers. The null coalescence keeps the logic clean and makes the fallback behavior explicit.

    Alternative approaches:

    You could achieve similar behavior with abstract methods, but constants are better when:

    • The value is truly constant (won’t change at runtime)
    • You want to access it statically: SalesReportJob::NAME
    • Child classes don’t need complex logic to determine the value

    For dynamic values that depend on instance state, stick with methods. For static configuration that some children override and others skip, nullable constants are perfect.

  • Use HAVING for Aggregate Filters, Not WHERE

    Use HAVING for Aggregate Filters, Not WHERE

    When filtering on aggregated columns like COUNT or SUM, WHERE won’t work—you need HAVING. The difference tripped me up when I needed to find projects with zero active tasks.

    Here’s the wrong approach that throws a syntax error:

    -- This FAILS with syntax error
    SELECT 
        p.id, 
        p.title,
        COUNT(t.id) as task_count
    FROM projects p
    LEFT JOIN tasks t ON t.project_id = p.id 
    WHERE t.status = 'active'
      AND COUNT(t.id) = 0  -- ERROR: Invalid use of aggregate
    GROUP BY p.id, p.title

    MySQL will complain: “Invalid use of group function.” You can’t filter on aggregates in the WHERE clause because aggregation happens after the WHERE filter is applied.

    The correct approach uses HAVING:

    SELECT 
        p.id, 
        p.title,
        COUNT(t.id) as task_count
    FROM projects p
    LEFT JOIN tasks t ON t.project_id = p.id 
        AND t.status = 'active'
    GROUP BY p.id, p.title
    HAVING task_count = 0

    The key difference: WHERE filters rows before aggregation, HAVING filters groups after. Using WHERE on an aggregate throws a syntax error.

    In Laravel’s query builder, this translates to:

    DB::table('projects')
        ->leftJoin('tasks', function ($join) {
            $join->on('tasks.project_id', '=', 'projects.id')
                 ->where('tasks.status', '=', 'active');
        })
        ->select('projects.id', 'projects.title', DB::raw('COUNT(tasks.id) as task_count'))
        ->groupBy('projects.id', 'projects.title')
        ->havingRaw('task_count = 0')
        ->get();

    Or if you already aliased it in selectRaw, you can use the cleaner having() method:

    ->having('task_count', 0)

    Understanding this distinction prevents hours of debugging cryptic MySQL errors. Remember: filter rows with WHERE, filter aggregates with HAVING.

  • Subqueries in SELECT Clauses Are Performance Killers

    Subqueries in SELECT Clauses Are Performance Killers

    A query that took 40+ seconds to run had this pattern: each row triggered a separate subquery execution—terrible for large tables.

    The problematic query looked like this:

    SELECT 
        p.id,
        p.title,
        (SELECT COUNT(*) 
         FROM tasks t 
         WHERE t.project_id = p.id 
           AND t.status = 'active') as task_count
    FROM projects p
    WHERE p.created_at > '2024-01-01'

    The issue: for every row in the projects table, MySQL executes the subquery individually. With 10,000 projects, that’s 10,000 separate COUNT queries. Ouch.

    The fix is to refactor to a JOIN with aggregation:

    SELECT 
        p.id,
        p.title,
        COUNT(t.id) as task_count
    FROM projects p
    LEFT JOIN tasks t ON t.project_id = p.id 
        AND t.status = 'active'
    WHERE p.created_at > '2024-01-01'
    GROUP BY p.id, p.title

    Same result, but execution time dropped from 40+ seconds to under 200ms. The database can optimize JOINs far better than correlated subqueries.

    How to spot these: Use EXPLAIN to catch correlated subqueries before they hit production. Look for “DEPENDENT SUBQUERY” in the type column—that’s your red flag. Here’s what you’ll see:

    EXPLAIN SELECT ...
    
    +----+--------------------+-------+-------------------+
    | id | select_type        | table | type              |
    +----+--------------------+-------+-------------------+
    |  1 | PRIMARY            | p     | ALL               |
    |  2 | DEPENDENT SUBQUERY | t     | ref               |
    +----+--------------------+-------+-------------------+

    That “DEPENDENT SUBQUERY” line means the inner query depends on the outer query’s values and runs once per row. Refactor it to a JOIN with GROUP BY, and watch your query times drop dramatically.

  • MySQL Reserved Words Will Break Your Queries Silently

    MySQL Reserved Words Will Break Your Queries Silently

    When aliasing tables in SQL, avoid reserved keywords like if, select, where, etc. I recently debugged a query that failed with a cryptic syntax error, only to discover the alias if was the culprit. MySQL’s parser treats it as the IF() function, not your alias.

    Instead of this broken query:

    SELECT r.id, r.name
    FROM reports r
    LEFT JOIN report_data rd ON rd.report_id = r.id

    If you accidentally use a reserved word like if as an alias:

    -- This will fail with syntax error!
    SELECT r.id, r.name
    FROM reports r
    LEFT JOIN report_data if ON if.report_id = r.id

    Use something descriptive instead:

    -- Safe and readable
    SELECT r.id, r.name
    FROM reports r
    LEFT JOIN report_data rd ON rd.report_id = r.id

    The extra characters are worth the clarity and reliability. This is especially tricky because some reserved words work fine as column names but fail as aliases, making the behavior inconsistent. Always consult MySQL’s reserved words list when choosing aliases, or better yet, just use short descriptive abbreviations that are clearly not keywords.

    Pro tip: Modern IDEs will often highlight reserved words differently. Pay attention to that syntax coloring—it can save you debugging time.

  • DRY Your Laravel AI Agents with a Base Agent Class

    If you’re building multiple Laravel AI agents, you’ll notice a lot of repetitive setup code in every agent class:

    // ❌ Every agent repeats the same middleware setup
    class CustomerSupport implements Agent, HasMiddleware
    {
        use Promptable;
    
        public function middleware(): array
        {
            return [
                RateLimitMiddleware::class,
                LoggingMiddleware::class,
                RetryMiddleware::class,
            ];
        }
    }
    
    class ReportGenerator implements Agent, HasMiddleware
    {
        use Promptable;
    
        public function middleware(): array
        {
            return [
                RateLimitMiddleware::class,
                LoggingMiddleware::class,
                RetryMiddleware::class,
            ];
        }
    }

    DRY it up with a base agent:

    // app/Ai/Agents/BaseAgent.php
    abstract class BaseAgent implements Agent, HasMiddleware
    {
        use Promptable;
    
        public function middleware(): array
        {
            return [
                RateLimitMiddleware::class,
                LoggingMiddleware::class,
                RetryMiddleware::class,
            ];
        }
    }

    Now every agent just extends BaseAgent:

    // ✅ Clean and minimal
    class CustomerSupport extends BaseAgent
    {
        // Just your agent-specific logic
    }
    
    class ReportGenerator extends BaseAgent
    {
        // Just your agent-specific logic
    }

    Benefits:

    • Add/remove middleware in one place – affects all agents instantly
    • Onboarding a new agent? One line: extends BaseAgent
    • Override middleware for specific agents if needed (just override the middleware() method)

    Example override for special cases:

    // Most agents use BaseAgent defaults
    class BulkProcessor extends BaseAgent
    {
        // Different rate limit for bulk operations
        public function middleware(): array
        {
            return [
                BulkRateLimitMiddleware::class,
                ...parent::middleware(),
            ];
        }
    }

    Pattern applies beyond agents: Same concept works for controllers (BaseController), jobs (BaseJob), or any class hierarchy where you have shared setup.

  • Stop Hardcoding Pricing: Fetch It Dynamically from Your Provider’s API

    If you’re integrating with a service that has dynamic pricing (shipping carriers, payment processors, cloud APIs), you might hardcode the rates in your config:

    // config/shipping.php
    return [
        'rates' => [
            'express' => ['base' => 5.99, 'per_kg' => 0.89],
            'standard' => ['base' => 2.99, 'per_kg' => 0.45],
        ],
    ];

    The problem: Rates change frequently. You’re constantly updating config files, redeploying, and risking stale prices shown to customers.

    Better approach: Fetch pricing dynamically from the provider’s API and cache it:

    // app/Services/ShippingPricingService.php
    class ShippingPricingService
    {
        public function getRates(): array
        {
            return Cache::remember('shipping_rates', now()->addHour(), function () {
                $response = Http::withToken(config('shipping.api_key'))
                    ->get('https://api.shippingprovider.example/v1/rates');
    
                return $response->json('rates');
            });
        }
    
        public function calculateCost(string $method, float $weight): float
        {
            $rates = $this->getRates();
            $rate = $rates[$method];
    
            return $rate['base'] + ($weight * $rate['per_kg']);
        }
    }

    Now your cost calculations always use current pricing, without manual config updates:

    // Before: Hardcoded config (stale pricing risk)
    $cost = config("shipping.rates.express.base") 
        + ($weight * config("shipping.rates.express.per_kg"));
    
    // After: Dynamic pricing (always current)
    $cost = app(ShippingPricingService::class)->calculateCost('express', $weight);

    Cache strategy: 1-hour TTL is usually fine for pricing data – it doesn’t change minute-to-minute. If the provider updates rates, your app picks them up within an hour. No deploys needed.

    Bonus: Some providers let you pass per-request pricing overrides. If your tracking package supports runtime config, you can pass the fetched rates directly:

    // Hypothetical cost tracker with runtime pricing override
    CostTracker::calculate(
        method: 'express',
        weight: $weight,
        pricingOverride: $this->shippingPricing->getRates()['express']
    );

    When NOT to do this: If the API is slow or unreliable, fetch pricing asynchronously via a scheduled job and store it in your database. Then your app reads from the DB instead of hitting the API every hour.

  • Use Laravel AI SDK Middleware for Automatic Request Recording

    If you’re using Laravel AI SDK and need to log or track every AI agent call (costs, tokens, timing), you might be tempted to manually call your tracking service after every prompt():

    // ❌ Repetitive - you'll forget this somewhere
    $response = $agent->prompt($message);
    UsageLogger::record($response);

    Instead, use the HasMiddleware interface to automatically intercept all agent responses. Create a middleware that runs after every agent call:

    // app/Ai/Middleware/LogUsage.php
    namespace App\Ai\Middleware;
    
    use Closure;
    use Laravel\Ai\Prompts\AgentPrompt;
    
    class LogUsage
    {
        public function handle(AgentPrompt $prompt, Closure $next): mixed
        {
            $response = $next($prompt);
    
            // Automatic logging for ALL agents
            UsageLogger::record(
                response: $response,
                model: $response->meta->model,
                tag: class_basename($prompt->agent::class)
            );
    
            return $response;
        }
    }

    Then create a BaseAgent class that all your agents extend:

    // app/Ai/Agents/BaseAgent.php
    abstract class BaseAgent implements Agent, HasMiddleware
    {
        use Promptable;
    
        public function middleware(): array
        {
            return [
                \App\Ai\Middleware\LogUsage::class,
            ];
        }
    }

    Now every agent that extends BaseAgent gets automatic logging:

    // app/Ai/Agents/ReportGenerator.php
    class ReportGenerator extends BaseAgent
    {
        // Zero extra code - logging happens automatically
    }

    Why this matters: You have 20 agents and want to add cost tracking. Without middleware, you’re manually adding UsageLogger::record() after 20+ prompt() calls. With middleware, it’s one line in BaseAgent.

    Bonus: Need authentication, rate limiting, or retries? Add more middleware to the BaseAgent::middleware() array. All agents inherit the behavior.

  • PHP Type Checking Pitfall: is_string(‘literal’) Always True

    I just spent 2 hours debugging a “works fine without this condition” bug, only to discover the most face-palm PHP mistake I’ve made in years: checking is_string('field_name') instead of is_string($data['field_name']).

    The Bug

    The code was supposed to merge currency codes from multiple sources, but only if a specific field contained a valid string:

    $currencyCodes = [];
    
    foreach ($batches as $batch) {
        // ... collect from batches ...
    }
    
    // Merge in historical data if it exists
    if (is_string('historical_currencies')) {  // 🐛 BUG HERE
        $currencyCodes = array_unique(array_merge(
            $currencyCodes,
            explode(',', $batch['historical_currencies'])
        ));
    }
    

    See the problem? I’m checking if the literal string 'historical_currencies' is a string (it always is), not whether $batch['historical_currencies'] contains string data.

    The Result

    When $batch['historical_currencies'] was NULL:

    1. The condition is_string('historical_currencies') evaluated to TRUE (literal strings are always strings)
    2. The code tried to explode(',', NULL)
    3. PHP 8.1+ throws a deprecation warning, but older versions silently return an empty array
    4. Result: missing data, no error logs, mystery bug

    The Fix

    // Correct version
    if (is_string($batch['historical_currencies'])) {
        $currencyCodes = array_unique(array_merge(
            $currencyCodes,
            explode(',', $batch['historical_currencies'])
        ));
    }
    

    Or better yet, use optional chaining with type safety:

    if (!empty($batch['historical_currencies']) && is_string($batch['historical_currencies'])) {
        $currencyCodes = array_unique(array_merge(
            $currencyCodes,
            explode(',', $batch['historical_currencies'])
        ));
    }
    

    How This Slipped Through

    1. No static analysis – PHPStan/Psalm would catch this immediately
    2. Conditional always TRUE – so tests with valid data passed
    3. Silent failure – no exception thrown, just missing results

    The Lesson

    Type-checking functions operate on values, not field names.

    This applies to all type checks:

    • is_array('items') ❌ vs is_array($data['items'])
    • is_numeric('total') ❌ vs is_numeric($data['total'])
    • is_null('deleted_at') ❌ vs is_null($data['deleted_at'])

    When in doubt, echo the value you’re checking. If you see a field name instead of actual data, you’re checking the wrong thing.

    And seriously, run PHPStan. It would have caught this in 0.2 seconds.

  • Laravel JSON Column Filtering: The Hidden Cache Trap

    Ever had users report that certain records mysteriously disappear when they apply filters, even though the filter options clearly show those values exist? I recently debugged one of these “phantom disappearance” bugs, and the culprit was a sneaky mismatch between display logic and filter logic.

    The Symptom

    Users could see “Urgent” in the priority filter dropdown. But when they selected it, several projects that should match just vanished from the results. Without the filter? The projects showed up fine.

    The Investigation

    I traced two separate code paths:

    1. Display Logic (Filter Dropdown Options)

    // Controller - populating the dropdown
    $priorities = collect(['Low', 'Medium', 'High', 'Urgent'])
        ->concat(
            Task::active()
                ->distinct()
                ->pluck('priority')
        )
        ->unique()
        ->values();
    

    The dropdown showed priorities from ALL active tasks in the system. So “Urgent” appeared because somewhere in the database, urgent tasks existed.

    2. Filter Logic (What Actually Gets Queried)

    // Later in the controller
    if ($request->input('priority')) {
        $query->whereJsonContains('project_summaries.cached_priorities', 
                                   $request->input('priority'));
    }
    

    But the filter checked a denormalized JSON field on the project_summaries table, not the live tasks.

    The Root Cause

    Project #4251 had 6 urgent tasks assigned to it. But its project_summaries.cached_priorities field was an empty array: [].

    Why? The refresh job that populated the cache had a bug—it only collected priorities from completed tasks, not active ones. Since this project had zero completed tasks, its cache stayed empty.

    Result:

    • Display: “Urgent” shows in dropdown (because urgent tasks exist globally)
    • Filter: whereJsonContains(cached_priorities, 'Urgent') on Project #4251 returns FALSE (because its cache is [])
    • User Experience: Project disappears when filtering by Urgent

    The Fix

    Two options:

    1. Fix the cache population logic – collect priorities from all tasks (active + completed)
    2. Change the filter to query live data – join directly to the tasks table instead of checking the cache

    I went with option 1 (fix the cache) since the denormalized field existed for performance reasons. But I added a verification step: after every refresh, count tasks with each priority and compare to the cached field. Log mismatches to Sentry.

    The Lesson

    When display logic and filter logic pull from different sources, you will have bugs.

    Always verify:

    1. Where do the filter options come from? (Live query? Cache? Hardcoded list?)
    2. What field does the actual filter check? (Same source or different?)
    3. If they’re different sources, is there a sync mechanism? Does it work correctly?

    Denormalized/cached fields are fast, but they’re only as good as the code that keeps them up to date. Trust, but verify.

  • How to Use Laravel AI Provider Tools with the agent() Helper

    The Question

    How do you enable Laravel AI’s provider tools (like WebSearch, WebFetch, or FileSearch) when using the agent() helper? They’re not regular tools—they’re native provider capabilities—but the API surface looks identical.

    The Answer

    Provider tools pass through the same tools parameter as regular tools. Laravel AI’s gateway layer automatically detects and separates them under the hood.

    Basic Usage

    use Laravel\Ai\Providers\Tools\WebSearch;
    use function Laravel\Ai\agent;
    
    $response = agent(
        instructions: 'You are a research assistant.',
        tools: [new WebSearch],
    )->prompt('What are the best practices for API rate limiting?');
    

    With Configuration Options

    Provider tools support fluent configuration:

    $response = agent(
        instructions: 'You are a research assistant.',
        tools: [
            (new WebSearch)
                ->max(5)                                      // limit number of searches
                ->allow(['stackoverflow.com', 'laravel.com']) // restrict to specific domains
                ->location(city: 'London', country: 'UK'),    // refine by geographic context
        ],
    )->prompt('Find recent discussions on queue optimization.');
    

    Mixing Provider Tools with Regular Tools

    You can combine them seamlessly:

    $response = agent(
        instructions: 'You are a research assistant.',
        tools: [
            new WebSearch,         // provider tool (handled natively)
            new DatabaseQuery,     // your custom Tool implementation
        ],
    )->prompt('Research this topic and query our internal database for related records.');
    

    Available Provider Tools

    • WebSearch — Search the web (Anthropic, OpenAI, Gemini)
    • WebFetch — Fetch and extract content from URLs (Anthropic, Gemini)
    • FileSearch — Semantic search over file stores (OpenAI, Gemini)

    Provider Support Matrix

    Tool Anthropic OpenAI Gemini
    WebSearch web_search_20250305 web_search google_search
    WebFetch web_fetch_20250910 url_context
    FileSearch file_search fileSearch

    Note: If your configured provider doesn’t support a given tool, Laravel AI throws a RuntimeException at runtime.

    Why This Matters

    Provider tools unlock powerful native capabilities without the overhead of implementing custom tool handlers. When your agent needs to search the web, fetch external content, or query semantic file stores, you can hand that responsibility directly to the provider’s optimized implementation.

    The unified API keeps your code simple: whether it’s a native provider tool or your own custom tool, they all live in the same tools array.