Author: Daryle De Silva

  • Separate Display Names from System Identifiers

    When building systems with both user interfaces and internal logic, explicitly separating display names from system identifiers prevents cascading changes when marketing decides to rename a feature.

    The pattern: use stable identifiers (type, id, slug) for code/database/filenames, and store display names (name, label) separately in configuration:

    // config/features.php
    return [
        'available_reports' => [
            [
                'type' => 'sales_report',               // Stable system identifier
                'name' => 'Quarterly Sales Analysis',   // User-facing display name
                'permission' => 'view_sales_reports',
            ],
            [
                'type' => 'inventory_report',
                'name' => 'Stock Level Summary',
                'permission' => 'view_inventory',
            ],
        ],
    ];

    In your controllers:

    class ReportController
    {
        public function index()
        {
            $reports = collect(config('features.available_reports'))
                ->filter(fn ($report) => auth()->user()->can($report['permission'] ?? ''))
                ->map(fn ($report) => [
                    'id' => $report['type'],      // Internal ID for API/routes
                    'label' => $report['name'],   // Display name for UI
                ]);
                
            return view('reports.index', compact('reports'));
        }
        
        public function generate(string $reportType)
        {
            // Use 'type' for routing, job dispatch, filename generation
            $job = match ($reportType) {
                'sales_report' => new GenerateSalesReport(),
                'inventory_report' => new GenerateInventoryReport(),
                default => throw new InvalidArgumentException(),
            };
            
            dispatch($job);
        }
    }

    Job classes use the stable identifier:

    class GenerateSalesReport implements ShouldQueue
    {
        public function handle()
        {
            $filename = 'sales_report_' . now()->format('Y-m-d') . '.pdf';
            
            Storage::put("exports/{$filename}", $this->generatePdf());
            
            // Filename: 'sales_report_2024-03-05.pdf' — never changes
        }
    }

    Why this matters:

    • Marketing freedom: “Quarterly Sales Analysis” can become “Revenue Insights Dashboard” without touching code
    • Stability: Database queries, API endpoints, and filenames don’t break when display names change
    • A/B testing: Easily test different labels for the same feature
    • Internationalization: Display names can be translated while system identifiers stay English

    What NOT to do:

    // ❌ DON'T couple display names to class constants
    class GenerateSalesReport
    {
        public const DISPLAY_NAME = 'Quarterly Sales Analysis';
        
        public function getFilename()
        {
            return self::DISPLAY_NAME . '_' . now()->format('Y-m-d') . '.pdf';
            // Filename: 'Quarterly Sales Analysis_2024-03-05.pdf' — spaces, changes when label changes
        }
    }
    
    // ❌ DON'T hardcode display names in multiple places
    // Controller
    $reportName = 'Quarterly Sales Analysis';
    
    // Blade view
    

    Quarterly Sales Analysis

    // Email notification Mail::send(..., ['report' => 'Quarterly Sales Analysis']); // Now you have 3+ places to update when marketing changes the name

    The right approach:

    • Store display names in config/*.php or database tables where non-developers can update them
    • Use system identifiers everywhere in code (sales_report, not "Quarterly Sales Analysis")
    • Fetch display names at runtime from the centralized source

    Your codebase becomes more flexible, and non-technical stakeholders can update user-facing labels without opening a pull request.

  • Sanitize Filenames with Laravel’s String Helper Chain

    When generating filenames from dynamic input—user-provided names, database values, or API responses—a three-step Laravel string helper chain ensures clean, filesystem-safe output.

    The pattern combines Str::lower(), Str::slug(), and Str::title() to handle edge cases you might not think of:

    use Illuminate\Support\Str;
    
    class FileGenerator
    {
        public static function sanitizeFilename(string $name): string
        {
            return Str::title(Str::slug(Str::lower($name), '_'));
        }
    }
    
    // Examples
    FileGenerator::sanitizeFilename('Q4 Sales Report');
    // Returns: 'Q4_Sales_Report'
    
    FileGenerator::sanitizeFilename('User Analytics (2024)');
    // Returns: 'User_Analytics_2024'
    
    FileGenerator::sanitizeFilename('Employee List - HR Dept.');
    // Returns: 'Employee_List_Hr_Dept'
    
    FileGenerator::sanitizeFilename('Données françaises');
    // Returns: 'Donnees_Francaises'

    Why this three-step chain works:

    1. Str::lower() normalizes case to avoid filesystem issues on case-sensitive systems (Linux servers are case-sensitive, Windows/Mac are not)
    2. Str::slug() converts to URL-safe format, replacing spaces and special characters with your chosen separator (underscore here)
    3. Str::title() capitalizes words for readable filenames without breaking filesystem compatibility

    Real-world usage with timestamps:

    class ReportExporter
    {
        public function export(string $reportName, array $data): string
        {
            $filename = FileGenerator::sanitizeFilename($reportName) 
                        . '_' . now()->format('Y-m-d_His') 
                        . '.csv';
            
            Storage::put("exports/{$filename}", $this->toCsv($data));
            
            return $filename;
        }
    }
    
    // Output: 'Monthly_Revenue_2024-03-05_093045.csv'

    Why not just use Str::slug() alone?

    Plain Str::slug() would give you 'q4-sales-report' (all lowercase). The Str::title() step makes filenames more readable when users download them. Compare:

    • Without title case: employee_performance_report.csv
    • With title case: Employee_Performance_Report.csv

    The second is clearer at a glance in file explorers.

    Alternative separators:

    You can use hyphens instead of underscores by changing the second parameter to Str::slug():

    return Str::title(Str::slug(Str::lower($name), '-'));

    This is common for web-facing URLs. Underscores are traditional for downloaded files, but both work fine for filesystems.

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