Blog

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

  • Code Archaeology: How to Reverse-Engineer a Complex Operation

    Code Archaeology: How to Reverse-Engineer a Complex Operation

    You join a project mid-flight. There’s a complex operation that creates records, updates statuses, sends notifications, and touches three different services. You need to build the reverse of it. Nobody wrote docs.

    Welcome to code archaeology.

    The Approach That Actually Works

    Don’t start by reading the code top-to-bottom. Start by finding the entry point and tracing outward.

    # Find where the operation starts
    grep -rn "createOrder\|placeOrder\|submitOrder" app/ --include="*.php" -l
    
    # Find what events it fires
    grep -rn "event(\|dispatch(" app/Services/OrderService.php
    
    # Find what listeners react
    grep -rn "OrderCreated\|OrderPlaced" app/Listeners/ -l

    Build a map as you go. I literally open a scratch file and write:

    OrderService::create()
      -> validates input
      -> creates DB record
      -> fires OrderCreated event
         -> SendConfirmationEmail (listener)
         -> UpdateInventory (listener)
         -> NotifyWarehouse (listener)
      -> returns response

    Repository Pattern Makes This Harder

    If the codebase uses the repository pattern, the actual logic might be buried two or three layers deep. The controller calls the service, the service calls the repository, the repository has the Eloquent query. Grep is your best friend here.

    # When you can't find where the actual DB write happens
    grep -rn "->save()\|->create(\|->insert(" app/Repositories/ --include="*.php"

    The Undo Operation

    Once you have the map, building the reverse is mechanical. Each step in the forward operation needs a corresponding undo step, executed in reverse order. The hard part was never the coding. It was understanding what the original code actually does.

    Next time you’re staring at a method that calls six other methods across four files, resist the urge to “just figure it out” in your head. Write the map. It takes five minutes and saves five hours.

  • Per-Step Try/Catch: Don’t Let One Bad Record Kill Your Entire Batch

    Per-Step Try/Catch: Don’t Let One Bad Record Kill Your Entire Batch

    Last week I had an Artisan command that processed about 2,000 records. The first version used a transaction wrapper — if any single record failed, the whole batch rolled back. Clean, right?

    Except when record #1,847 hit an edge case, all 1,846 successful records got nuked. That’s not clean. That’s a landmine.

    The Fix: Per-Step Try/Catch

    Instead of wrapping the entire loop in one big try/catch, wrap each iteration individually:

    $records->each(function ($record) {
        try {
            $this->processRecord($record);
            $this->info("✅ Processed #{$record->id}");
        } catch (\Throwable $e) {
            $this->error("❌ Failed #{$record->id}: {$e->getMessage()}");
            Log::error("Batch process failed", [
                'record_id' => $record->id,
                'error' => $e->getMessage(),
            ]);
        }
    });

    Why This Matters

    The all-or-nothing approach feels safer because it’s “atomic.” But for batch operations where each record is independent, it’s actually worse. One bad record shouldn’t hold 1,999 good ones hostage.

    The status symbols (✅/❌) aren’t just cute either. When you’re watching a command chug through thousands of records, that visual feedback tells you instantly if something’s going sideways without reading log files.

    When to Use Which

    Use transactions (all-or-nothing) when records depend on each other. Think: transferring money between accounts, or creating a parent record with its children.

    Use per-step try/catch when each record is independent. Think: sending notification emails, syncing external data, or migrating legacy records.

    The pattern is simple but I’ve seen teams default to transactions for everything. Sometimes the safest thing is to let the failures fail and keep the successes.