Blog

  • Queue Large Reports, Don’t Block HTTP Requests

    The Problem

    Your users request a large CSV export of 50,000 orders. You build the file synchronously in a controller:

    public function export()
    {
        $file = fopen('php://temp', 'w+');
        
        Order::chunk(1000, function ($orders) use ($file) {
            foreach ($orders as $order) {
                fputcsv($file, [$order->id, $order->total, $order->status]);
            }
        });
        
        rewind($file);
        return response()->stream(function() use ($file) {
            fpassthru($file);
        }, 200, [
            'Content-Type' => 'text/csv',
            'Content-Disposition' => 'attachment; filename="orders.csv"',
        ]);
    }

    The request times out after 30 seconds. The user sees a 504 Gateway Timeout error.

    The Fix

    Dispatch a queued job that emails the file when it’s ready:

    // Controller
    public function export(Request $request)
    {
        GenerateOrdersReport::dispatch($request->user());
        
        return back()->with('success', 'Report is being generated. We\'ll email you when it\'s ready.');
    }
    
    // app/Jobs/GenerateOrdersReport.php
    class GenerateOrdersReport implements ShouldQueue
    {
        public $timeout = 600; // 10 minutes
        public $tries = 1;
        
        public function __construct(
            private User $user
        ) {}
        
        public function handle()
        {
            ini_set('memory_limit', '512M');
            
            $filename = 'orders-' . now()->format('Y-m-d-His') . '.csv';
            $path = storage_path('app/exports/' . $filename);
            
            $file = fopen($path, 'w');
            fputcsv($file, ['Order ID', 'Total', 'Status']); // header
            
            Order::chunk(1000, function ($orders) use ($file) {
                foreach ($orders as $order) {
                    fputcsv($file, [$order->id, $order->total, $order->status]);
                }
            });
            
            fclose($file);
            
            $this->user->notify(new ReportReady($filename));
        }
    }

    The notification email includes a download link:

    // app/Notifications/ReportReady.php
    class ReportReady extends Notification
    {
        public function __construct(
            private string $filename
        ) {}
        
        public function via($notifiable)
        {
            return ['mail'];
        }
        
        public function toMail($notifiable)
        {
            $url = route('reports.download', $this->filename);
            
            return (new MailMessage)
                ->subject('Your report is ready')
                ->line('Your export has been generated.')
                ->action('Download Report', $url)
                ->line('This link expires in 24 hours.');
        }
    }

    Why It Works

    • No timeout — The job can run for minutes without hitting HTTP limits
    • User feedback — Instant response (“We’re working on it”) instead of a hanging request
    • Memory control — Explicit ini_set('memory_limit') prevents runaway processes
    • Retry safety$tries = 1 prevents duplicate reports if the job fails midway

    Best Practices

    1. Set explicit timeouts — Don’t let jobs run forever
    2. Clean up old files — Schedule a daily job to delete exports older than 7 days
    3. Rate limit export requests — Prevent users from queuing 10 exports at once
    4. Track job status — Store $job->getJobId() in the database so you can show “In Progress” UI

    For any operation that takes more than 5 seconds, move it to a queue. Users appreciate instant feedback more than waiting for a loading spinner.

  • View Composers Keep Controllers Thin

    The Problem

    Your admin panel has a product filter form. Every controller method that renders this view needs to pass the same dropdown data:

    // ProductController@index
    public function index()
    {
        $categories = Category::pluck('name', 'id');
        $brands = Brand::pluck('name', 'id');
        $statuses = ['active', 'draft', 'archived'];
        
        return view('products.index', compact('categories', 'brands', 'statuses'));
    }
    
    // ProductController@create
    public function create()
    {
        $categories = Category::pluck('name', 'id');
        $brands = Brand::pluck('name', 'id');
        $statuses = ['active', 'draft', 'archived'];
        
        return view('products.create', compact('categories', 'brands', 'statuses'));
    }
    
    // ProductController@edit
    public function edit($id)
    {
        $product = Product::findOrFail($id);
        $categories = Category::pluck('name', 'id');
        $brands = Brand::pluck('name', 'id');
        $statuses = ['active', 'draft', 'archived'];
        
        return view('products.edit', compact('product', 'categories', 'brands', 'statuses'));
    }

    Duplicated logic across multiple methods. When you add a new filter dropdown, you update 5+ methods.

    The Fix

    Use a view composer to bind data automatically:

    // app/Providers/ViewServiceProvider.php
    use Illuminate\Support\Facades\View;
    use App\Models\Category;
    use App\Models\Brand;
    
    public function boot()
    {
        View::composer('products.*', function ($view) {
            $view->with([
                'categories' => Category::pluck('name', 'id'),
                'brands' => Brand::pluck('name', 'id'),
                'statuses' => ['active', 'draft', 'archived'],
            ]);
        });
    }

    Now your controllers stay thin:

    public function index()
    {
        return view('products.index');
    }
    
    public function create()
    {
        return view('products.create');
    }
    
    public function edit($id)
    {
        $product = Product::findOrFail($id);
        return view('products.edit', compact('product'));
    }

    How It Works

    The composer runs before every view that matches products.*. The data is automatically injected, so your Blade templates can access $categories, $brands, and $statuses without the controller passing them.

    When to Use It

    • Shared dropdown data across multiple views (categories, statuses, countries)
    • Default filter values that every page needs
    • Navigation menus or sidebars that appear on many pages
    • Current user permissions for conditional UI elements

    Trade-Offs

    The data is fetched on every view render, even if the template doesn’t use it. If your composer queries are expensive, cache them or use class-based composers with dependency injection for better control.

    But for simple dropdown data, view composers eliminate duplication and keep your controllers focused on actions, not view preparation.

  • Skip Eloquent for Large Dataset Exports

    The Problem

    You’re building a CSV export feature for your admin panel. Users can download product catalogs with thousands of rows. You write this:

    $products = Product::with(['category', 'brand', 'variants'])
        ->get();
    
    foreach ($products as $product) {
        $row = [
            $product->name,
            $product->category->name,
            $product->brand->name,
            $product->variants->count(),
        ];
        fputcsv($file, $row);
    }

    The job runs for 2 minutes, then crashes with “Allowed memory size exhausted.” You’re loading 10,000 Eloquent models with all their relationships into memory.

    The Fix

    Use the query builder directly. Fetch only the columns you need:

    DB::table('products')
        ->leftJoin('categories', 'products.category_id', '=', 'categories.id')
        ->leftJoin('brands', 'products.brand_id', '=', 'brands.id')
        ->select([
            'products.name as product_name',
            'categories.name as category_name',
            'brands.name as brand_name',
            DB::raw('(SELECT COUNT(*) FROM variants WHERE variants.product_id = products.id) as variant_count')
        ])
        ->chunk(1000, function ($rows) use ($file) {
            foreach ($rows as $row) {
                fputcsv($file, [
                    $row->product_name,
                    $row->category_name,
                    $row->brand_name,
                    $row->variant_count,
                ]);
            }
        });

    Why It Works

    • Chunking — Processes 1,000 rows at a time instead of loading all 10,000
    • Raw arrays — No Eloquent model overhead (attributes, casts, accessors, relationships)
    • Selective columns — Fetches only what the CSV needs, not every database column
    • Manual joins — One query instead of N+1 queries for each relationship

    Trade-Offs

    You lose Eloquent’s conveniences (accessors, casts, automatic timestamps). But for batch exports and reports, raw performance matters more than elegant code.

    Save Eloquent for interactive UIs where you’re displaying 10-50 records at a time. For bulk operations that touch thousands of rows, drop down to the query builder.

  • Debug Laravel Cache Issues with Conditional Cache Parameters

    When you’re debugging data inconsistencies in Laravel and suspect caching might be the culprit, here’s a pattern that can save hours of investigation.

    The Problem

    You have a calculator class that computes prices. Sometimes it returns stale data from Redis, sometimes fresh data from the database. The bug is intermittent and hard to reproduce. How do you prove cache is the issue?

    The Debug Pattern

    Add a cache parameter to your method and compare both results side-by-side:

    // Test with cache enabled (default behavior)
    $cachedPrice = $calculator->calculate($orderId);
    
    // Test with cache explicitly disabled  
    $freshPrice = $calculator->calculate($orderId, use_cache: false);
    
    if ($cachedPrice !== $freshPrice) {
        // Cache poisoning confirmed
    }
    

    This immediately tells you if cache is the problem without clearing cache globally or adding debug logging everywhere.

    The Implementation

    Add a boolean parameter to methods that might cache data:

    class PriceCalculator
    {
        public function calculate(
            int $orderId,
            bool $use_cache = true
        ): float {
            if (!$use_cache) {
                return $this->calculateFromDatabase($orderId);
            }
    
            return Cache::remember(
                "order_price_{$orderId}",
                now()->addHours(1),
                fn() => $this->calculateFromDatabase($orderId)
            );
        }
    
        private function calculateFromDatabase(int $orderId): float
        {
            // Fresh calculation from DB
            return Order::find($orderId)->items->sum('total');
        }
    }
    

    Use Named Parameters

    PHP 8+ named parameters make cache-disabling calls self-documenting:

    // Clear intent - this call bypasses cache
    $price = $calculator->calculate($orderId, use_cache: false);
    
    // vs the old way (less obvious)
    $price = $calculator->calculate($orderId, false);
    

    When to Disable Cache

    Keep cache enabled by default (performance), but disable it for operations where stale data causes bugs:

    class OrderRepository
    {
        public function create(array $data): Order
        {
            // Order creation must use fresh prices
            $price = $this->calculator->calculate(
                $data['product_id'],
                use_cache: false
            );
    
            return Order::create([
                'product_id' => $data['product_id'],
                'price' => $price,
            ]);
        }
    }
    

    Takeaway

    When you suspect cache issues, don’t guess. Add a cache parameter, compare results, and you’ll know instantly if cache is the problem. Then you can fix it surgically without breaking performance elsewhere.

  • Null Coalescing for Safe Configuration Access

    Configuration arrays—whether from databases, API responses, or config files—can be incomplete or inconsistent. Directly accessing keys without checking for their existence causes Undefined array key errors in PHP 8+.

    The Problem

    This constructor assumes all configuration keys exist:

    class ServiceCredentials
    {
        private string $apiKey;
        private string $testApiKey;
        private string $secretToken;
        private string $testSecretToken;
    
        public function __construct(array $config, bool $isTest)
        {
            $this->apiKey = $config['api_key'];
            $this->testApiKey = $config['test']['api_key'];  // ❌ Crashes if test.api_key missing
            
            $this->secretToken = $config['secret_token'];
            $this->testSecretToken = $config['test']['secret_token'];  // ❌ Crashes if test.secret_token missing
        }
    }
    

    When $config['test'] is incomplete (missing api_key or secret_token), PHP 8 throws ErrorException: Undefined array key and your app crashes.

    The Solution

    Use null coalescing (??) to provide fallback values:

    class ServiceCredentials
    {
        private ?string $apiKey;
        private ?string $testApiKey;
        private ?string $secretToken;
        private ?string $testSecretToken;
    
        public function __construct(array $config, bool $isTest)
        {
            $this->apiKey = $config['api_key'] ?? null;
            $this->testApiKey = $config['test']['api_key'] ?? null;  // ✅ Graceful
            
            $this->secretToken = $config['secret_token'] ?? null;
            $this->testSecretToken = $config['test']['secret_token'] ?? null;  // ✅ Graceful
        }
        
        public function getApiKey(): string
        {
            if ($this->isTest && $this->testApiKey !== null) {
                return $this->testApiKey;
            }
            
            return $this->apiKey ?? throw new \RuntimeException('API key not configured');
        }
    }
    

    Why This Works

    • Defensive coding — Configuration can be incomplete, user-managed, or legacy
    • Graceful degradation — System doesn’t crash on initialization; fails at the point where the value is actually needed with better context
    • Backwards compatible — Doesn’t break existing working configurations
    • Type-safe — Nullable types (?string) signal that values might be absent

    When to Use This

    • Database-stored configuration (user-editable, schema evolves)
    • API response deserialization (external APIs change)
    • Multi-environment credentials (test/staging/production)
    • Optional feature flags or settings

    Rule of thumb: If a config key might be missing in production without it being a fatal error, use ?? + nullable types. If it’s always required, let it crash early with a clear error.

    Discovered while debugging a production error where test credentials were incomplete in a third-party service integration. The fix: 4 lines changed, zero data migrations required.

  • Building API Transformers with Multiple Interfaces in Laravel

    Need to transform data between two different API formats? Implement both interfaces in a single service class. This pattern creates a bridge that speaks both languages.

    The Problem

    You’re building a mobile app that expects data in Format A (clean REST), but your data source provides Format B (legacy XML-RPC or a different REST structure). You need to translate between them without rewriting either side.

    The Solution: Dual Interface Implementation

    Create a transformer service that implements both interfaces:

    <?php
    
    namespace App\Services;
    
    use App\Contracts\SourceApiInterface;  // What you're fetching from
    use App\Contracts\TargetApiInterface;  // What clients expect
    
    class DataTransformer implements SourceApiInterface, TargetApiInterface
    {
        private HttpClient $client;
        
        public function __construct(HttpClient $client)
        {
            $this->client = $client;
        }
    
        // ===================================================
        // SOURCE API METHODS (fetch from external API)
        // ===================================================
        
        public function fetchProducts(array $filters): array
        {
            // Fetch from external API
            $response = $this->client->get('/v1/items', $filters);
            return $response->json();
        }
        
        public function fetchCategories(): array
        {
            $response = $this->client->get('/v1/taxonomies');
            return $response->json();
        }
    
        // ===================================================
        // TARGET API METHODS (transform + expose to clients)
        // ===================================================
        
        public function getItems(string $category, int $limit = 20): array
        {
            // Map category to source filter
            $sourceFilters = ['taxonomy_id' => $this->mapCategory($category)];
            
            // Fetch using source API method
            $sourceData = $this->fetchProducts($sourceFilters);
            
            // Transform to target format
            return $this->transformToItems($sourceData);
        }
        
        public function getItemDetails(string $id): array
        {
            $sourceData = $this->fetchProduct($id);
            return $this->transformToItemDetails($sourceData);
        }
    
        // ===================================================
        // PRIVATE TRANSFORMATION LOGIC
        // ===================================================
        
        private function transformToItems(array $sourceProducts): array
        {
            return array_map(function ($product) {
                return [
                    'id' => $product['item_id'],
                    'title' => $product['name'],
                    'price' => $product['cost'] / 100,  // cents to dollars
                    'stock' => $product['inventory']['available'],
                ];
            }, $sourceProducts);
        }
        
        private function mapCategory(string $targetCategory): int
        {
            // Map client-facing category names to source taxonomy IDs
            return match($targetCategory) {
                'electronics' => 15,
                'books' => 8,
                'clothing' => 23,
                default => 1,
            };
        }
    }
    

    How It Works

    1. Source interface methods (fetchProducts, fetchCategories) handle raw API calls to the external service
    2. Target interface methods (getItems, getItemDetails) expose the clean API your clients expect
    3. Transformation happens in private methods that map fields, rename keys, convert units, etc.

    The transformer becomes a bidirectional adapter — it speaks the source API internally and the target API publicly.

    When to Use This Pattern

    • Wrapping legacy APIs with modern REST interfaces
    • Multi-source data aggregation where you combine APIs into one unified interface
    • API versioning — v2 interface transforms data from v1 endpoints
    • SDK replacement — your app expects Reddit API format, but you’re pulling from WordPress

    Benefits

    Type safety: Both interfaces enforce contracts at compile time

    Testability: Mock either interface independently

    Single responsibility: One class, one job — transform between two formats

    Swappability: Replace the source API without changing client code (dependency inversion)

    Real-World Example

    A mobile app needed a Reddit-style API (posts, comments, upvotes) but the content lived in WordPress. The transformer implemented both WordPressApiInterface and RedditApiInterface:

    • fetchPosts() called WordPress REST API
    • getHot() transformed WP posts into Reddit listing format
    • Mobile app saw clean Reddit JSON, never touched WordPress directly

    Key insight: When formats overlap, the target format wins. The transformer hides complexity from clients.

  • Laravel 12: Adding API Routes the Official Way

    Laravel 12 removed routes/api.php from the default skeleton to keep new projects lean. When you’re ready to build APIs, here’s the modern way to add API support.

    The Old Way (Laravel 10 and Earlier)

    Every fresh Laravel install came with routes/api.php pre-registered with the /api prefix. Most projects never used it, but it was always there.

    The Laravel 12 Way

    Run one command:

    php artisan install:api
    

    This installs:

    • routes/api.php with automatic /api/* prefix
    • Route registration in bootstrap/app.php
    • Laravel Sanctum for token-based authentication
    • Sanctum database migrations

    After running it, any route you add to routes/api.php is automatically accessible at /api/{route}.

    What Gets Generated

    Inside bootstrap/app.php, you’ll see:

    return Application::configure(basePath: dirname(__DIR__))
        ->withRouting(
            web: __DIR__.'/../routes/web.php',
            api: __DIR__.'/../routes/api.php',  // ← Added by install:api
            commands: __DIR__.'/../routes/console.php',
            health: '/up',
        )
        // ...
    

    It’s the same structure as before, just opt-in instead of default.

    Manual Alternative (No Sanctum)

    If you don’t need Sanctum (e.g., public APIs or custom auth), you can manually add API routes without installing the full package:

    // bootstrap/app.php
    return Application::configure(basePath: dirname(__DIR__))
        ->withRouting(
            web: __DIR__.'/../routes/web.php',
            api: __DIR__.'/../routes/api.php',  // ← Add manually
            commands: __DIR__.'/../routes/console.php',
            health: '/up',
        )
    

    Then create routes/api.php yourself:

    <?php
    
    use Illuminate\Support\Facades\Route;
    
    Route::get('/status', function () {
        return ['status' => 'ok'];
    });
    

    Routes in this file are automatically prefixed with /api.

    Why the Change?

    Most Laravel apps don’t need API routes. Removing them from the skeleton reduces decision fatigue for beginners and clutter for everyone else.

    When you do need APIs, install:api gives you the full setup in one step instead of hunting for documentation.

    Bottom line: Laravel 12 defaults to simple, adds complexity on demand.

  • Nginx Config for Laravel + WordPress Hybrid Apps

    Running Laravel as an API backend alongside WordPress on the same server? Here’s how to configure Nginx to route /api/* requests to Laravel while serving everything else through WordPress.

    This pattern is useful when you want Laravel’s powerful API capabilities but need WordPress for content management.

    The Nginx Configuration

    server {
        listen 80;
        server_name app.example.com;
        
        # Default to WordPress
        root /var/www/wordpress;
        index index.php index.html;
    
        # Route /api/* to Laravel
        location ~ ^/api {
            root /var/www/laravel/public;
            try_files $uri $uri/ /index.php?$query_string;
            
            location ~ \.php$ {
                fastcgi_pass php:9000;
                include fastcgi_params;
                fastcgi_param SCRIPT_FILENAME $document_root/index.php;
                fastcgi_param SCRIPT_NAME /index.php;
            }
        }
    
        # WordPress permalinks
        location / {
            try_files $uri $uri/ /index.php?$args;
        }
    
        # PHP handler for WordPress
        location ~ \.php$ {
            fastcgi_pass php:9000;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        }
    }
    

    How It Works

    The key is location priority. Nginx processes locations in this order:

    1. Exact matches (=)
    2. Prefix matches with ^~
    3. Regex matches (processed in order)
    4. Prefix matches

    The ~ ^/api regex catches API routes first, switches the root to Laravel’s public directory, and passes PHP requests to Laravel’s front controller.

    Everything else falls through to the default root (WordPress) and uses WordPress’s permalink handling.

    Why This Pattern?

    You might need this when:

    • Migrating from WordPress to Laravel incrementally
    • Building a mobile app that needs clean REST APIs but wants to keep WordPress for the marketing site
    • Your team knows WordPress for content but prefers Laravel for backend logic

    The hybrid setup lets each framework do what it does best without migration pressure.

    Gotcha: Don’t forget to change the root directive inside the /api location block. If you only set try_files without changing root, Nginx will look for Laravel files in the WordPress directory.

  • Tracing Frontend Form Fields to Backend Database Columns

    A user reports “the status field isn’t saving.” You need to find where that field is stored in the database. Here’s the fastest way to trace from frontend label to backend column in a Laravel + Vue.js app.

    The Workflow

    1. Start with the label text

    User says “status field.” Search your Vue components for that label:

    grep -r "Status" resources/js/components/

    Find the component:

    <template>
      <div>
        <label>Status</label>
        <select v-model="form.order_status">
          <option value="pending">Pending</option>
          <option value="completed">Completed</option>
        </select>
      </div>
    </template>

    2. Check the v-model binding

    v-model="form.order_status" tells you the property name. Check what gets sent to the backend:

    // In the same component or parent
    methods: {
      submitForm() {
        axios.post('/api/orders', this.form)
          .then(/* ... */)
      }
    }

    The POST payload includes order_status: 'pending'.

    3. Match to the Laravel controller

    Check your API route. The request hits OrderController@store:

    public function store(Request $request)
    {
        $validated = $request->validate([
            'order_status' => 'required|string',
            // ...
        ]);
    
        Order::create($validated);
    }

    4. Check the database column

    In most Laravel apps, the input name matches the column name. Verify in your migration or model:

    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->string('order_status');  // ← Found it
        // ...
    });

    Full path: Status label → form.order_status (Vue) → order_status (request) → order_status (column)

    When Names Don’t Match

    Sometimes frontend and backend use different names:

    <!-- Frontend: display_name -->
    <input v-model="form.display_name">
    // Backend renames it
    public function store(Request $request)
    {
        User::create([
            'name' => $request->input('display_name'),
        ]);
    }

    Check $fillable or create() calls to see the mapping.

    Bonus: Use Browser DevTools

    Open Network tab, submit the form, inspect the POST payload. You’ll see exactly what key names are sent:

    {
      "order_status": "pending",
      "customer_email": "[email protected]"
    }

    Match those keys to your database columns.

    Why This Matters

    When debugging form issues, you need the column name to:

    • Check database constraints (NOT NULL, unique, etc.)
    • Read logs filtered by that field
    • Write queries to inspect actual data
    • Verify fillable/guarded settings

    This workflow gets you from “the status field” (user language) to orders.order_status (database reality) in under a minute.

  • Variable Overwriting Bug: When $data Means Two Different Things

    Reusing variable names seems harmless until you’re deep in a debugging session wondering why your logs don’t match reality. Here’s a common trap and how to avoid it.

    The Bug

    You’re calling a payment gateway API. You build the request payload, send it, get a response. Standard stuff:

    public function processPayment($amount, $currency)
    {
        $data = [
            'amount' => $amount,
            'currency' => $currency,
            'merchant_id' => config('payment.merchant_id'),
        ];
    
        Log::info('Sending payment request', ['data' => $data]);
    
        $response = Http::post('https://api.paymentgateway.com/charge', $data);
        
        $data = $response->json();
        
        if ($data['status'] === 'success') {
            Log::info('Payment succeeded', ['data' => $data]);
            return $data['transaction_id'];
        }
        
        Log::error('Payment failed', ['data' => $data]);
        throw new PaymentException($data['message']);
    }

    Looks fine. But when you check logs after an error, you see:

    Sending payment request: {"status":"failed","message":"Invalid merchant"}
    Payment failed: {"status":"failed","message":"Invalid merchant"}

    Wait, what? The “sending” log shows the response, not the request.

    What Happened

    You logged $data before the API call, but $data got overwritten by the response on line 11. Laravel’s logger is lazy—it doesn’t serialize variables immediately. When the log actually writes, $data now holds the response, not the request.

    Result: both log entries show the same value (the response), making debugging a nightmare.

    The Fix: Use Different Variable Names

    Don’t reuse $data for two conceptually different things:

    public function processPayment($amount, $currency)
    {
        $payload = [
            'amount' => $amount,
            'currency' => $currency,
            'merchant_id' => config('payment.merchant_id'),
        ];
    
        Log::info('Sending payment request', ['payload' => $payload]);
    
        $response = Http::post('https://api.paymentgateway.com/charge', $payload);
        
        $responseData = $response->json();
        
        if ($responseData['status'] === 'success') {
            Log::info('Payment succeeded', ['response' => $responseData]);
            return $responseData['transaction_id'];
        }
        
        Log::error('Payment failed', ['response' => $responseData]);
        throw new PaymentException($responseData['message']);
    }

    Now your logs are correct:

    Sending payment request: {"amount":1000,"currency":"USD","merchant_id":"123"}
    Payment failed: {"status":"failed","message":"Invalid merchant"}

    Why This Matters

    • Logs are your debugging lifeline. If they lie, you’re lost.
    • $data, $result, $response — these names are magnets for reuse.
    • Lazy logging (common in Laravel, Monolog, etc.) means variable references get resolved later, not when you call Log::info().

    Rule of Thumb

    If you’re logging a variable, don’t overwrite it afterward. Give each conceptual “thing” its own name: $request, $response, $payload, $result.

    Debugging is hard enough. Don’t let your variable names lie to you.