Blog

  • The $array = vs $array[] Gotcha: A One-Character PHP Bug

    This one-character bug caused 300 errors over two weeks and survived three separate pull requests without anyone catching it.

    // The bug
    foreach ($items as $item) {
        $notifications = new Notification($item['title'], $item['channel']);
    }
    
    // The fix
    foreach ($items as $item) {
        $notifications[] = new Notification($item['title'], $item['channel']);
    }
    

    See it? $notifications = vs $notifications[] =. One character: [].

    What Happened

    A notification import loop was building a collection of Notification objects to pass to a service. Each iteration was supposed to append to an array. Instead, it was overwriting the variable every time.

    Result: only the last Notification object survived. The service downstream expected a collection, got a single object, and threw a TypeError.

    Why It Survived Three PRs

    Here’s the interesting part. This bug was introduced in the initial implementation and persisted through two subsequent refactors:

    1. PR #1 — Initial feature implementation. The bug shipped with it.
    2. PR #2 — Added dynamic ID logic. Touched nearby code but didn’t notice the assignment.
    3. PR #3 — Added a nested foreach around the existing loop. Reviewers focused on the new outer logic and missed the inner loop body.

    Each PR added complexity around the bug without ever looking at the bug. The nested loop actually made it harder to spot because there was more code to review.

    How It Was Caught

    A type-hinted closure caught it:

    $collection->map(function (Notification $notification) {
        // TypeError: expected Notification, got array
    });
    

    PHP’s strict type hints acted as a runtime validator. Without the type hint, the code would have silently produced wrong data instead of throwing an error.

    The Debugging Workflow

    Once the error surfaced, git blame told the full story:

    # Find who last touched the line
    git blame path/to/Handler.php -L 366,366
    
    # Check the original PR
    git show abc123
    
    # Trace backwards through each change
    git log --follow -p -- path/to/Handler.php
    

    This revealed the bug was there from day one. Not a regression — an original sin.

    The Reproduce-Before-Fix Rule

    Before applying the fix, run the failing code to confirm you can reproduce the error. Then apply the fix and run it again. Two runs:

    1. Without fix: Error reproduced. Good, you’re testing the right thing.
    2. With fix: No error. Fix confirmed.

    If you can’t reproduce the bug, you can’t be sure your fix actually addresses it.

    Lessons

    • Type hints are free runtime validators. They catch data structure bugs that unit tests might miss.
    • Code review has blind spots when nested loops add visual complexity. Reviewers naturally focus on new code.
    • git blame is archaeology. Don’t just find who to blame — trace the full history to understand why the bug persisted.
    • Always reproduce before fixing. Two runs: one to confirm the bug, one to confirm the fix.
  • afterCommit(): Dispatch Queue Jobs Only After the Transaction Commits

    You dispatch a queue job right after saving a model. The job fires, tries to look up the record… and it’s not there. The transaction hasn’t committed yet.

    This is one of those bugs that works fine locally (where your database has near-zero latency) but bites you in production with read replicas or under load.

    The Problem

    Consider this common pattern:

    DB::transaction(function () use ($report) {
        $report->status = 'generating';
        $report->save();
    
        GenerateReport::dispatch($report);
    });
    

    The job gets dispatched inside the transaction. Depending on your queue driver, the job might start executing before the transaction commits. The job tries to find the report with status = 'generating', but the database still shows the old state.

    The Fix: afterCommit()

    Laravel provides afterCommit() on dispatchable jobs to ensure the job only hits the queue after the wrapping transaction commits:

    DB::transaction(function () use ($report) {
        $report->status = 'generating';
        $report->save();
    
        GenerateReport::dispatch($report)->afterCommit();
    });
    

    Now the job waits until the transaction successfully commits before being pushed to the queue. If the transaction rolls back, the job never dispatches at all.

    Setting It Globally

    If you want all your jobs to behave this way by default, add the property to your job class:

    class GenerateReport implements ShouldQueue
    {
        use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
        public $afterCommit = true;
    
        // ...
    }
    

    Or set it in your queue.php config for the entire connection:

    // config/queue.php
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => 'default',
        'after_commit' => true,  // All jobs wait for commit
    ],
    

    When You Actually Need This

    This pattern is critical when:

    • You use read replicas — the replica might not have the committed data yet
    • Your job uses SerializesModels — it re-fetches the model from the database when deserializing
    • You dispatch jobs that depend on the data you just saved in the same transaction
    • You’re dispatching webhook notifications — the external system might call back before your DB commits

    The Gotcha

    If there’s no wrapping transaction, afterCommit() dispatches immediately — same as without it. It only delays when there’s an active transaction to wait for.

    This is a good thing. It means you can set $afterCommit = true on all your jobs without worrying about jobs that are dispatched outside transactions.

    One of those small changes that prevents a whole class of race condition bugs in production.

  • Feature Branch Subdomains: Every PR Gets Its Own URL

    Staging environments are great until you have three developers all waiting to test on the same one. Feature branch subdomains solve this: every branch gets its own isolated URL like feature-auth-refactor.staging.example.com.

    How It Works

    The setup has three parts:

    1. Wildcard DNS — Point *.staging.example.com to your staging server
    2. Wildcard SSL — One certificate covers all subdomains
    3. Dynamic Nginx config — Route each subdomain to the right container

    The DNS

    Add a single wildcard A record:

    *.staging.example.com  A  203.0.113.50

    Every subdomain now resolves to your staging server. No DNS changes needed per branch.

    The SSL Certificate

    Use Let’s Encrypt with DNS validation for wildcard certs:

    certbot certonly \
      --dns-cloudflare \
      --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
      -d "*.staging.example.com" \
      -d "staging.example.com"

    The Nginx Config

    Extract the subdomain and proxy to the matching container:

    server {
        listen 443 ssl;
        server_name ~^(?<branch>.+)\.staging\.example\.com$;
    
        ssl_certificate     /etc/letsencrypt/live/staging.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/staging.example.com/privkey.pem;
    
        location / {
            resolver 127.0.0.11 valid=10s;
            proxy_pass http://$branch:80;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }

    The regex capture (?<branch>.+) extracts the subdomain. If your CI names Docker containers after the branch slug, Nginx routes directly to them.

    The CI Pipeline

    In your CI config, deploy each branch as a named container:

    deploy_review:
      stage: deploy
      script:
        - docker compose -p "$CI_COMMIT_REF_SLUG" up -d
      environment:
        name: review/$CI_COMMIT_REF_NAME
        url: https://$CI_COMMIT_REF_SLUG.staging.example.com
        on_stop: stop_review

    Why This Beats Shared Staging

    With shared staging, you get merge conflicts, “don’t deploy, I’m testing” Slack messages, and broken environments that block everyone. With per-branch subdomains, each developer (and each PR reviewer) gets their own isolated environment. QA can test three features simultaneously. No coordination needed.

    The wildcard DNS + wildcard SSL + dynamic Nginx combo means zero manual setup per branch. Push a branch, CI deploys it, URL works automatically.

  • Make Shell Scripts Username-Portable with sed

    You write a shell script that works perfectly on your machine. You share it with the team. It breaks immediately because your username is hardcoded in every path.

    #!/bin/bash
    source /home/jake/.config/app/settings.sh
    cp /home/jake/templates/nginx.conf /etc/nginx/sites-available/

    Classic. The fix isn’t “use variables from the start” (though you should). The fix for right now is a one-liner that makes any script portable after the fact.

    The sed One-Liner

    sed -i "s|/home/jake|/home/$(whoami)|g" setup.sh

    That’s it. Every instance of /home/jake becomes /home/<current_user>. The | delimiter avoids escaping the forward slashes in paths (using / as a delimiter with paths containing / is a nightmare).

    Make It Part of Your Install

    If you distribute scripts that reference paths, add a self-patching step at the top:

    #!/bin/bash
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    CURRENT_USER="$(whoami)"
    
    # Patch all config files for the current user
    for f in "$SCRIPT_DIR"/configs/*.conf; do
        sed -i "s|/home/[a-zA-Z0-9_-]*/|/home/$CURRENT_USER/|g" "$f"
    done

    The regex /home/[a-zA-Z0-9_-]*/ matches any username in a home path, not just one specific name. Way more robust than hardcoding the original username.

    The Better Long-Term Fix

    Obviously, the real solution is to never hardcode paths in the first place:

    #!/bin/bash
    HOME_DIR="${HOME:-/home/$(whoami)}"
    CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME_DIR/.config}"
    
    source "$CONFIG_DIR/app/settings.sh"

    Use $HOME, $USER, and $XDG_CONFIG_HOME from the start. But when you’re retrofitting an existing script or inheriting someone else’s work, sed with a regex pattern gets you portable in seconds.

  • Read Replica Lag Breaks Laravel Queue Jobs Before handle() Runs

    You dispatch a queue job right after saving a model. The job picks it up in milliseconds. And then — ModelNotFoundException.

    The model definitely exists. You just created it. You can query it manually. But the queue worker says otherwise.

    The Culprit: Read Replicas

    If your database uses read replicas (and most production setups do), there’s a lag between the primary and the replicas. Usually milliseconds, sometimes longer under load.

    Laravel’s SerializesModels trait only stores the model’s ID when the job is serialized. When the worker deserializes it, it runs a fresh query — against the read replica. If the replica hasn’t caught up yet, the model doesn’t exist from the worker’s perspective.

    The cruel part: this happens before your handle() method runs. Your retry logic never fires because the job fails during deserialization.

    The Fix: afterCommit()

    Laravel has a built-in solution. Add afterCommit to your job:

    class GenerateInvoice implements ShouldQueue
    {
        use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
        public $afterCommit = true;
    
        public function handle(): void
        {
            // This now runs after the transaction commits
            // and the replica has had time to sync
        }
    }

    Or dispatch with the method:

    GenerateInvoice::dispatch($invoice)->afterCommit();

    Other Options

    If afterCommit isn’t enough (replicas can still lag after commit), you have two more tools:

    // Option 1: Add a small delay
    GenerateInvoice::dispatch($invoice)->delay(now()->addSeconds(5));
    
    // Option 2: Skip missing models instead of failing
    public $deleteWhenMissingModels = true;

    Option 2 is a silent skip — the job just disappears. Use it only when the job is truly optional (like sending a notification for a model that might get deleted).

    The Lesson

    If you’re dispatching queue jobs immediately after writes and seeing phantom ModelNotFoundException errors, check your database topology. Read replicas + SerializesModels + fast workers = a race condition that only shows up under load. afterCommit() is the cleanest fix.

  • PHP 8 Strictness: Don’t Forget to Initialize Your Strings

    PHP 8 is stricter about uninitialized variables. Using the concatenation assignment operator (.=) on a variable that hasn’t been declared will now throw an ErrorException in most modern framework environments.

    
    // Throws ErrorException in PHP 8 if $content is undefined
    foreach ($items as $item) {
        $content .= $item->name;
    }
    
    // Correct
    $content = '';
    foreach ($items as $item) {
        $content .= $item->name;
    }
    

    Always initialize your string variables before entering a loop where you append data. This ensures forward compatibility and makes your code’s intent clearer.

  • Translation Placeholders: Enable Word Order Flexibility for Localizers

    When defining translation strings in Laravel, always use named placeholders (like :count or :percent) instead of positional ones or manual string concatenation.

    
    // Bad
    __('You have ' . $count . ' notifications');
    
    // Good
    __('You have :count notifications', ['count' => $count]);
    

    Different languages have varying word orders; named placeholders allow translators to move variables within the sentence without breaking the logic or requiring code changes for each locale.

  • Frontend/Backend Parity: Treat Your UI Logic as the Specification

    When duplicating complex UI validation or calculation logic on the backend, treat the frontend component as the source of truth. Copy the logic line-by-line into your PHP services, and include comments with the original file and line numbers.

    
    // Replicating logic from ProductForm.vue:142
    if ($data['price'] > 0) {
        // ...
    }
    

    This makes future parity checks easier and ensures your backend handles edge cases exactly as the user experience defines them. It also serves as great documentation for why certain backend checks exist.

  • Stop Passing App::getLocale() to Laravel’s __() Helper

    I’ve seen this pattern in multiple Laravel codebases — a translation helper that manually fetches the locale before passing it to the translation function:

    // Don't do this
    $locale = App::getLocale();
    $label = __('orders.status_label', [], $locale);

    That third parameter is unnecessary. The __() helper already calls App::getLocale() internally when no locale is provided.

    How __() Actually Works

    Under the hood, __() delegates to the Translator’s get() method:

    // Illuminate\Translation\Translator::get()
    public function get($key, array $replace = [], $locale = null, $fallback = true)
    {
        $locale = $locale ?: $this->locale;
        // ...
    }

    When $locale is null (the default), it uses $this->locale — which is the application locale set by App::setLocale(). It’s the same value App::getLocale() returns.

    So the clean version is just:

    // Do this instead
    $label = __('orders.status_label');

    When You DO Need the Locale Parameter

    The third parameter exists for a reason — when you need a translation in a specific locale that differs from the current one:

    // Sending an email in the user's preferred language
    $subject = __('emails.welcome_subject', [], $user->preferred_locale);
    
    // Generating a PDF in a specific language regardless of current request
    $label = __('invoice.total', [], 'ja');

    These are legitimate uses. The anti-pattern is fetching the current locale just to pass it right back.

    The Compound Version

    This gets worse when the manual locale fetch spreads across a method:

    // This entire method is doing unnecessary work
    public function getLabels()
    {
        $locale = App::getLocale();
        
        return [
            'name'    => __('fields.name', [], $locale),
            'email'   => __('fields.email', [], $locale),
            'phone'   => __('fields.phone', [], $locale),
            'address' => __('fields.address', [], $locale),
        ];
    }

    Every single $locale parameter is redundant. This should be:

    public function getLabels()
    {
        return [
            'name'    => __('fields.name'),
            'email'   => __('fields.email'),
            'phone'   => __('fields.phone'),
            'address' => __('fields.address'),
        ];
    }

    Same output, less noise, fewer places to introduce bugs. The framework already handles locale resolution — let it do its job.

  • The Hidden public/hot File in Laravel Mix HMR

    You run npm run hot and Laravel Mix starts a webpack dev server with Hot Module Replacement. Your browser auto-refreshes when you edit Vue components or CSS. Magic. But have you ever wondered how Laravel knows to serve assets from the dev server instead of the compiled files in public/?

    The answer is a tiny file you’ve probably never noticed: public/hot.

    What public/hot Does

    When you run npm run hot, Laravel Mix creates a file at public/hot. It contains the dev server URL — typically http://localhost:8080.

    Laravel’s mix() helper checks for this file on every request:

    // Simplified version of what mix() does internally
    if (file_exists(public_path('hot'))) {
        $devServerUrl = rtrim(file_get_contents(public_path('hot')));
        return $devServerUrl . $path;
    }
    
    // No hot file? Serve from mix-manifest.json as normal
    return $manifest[$path];

    So when public/hot exists, mix('js/app.js') returns http://localhost:8080/js/app.js instead of /js/app.js?id=abc123.

    When This Bites You

    The classic gotcha: you kill the dev server with Ctrl+C, but the public/hot file doesn’t get cleaned up. Now your app is trying to load assets from a server that doesn’t exist.

    # Symptoms: blank page, console full of ERR_CONNECTION_REFUSED
    # Fix:
    rm public/hot

    Add it to your troubleshooting checklist. If assets suddenly stop loading after you were running HMR, check if public/hot is still hanging around.

    Why This Matters for Teams

    The public/hot file should always be in your .gitignore. If someone accidentally commits it, everyone else’s app will try to load assets from localhost:8080 — which won’t be running on their machines.

    # .gitignore
    public/hot

    Most Laravel projects already have this, but if you bootstrapped your project a long time ago or generated your .gitignore manually, double-check.

    It’s a tiny file with a simple job, but understanding it saves you 20 minutes of confused debugging when HMR stops working or your assets vanish after killing the dev server.