<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="/feed.xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        <title>CSlant</title>
        <link>https://cslant.com</link>
        <atom:link href="https://cslant.com/feed" rel="self" type="application/rss+xml" />
        <description></description>
        <language>en</language>
                <item>
            <title>Cache Stampede Prevention: Locks, XFetch, Stale-While-Revalidate and Laravel Cache::flexible</title>
            <link>https://cslant.com/tips/cache-stampede-prevention-locks-xfetch-stale-while-revalidate-and-laravel-cacheflexible</link>
            <guid isPermaLink="false">https://cslant.com/tips/cache-stampede-prevention-locks-xfetch-stale-while-revalidate-and-laravel-cacheflexible</guid>
                        <pubDate>Thu, 12 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>Why a hot key expiring can melt your database, and four fixes: Cache::lock with double-check, XFetch early expiration, Cache::flexible, and TTL jitter.</description>
                                    <content:encoded><![CDATA[<p>The leaderboard query takes 4 seconds, so it is cached for 5 minutes with <code>Cache::remember()</code>. Most of the time the page renders in 30 ms. Every 5 minutes, at the moment the key expires, the database CPU goes to 100%, p99 latency hits 20 seconds and a few requests fail. During those 4 seconds of recomputation, every request that arrives sees a cache miss and starts the same 4-second query. At 300 requests per second that is 1,200 identical queries fighting each other, each one slower because of the others.</p>
<p>That is a cache stampede, also called the dogpile effect or thundering herd. The cache works perfectly at steady state and creates a synchronized burst of load exactly when it expires. There are four standard defenses: locking so only one caller recomputes, probabilistic early expiration (XFetch) so recomputation happens <em>before</em> expiry, stale-while-revalidate so users never wait for it, and TTL jitter so thousands of keys do not expire together. Laravel ships two of them out of the box.</p>
<h2>Where the Herd Comes From</h2>
<pre><code class="language-php">namespace App\Http\Controllers;

use App\Services\Leaderboard;
use Illuminate\Support\Facades\Cache;

final class LeaderboardController
{
    public function __invoke(Leaderboard $board): array
    {
        // 4 second query, cached for 5 minutes. Every 5 minutes, every request
        // that arrives during those 4 seconds runs the query too.
        return Cache::remember('leaderboard:global', 300, fn () =&gt; $board-&gt;compute());
    }
}
</code></pre>
<p><code>Cache::remember()</code> is "get, and on miss compute and put". It has no coordination between processes. The more expensive the computation and the higher the traffic, the more callers land in the miss window: the expected number of duplicate computations is roughly request rate times computation time. A 4-second query at 300 rps means about 1,200 copies. A 50 ms query at 20 rps is one copy, which is why stampedes only show up on hot, expensive keys and are easy to miss in staging.</p>
<p>The same thing happens at a larger scale after a cache flush, a Redis failover, or a deploy that changes key prefixes: every key is cold at once.</p>
<h2>The Four Defenses at a Glance</h2>
<div class="page-mermaid"><div class="mermaid">flowchart TD
    A[Request reads key] --&gt; B{Value in cache?}
    B --&gt;|fresh| C[Return value]
    B --&gt;|stale but usable| D[Return stale value]
    D --&gt; E{Got refresh lock?}
    E --&gt;|yes| F[Recompute in background and store]
    E --&gt;|no| G[Someone else is refreshing]
    B --&gt;|missing| H{Got compute lock?}
    H --&gt;|yes| F
    H --&gt;|no| I[Wait briefly then read again]</div></div>
<ul>
<li><strong>Lock on miss</strong>: the first caller takes a lock and recomputes; others wait or get a fallback. Guarantees one computation per expiry, at the cost of waiting.</li>
<li><strong>Probabilistic early expiration</strong>: each reader, with a probability that rises as expiry approaches, decides to recompute early. One reader usually refreshes the key while everyone else still gets a hit. No locks needed.</li>
<li><strong>Stale-while-revalidate</strong>: keep serving the old value past its freshness window while a single refresh runs in the background. Users never wait.</li>
<li><strong>TTL jitter</strong>: randomize expiry times so keys populated together do not expire together.</li>
</ul>
<h2>Defense 1: A Lock with a Double-Check</h2>
<p>Laravel's atomic locks work on Redis, Memcached, DynamoDB, the database and the array store. <code>block()</code> waits up to N seconds to acquire the lock and throws <code>LockTimeoutException</code> if it cannot.</p>
<pre><code class="language-php">namespace App\Cache;

use Closure;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;

final class LockedRemember
{
    /**
     * Only one process recomputes a missing key; the others wait briefly
     * and read the value the winner stored.
     */
    public static function get(string $key, int $ttl, Closure $compute, int $waitSeconds = 5): mixed
    {
        $value = Cache::get($key);
        if ($value !== null) {
            return $value;
        }

        try {
            return Cache::lock("lock:{$key}", 30)-&gt;block($waitSeconds, function () use ($key, $ttl, $compute) {
                // Double-check: the previous lock holder probably filled it already.
                $value = Cache::get($key);
                if ($value !== null) {
                    return $value;
                }

                $value = $compute();
                Cache::put($key, $value, $ttl);

                return $value;
            });
        } catch (LockTimeoutException) {
            // The winner is too slow. Degrade instead of piling onto the database.
            return Cache::get($key) ?? throw new \RuntimeException("{$key} unavailable, try again");
        }
    }
}
</code></pre>
<p>Two details make or break this pattern. The <strong>double-check</strong> inside the lock: by the time a waiting process gets the lock, the winner has already stored the value, so the waiter must read again instead of recomputing. And the <strong>lock TTL</strong> (30 seconds here) must be longer than the worst-case compute time, otherwise the lock expires mid-computation and a second process starts. If compute time is unpredictable, prefer an owner-checked release (Laravel's <code>release()</code> only frees a lock you own) and a generous TTL.</p>
<p>The weakness is waiting. With 1,200 waiting requests, you have traded a database stampede for a pile of PHP-FPM workers blocked on a lock. That is better, but on a really hot key you want nobody to wait.</p>
<h2>Defense 2: Probabilistic Early Expiration (XFetch)</h2>
<p>XFetch, from the 2015 VLDB paper <em>Optimal Probabilistic Cache Stampede Prevention</em> by Vattani, Chierichetti and Lowenstein, stores two extra numbers with each value: <code>delta</code>, how long the last computation took, and <code>expiry</code>. On every read it recomputes when</p>
<pre><code class="language-text">now - delta * beta * ln(random()) &gt;= expiry      with random() in (0, 1]
</code></pre>
<p><code>-ln(random())</code> is an exponentially distributed number with mean 1, so each reader pretends the clock is a little later than it is. Far from expiry, almost nobody crosses the line. As expiry approaches, the probability rises smoothly, and it rises <em>earlier</em> for keys that are expensive to compute (bigger <code>delta</code>), which is exactly where you need the head start. <code>beta</code> tunes eagerness: 1.0 is the paper's recommendation, values above 1 favor earlier refreshes.</p>
<pre><code class="language-php">namespace App\Cache;

use Closure;
use Illuminate\Contracts\Cache\Repository;

/**
 * Probabilistic early expiration (XFetch, Vattani et al., VLDB 2015).
 * Each entry stores the value, how long it took to compute (delta) and its logical expiry.
 */
final class XFetch
{
    public function __construct(
        private Repository $cache,
        private float $beta = 1.0,
        private ?Closure $clock = null,
        private ?Closure $random = null,
    ) {
        $this-&gt;clock ??= fn (): float =&gt; microtime(true);
        $this-&gt;random ??= fn (): float =&gt; mt_rand(1, mt_getrandmax()) / mt_getrandmax(); // (0, 1]
    }

    public function remember(string $key, int $ttl, Closure $compute): mixed
    {
        $entry = $this-&gt;cache-&gt;get($key);
        $now = ($this-&gt;clock)();

        if (is_array($entry)) {
            [$value, $delta, $expiry] = $entry;
            // log(random) &lt;= 0, so this subtracts a random head start that grows
            // with compute time (delta) and with beta.
            if ($now - $delta * $this-&gt;beta * log(($this-&gt;random)()) &lt; $expiry) {
                return $value;
            }
        }

        $start = ($this-&gt;clock)();
        $value = $compute();
        $delta = ($this-&gt;clock)() - $start;

        // Keep the physical TTL a bit longer than the logical one so late
        // readers still find the old value while someone recomputes.
        $this-&gt;cache-&gt;put($key, [$value, $delta, $start + $delta + $ttl], $ttl + (int) ceil($delta) + 60);

        return $value;
    }
}
</code></pre>
<p>There are no locks, no waiting and no background workers. Keep the physical TTL longer than the logical expiry so that the value stays readable while one reader recomputes.</p>
<h2>Defense 3: Stale-While-Revalidate with <code>Cache::flexible</code></h2>
<p>Since Laravel 11, <code>Cache::flexible()</code> implements stale-while-revalidate directly. You give it two durations: how long the value is fresh, and how long a stale value may still be served.</p>
<pre><code class="language-php">use App\Services\Leaderboard;
use Illuminate\Support\Facades\Cache;

// Fresh for 60s. Between 60s and 300s the stale value is returned immediately
// and ONE refresh runs after the response is sent. After 300s it is a normal miss.
$board = Cache::flexible('leaderboard:global', [60, 300], function () {
    return app(Leaderboard::class)-&gt;compute();
});
</code></pre>
<p>Inside the fresh window it is a normal hit. Inside the stale window the caller gets the old value immediately, and Laravel schedules a refresh with <code>defer()</code>, so it runs after the response has been sent to the user, guarded by a lock so only one process refreshes. Past the stale window it behaves like <code>remember()</code>. Pick the stale window based on how wrong the data may be, not on performance: a leaderboard can be 5 minutes stale, an account balance cannot be stale at all.</p>
<p>Note that deferred work runs in the same PHP-FPM worker after the response, so it still occupies that worker. Keep the refresh itself reasonably fast, or dispatch a queued job from the closure for very slow recomputations.</p>
<h2>Defense 4: TTL Jitter</h2>
<p>Locks and XFetch protect one hot key. Jitter protects you from thousands of medium-hot keys that were written at the same moment, typically by a cache warmer or a deploy:</p>
<pre><code class="language-php">use Illuminate\Support\Facades\Cache;

// 10,000 product pages warmed by the same deploy expire at the same second
// unless you spread the TTLs out.
function ttlWithJitter(int $base, float $spread = 0.1): int
{
    $delta = (int) round($base * $spread);

    return $base + random_int(-$delta, $delta);
}

Cache::put('product:42', ['id' =&gt; 42], ttlWithJitter(3600));
</code></pre>
<p>Ten percent spread is usually enough. Combine it with any of the other three.</p>
<h2>Testing Stampede Protection</h2>
<p>The key to testing XFetch is injecting the clock and the random source, so "unlucky" and "lucky" draws are deterministic. <code>exp(-5)</code> as the random value produces a head start of exactly <code>5 * delta</code>. The lock tests run on the array store, which supports atomic locks: one checks that the value is computed once and reused, the other holds the lock as if another process were mid-computation and asserts that the caller gives up after the wait instead of running the query.</p>
<pre><code class="language-php">// tests/Unit/XFetchTest.php
use App\Cache\LockedRemember;
use App\Cache\XFetch;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;

beforeEach(function () {
    $this-&gt;now = 1000.0;
    $this-&gt;rand = 1.0;
    $this-&gt;calls = 0;
    $this-&gt;cache = new XFetch(
        new Repository(new ArrayStore()),
        beta: 1.0,
        clock: fn () =&gt; $this-&gt;now,
        random: fn () =&gt; $this-&gt;rand,
    );
    $this-&gt;compute = function () {
        $this-&gt;calls++;
        $this-&gt;now += 2.0; // the expensive query takes 2 seconds

        return "v{$this-&gt;calls}";
    };
});

it('computes once on a miss and then serves from cache', function () {
    expect($this-&gt;cache-&gt;remember('k', 60, $this-&gt;compute))-&gt;toBe('v1');
    $this-&gt;now += 10;
    expect($this-&gt;cache-&gt;remember('k', 60, $this-&gt;compute))-&gt;toBe('v1')
        -&gt;and($this-&gt;calls)-&gt;toBe(1);
});

it('recomputes early when the random draw is unlucky', function () {
    $this-&gt;cache-&gt;remember('k', 60, $this-&gt;compute);  // expiry = 1062
    $this-&gt;now = 1055.0;
    $this-&gt;rand = exp(-5);                             // head start = 2 * 5 = 10s
    expect($this-&gt;cache-&gt;remember('k', 60, $this-&gt;compute))-&gt;toBe('v2');
});

it('never recomputes early with a lucky draw', function () {
    $this-&gt;cache-&gt;remember('k', 60, $this-&gt;compute);
    $this-&gt;now = 1061.0;
    $this-&gt;rand = 1.0;                                 // log(1) = 0, no head start
    expect($this-&gt;cache-&gt;remember('k', 60, $this-&gt;compute))-&gt;toBe('v1');
});

it('computes once and reuses the stored value', function () {
    $runs = 0;
    $compute = function () use (&amp;$runs) {
        $runs++;

        return 'fresh';
    };

    foreach (range(1, 20) as $_) {
        expect(LockedRemember::get('report', 60, $compute))-&gt;toBe('fresh');
    }
    expect($runs)-&gt;toBe(1);
});

it('does not compute while another process holds the lock', function () {
    $runs = 0;
    Cache::lock('lock:report', 30)-&gt;get();   // simulate the winner, still computing

    expect(fn () =&gt; LockedRemember::get('report', 60, function () use (&amp;$runs) {
        $runs++;

        return 'fresh';
    }, waitSeconds: 1))-&gt;toThrow(RuntimeException::class);

    expect($runs)-&gt;toBe(0);
});
</code></pre>
<p>These tests prove the logic. For confidence under real concurrency, run a short load test (k6, <code>ab</code>, or <code>hey</code>) against a staging endpoint with a tiny TTL and count how many times the computation runs, for example with a Redis <code>INCR</code> inside the closure.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>No double-check after acquiring the lock.</strong> Every waiter recomputes in turn: you serialized the stampede instead of preventing it.</li>
<li><strong>Lock TTL shorter than compute time.</strong> The lock expires, a second process enters, and the first one later releases a lock it no longer owns (Laravel's owner check stops that release, but the duplicate work already happened).</li>
<li><strong>Caching <code>null</code> or <code>false</code> with <code>remember()</code>.</strong> A computation that legitimately returns <code>null</code> is never cached, so every request misses. Wrap it, for example cache an empty array or a sentinel object.</li>
<li><strong>Using <code>random()</code> that can return 0.</strong> <code>ln(0)</code> is negative infinity and forces a recompute on every read. Draw from (0, 1].</li>
<li><strong>Stale windows on data that must be correct.</strong> Stale-while-revalidate is a product decision. Prices at checkout, permissions and balances need invalidation on write, not stale reads.</li>
<li><strong>Forgetting cold starts.</strong> All four defenses assume most keys are warm. After a full flush, warm the hottest keys before shifting traffic, or rate-limit the recompute path.</li>
<li><strong>Holding locks across slow external calls.</strong> If the computation calls a third-party API, a timeout there blocks every waiter. Set explicit HTTP timeouts shorter than the lock TTL.</li>
</ul>
<h2>When to Use Which, and When Not To</h2>
<ul>
<li><strong>Low traffic or cheap computation</strong>: plain <code>remember()</code> is fine. Do not add locks to a 20 ms query hit twice per minute.</li>
<li><strong>Hot key, staleness acceptable</strong>: <code>Cache::flexible()</code>. It is the easiest win in a Laravel app and users never wait.</li>
<li><strong>Hot key, expensive computation, no background work available</strong> (CLI, long-running workers, other frameworks): XFetch.</li>
<li><strong>Must never compute twice</strong> (calls to a paid API, rate-limited upstream): a lock with double-check, possibly combined with flexible.</li>
<li><strong>Many keys written together</strong>: add jitter regardless of the other choices.</li>
</ul>
<p>Do not use caching tricks to hide a query that should be fixed. If the leaderboard takes 4 seconds, a summary table updated incrementally may remove the need for any of this.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Secure Next.js Server Actions: zod Validation, useActionState, Error Handling and Auth Checks</title>
            <link>https://cslant.com/tips/secure-nextjs-server-actions-zod-validation-useactionstate-error-handling-and-auth-checks</link>
            <guid isPermaLink="false">https://cslant.com/tips/secure-nextjs-server-actions-zod-validation-useactionstate-error-handling-and-auth-checks</guid>
                        <pubDate>Wed, 11 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Next.js</category>
                                    <description>A Server Action is a public POST endpoint, not a private function. Authenticate, validate with zod, authorize per resource and return safe errors to useActionState.</description>
                                    <content:encoded><![CDATA[<p>A team ships a "rename project" form with a Server Action. The page that renders the form checks that you are logged in and own the project, so the action itself just reads <code>projectId</code> and <code>title</code> from the form and runs an update. A week later someone notices project names changing to garbage across unrelated accounts. Nobody hacked the page. They called the action directly, with a different <code>projectId</code>, because a Server Action is an HTTP endpoint and the page's checks never ran.</p>
<p>Server Actions make mutations feel like calling a function, and that is precisely the danger. Everything you would do in a controller (authenticate, validate, authorize, handle errors without leaking internals) still has to happen, but now it has to happen <em>inside the function</em>, every time. This tip builds that pattern with zod and React 19's <code>useActionState</code>.</p>
<h2>What a Server Action Really Is</h2>
<p>When you mark a function with <code>'use server'</code> and pass it to a form's <code>action</code>, Next.js gives it an ID and exposes it as a POST endpoint. The client sends the action ID and the serialized arguments; the server looks up the function and runs it. Some consequences follow directly:</p>
<ul>
<li>The action can be invoked by anyone who can send a POST to your app, with any arguments. The UI that normally calls it is not a security boundary.</li>
<li><code>FormData</code> values are strings (or files) controlled by the client. Hidden inputs are just inputs.</li>
<li>Checks done in the page or layout that rendered the form do not run when the action is called.</li>
</ul>
<p>Here is the action from the intro. It looks harmless and is completely open:</p>
<pre><code class="language-ts">// app/projects/[id]/actions.ts  (do NOT ship this)
'use server'

import { db } from '@/lib/db'

export async function renameProject(formData: FormData) {
  const id = formData.get('projectId') as string
  const title = formData.get('title') as string

  // No session check, no ownership check, no validation:
  // anyone who can POST to this page can rename ANY project to ANY string.
  await db.project.update({ where: { id }, data: { title } })
}
</code></pre>
<p>Next.js does add some protection: actions only accept POST, the framework compares the <code>Origin</code> header with the host to block cross-site calls, and action IDs are not guessable from outside the client bundle. None of that knows who the user is or what they may change.</p>
<h2>The Four Steps Every Action Needs</h2>
<p>Treat each action like a public controller method. In order:</p>
<div class="page-mermaid"><div class="mermaid">graph TD
A[Form submit] --&gt; B[Server Action POST]
B --&gt; C[Check session]
C --&gt;|no user| X[Redirect to login]
C --&gt;|signed in| D[Validate with zod]
D --&gt;|invalid| E[Return field errors]
D --&gt;|valid| F[Check ownership]
F --&gt;|not allowed| G[Return not found]
F --&gt;|allowed| H[Write to database]
H --&gt; I[Revalidate and return success]</div></div>
<p>Authentication comes first so anonymous traffic never reaches your validation or database code. Validation comes before authorization so the ownership query receives clean, typed input. Authorization is per resource: "is logged in" is not the same as "may edit project 7".</p>
<h2>Validating Input With zod</h2>
<p>Put the schema in a shared module so the server action and, if you want, client-side hints use the same rules. Note the <code>trim()</code> before <code>min()</code>: a title of five spaces should fail.</p>
<pre><code class="language-ts">// lib/validation/project.ts
import { z } from 'zod'

export const renameProjectSchema = z.object({
  projectId: z.string().regex(/^\d+$/, 'Invalid project'),
  title: z
    .string()
    .trim()
    .min(3, 'Title must be at least 3 characters')
    .max(80, 'Title must be at most 80 characters'),
})

export type RenameProjectInput = z.infer&lt;typeof renameProjectSchema&gt;

export type ActionState = {
  status: 'idle' | 'success' | 'error'
  message: string
  fieldErrors: Partial&lt;Record&lt;keyof RenameProjectInput, string[]&gt;&gt;
  values?: { title: string }
}

export const initialState: ActionState = { status: 'idle', message: '', fieldErrors: {} }
</code></pre>
<p><code>ActionState</code> is the contract between the action and the form. It is plain serializable data: a status, a human message, per-field errors, and the values to put back in the form when validation fails.</p>
<h2>The Secure Action</h2>
<pre><code class="language-ts">// app/projects/[id]/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { logger } from '@/lib/logger'
import { type ActionState, renameProjectSchema } from '@/lib/validation/project'

export async function renameProject(_prev: ActionState, formData: FormData): Promise&lt;ActionState&gt; {
  // 1. Authenticate: this function is a public POST endpoint.
  const user = await getCurrentUser()
  if (!user) redirect('/login')

  // 2. Validate: FormData is untrusted input, whatever the form looks like.
  const parsed = renameProjectSchema.safeParse({
    projectId: formData.get('projectId'),
    title: formData.get('title'),
  })

  if (!parsed.success) {
    return {
      status: 'error',
      message: 'Please fix the highlighted fields.',
      fieldErrors: z.flattenError(parsed.error).fieldErrors,
      values: { title: String(formData.get('title') ?? '') },
    }
  }

  const { projectId, title } = parsed.data

  // 3. Authorize: the user must own THIS project. Same answer for "missing" and "not yours".
  const project = await db.project.findFirst({ where: { id: projectId, ownerId: user.id } })
  if (!project) {
    return { status: 'error', message: 'Project not found.', fieldErrors: {} }
  }

  // 4. Mutate, and translate failures into safe messages.
  try {
    await db.project.update({ where: { id: project.id }, data: { title } })
  } catch (error) {
    logger.error({ err: error, projectId, userId: user.id }, 'renameProject failed')
    return { status: 'error', message: 'Could not save the project. Please try again.', fieldErrors: {} }
  }

  revalidatePath(`/projects/${project.id}`)

  return { status: 'success', message: 'Project renamed.', fieldErrors: {} }
}
</code></pre>
<p>A few details matter here:</p>
<ul>
<li><strong><code>redirect()</code> sits outside any <code>try/catch</code>.</strong> It works by throwing a special error that Next.js handles. Wrapping it in <code>try/catch</code> swallows the redirect. The same applies to <code>notFound()</code>.</li>
<li><strong>Ownership is part of the query.</strong> <code>findFirst({ where: { id, ownerId } })</code> returns nothing for both "does not exist" and "belongs to someone else", so the response does not reveal which project IDs exist.</li>
<li><strong>Errors are logged with context and replaced by a generic message.</strong> Database errors can contain hostnames, SQL and constraint names. In production, Next.js already hides thrown error messages from the client, but returning a typed error state is better UX than letting the action throw into an error boundary.</li>
<li><strong>Expected errors are returned, unexpected ones are thrown.</strong> Validation failures and "not found" are part of the normal flow and come back as state. A bug in your code should still throw and hit <code>error.tsx</code> and your error tracker.</li>
</ul>
<h2>The Form With useActionState</h2>
<p>React 19's <code>useActionState</code> wires the action to local state. It returns the latest state, a wrapped action to pass to the form, and a pending flag:</p>
<pre><code class="language-tsx">// app/projects/[id]/rename-form.tsx
'use client'

import { useActionState } from 'react'
import { initialState } from '@/lib/validation/project'
import { renameProject } from './actions'

export function RenameForm({ projectId, title }: { projectId: string; title: string }) {
  const [state, formAction, isPending] = useActionState(renameProject, initialState)

  return (
    &lt;form action={formAction} noValidate&gt;
      &lt;input type="hidden" name="projectId" value={projectId} /&gt;

      &lt;label htmlFor="title"&gt;Title&lt;/label&gt;
      &lt;input
        id="title"
        name="title"
        defaultValue={state.values?.title ?? title}
        aria-invalid={Boolean(state.fieldErrors.title)}
        aria-describedby="title-error"
      /&gt;
      &lt;p id="title-error" role="alert"&gt;{state.fieldErrors.title?.[0]}&lt;/p&gt;

      &lt;button type="submit" disabled={isPending}&gt;
        {isPending ? 'Saving…' : 'Save'}
      &lt;/button&gt;

      {state.message &amp;&amp; (
        &lt;p aria-live="polite" data-status={state.status}&gt;{state.message}&lt;/p&gt;
      )}
    &lt;/form&gt;
  )
}
</code></pre>
<p>The action receives the previous state as its first argument and the <code>FormData</code> as the second, which is why the signature is <code>(_prev, formData)</code>. React resets uncontrolled form fields after an action completes, so on validation errors the action sends back <code>values.title</code> and the input uses it as <code>defaultValue</code>; the user does not lose what they typed. The hidden <code>projectId</code> is convenient for the UI and still untrusted on the server.</p>
<p>If you have many actions, the authentication and validation steps become boilerplate. A small wrapper keeps them impossible to forget, while leaving authorization inside each handler, where the resource is known:</p>
<pre><code class="language-ts">// lib/safe-action.ts
import 'server-only'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { getCurrentUser, type User } from '@/lib/auth'

export type Result&lt;TFields extends string&gt; = {
  status: 'idle' | 'success' | 'error'
  message: string
  fieldErrors: Partial&lt;Record&lt;TFields, string[]&gt;&gt;
}

export function authedAction&lt;S extends z.ZodObject&gt;(
  schema: S,
  handler: (input: z.infer&lt;S&gt;, user: User) =&gt; Promise&lt;Result&lt;Extract&lt;keyof z.infer&lt;S&gt;, string&gt;&gt;&gt;,
) {
  return async (_prev: unknown, formData: FormData) =&gt; {
    const user = await getCurrentUser()
    if (!user) redirect('/login')

    const parsed = schema.safeParse(Object.fromEntries(formData))
    if (!parsed.success) {
      return {
        status: 'error' as const,
        message: 'Please fix the highlighted fields.',
        fieldErrors: z.flattenError(parsed.error).fieldErrors,
      }
    }

    return handler(parsed.data, user)
  }
}
</code></pre>
<h2>Testing Server Actions</h2>
<p>A Server Action is an async function, so you can test it like one. Mock the framework and data modules with Vitest and assert on the returned state and on what was <em>not</em> called:</p>
<pre><code class="language-ts">// app/projects/[id]/actions.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('next/cache', () =&gt; ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () =&gt; ({
  redirect: vi.fn(() =&gt; {
    throw new Error('NEXT_REDIRECT')
  }),
}))
vi.mock('@/lib/auth', () =&gt; ({ getCurrentUser: vi.fn() }))
vi.mock('@/lib/logger', () =&gt; ({ logger: { error: vi.fn() } }))
vi.mock('@/lib/db', () =&gt; ({ db: { project: { findFirst: vi.fn(), update: vi.fn() } } }))

import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { initialState } from '@/lib/validation/project'
import { renameProject } from './actions'

const form = (fields: Record&lt;string, string&gt;) =&gt; {
  const fd = new FormData()
  for (const [k, v] of Object.entries(fields)) fd.set(k, v)
  return fd
}

describe('renameProject', () =&gt; {
  beforeEach(() =&gt; {
    vi.clearAllMocks()
    vi.mocked(getCurrentUser).mockResolvedValue({ id: 'u1' } as never)
  })

  it('redirects anonymous users before touching the database', async () =&gt; {
    vi.mocked(getCurrentUser).mockResolvedValue(null)
    await expect(renameProject(initialState, form({ projectId: '1', title: 'New' }))).rejects.toThrow('NEXT_REDIRECT')
    expect(db.project.findFirst).not.toHaveBeenCalled()
  })

  it('returns field errors for invalid input', async () =&gt; {
    const state = await renameProject(initialState, form({ projectId: '1', title: ' x ' }))
    expect(state.status).toBe('error')
    expect(state.fieldErrors.title?.[0]).toMatch(/at least 3/)
    expect(db.project.update).not.toHaveBeenCalled()
  })

  it("refuses to rename someone else's project", async () =&gt; {
    vi.mocked(db.project.findFirst).mockResolvedValue(null)
    const state = await renameProject(initialState, form({ projectId: '7', title: 'Hijacked' }))
    expect(state).toMatchObject({ status: 'error', message: 'Project not found.' })
    expect(db.project.findFirst).toHaveBeenCalledWith({ where: { id: '7', ownerId: 'u1' } })
    expect(db.project.update).not.toHaveBeenCalled()
  })

  it('hides database errors from the client', async () =&gt; {
    vi.mocked(db.project.findFirst).mockResolvedValue({ id: '7', ownerId: 'u1' } as never)
    vi.mocked(db.project.update).mockRejectedValue(new Error('connection refused at 10.0.3.7'))
    const state = await renameProject(initialState, form({ projectId: '7', title: 'Roadmap' }))
    expect(state.message).not.toContain('10.0.3.7')
  })
})
</code></pre>
<p>The two most valuable tests are the negative ones: an anonymous call never touches the database, and a user cannot rename a project they do not own. Those are the behaviours the UI cannot protect, and they regress silently when someone refactors.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Relying on the page to authenticate.</strong> Middleware, layouts and pages do not wrap action calls. Check the session in every action, or in a shared wrapper.</li>
<li><strong>Trusting IDs from the form.</strong> Anything in <code>FormData</code>, including hidden fields and bound arguments, comes from the client. Re-check ownership on the server.</li>
<li><strong>Casting instead of parsing.</strong> <code>formData.get('title') as string</code> is a type assertion, not validation. It can be <code>null</code>, a <code>File</code>, or 10 MB of text.</li>
<li><strong>Catching redirects.</strong> A <code>try/catch</code> around <code>redirect()</code> turns a redirect into an error state. Call it after the <code>try/catch</code>.</li>
<li><strong>Returning raw error messages.</strong> <code>return { message: String(error) }</code> sends database internals to the browser. Log the error, return a generic message.</li>
<li><strong>Exposing internal actions.</strong> Every exported function in a <code>'use server'</code> file is callable. Keep helpers that must not be called directly in a separate <code>server-only</code> module.</li>
</ul>
<h2>When to Use Server Actions, and When Not To</h2>
<p>Use Server Actions for mutations that belong to your own UI: forms, buttons, inline edits. You get progressive enhancement (forms work before JavaScript loads), typed arguments, and built-in revalidation in one place.</p>
<p>Prefer a Route Handler when the endpoint is consumed by something other than your React UI: mobile apps, webhooks, third-party integrations, or anything that needs a stable URL, custom HTTP methods, specific status codes or caching headers. Also avoid Server Actions for reading data; they are designed for mutations, and calls from the same client are processed one at a time. Fetch data in Server Components instead.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Rolling vs Blue-Green vs Canary: Deploying Without Breaking the Database</title>
            <link>https://cslant.com/tips/rolling-vs-blue-green-vs-canary-deploying-without-breaking-the-database</link>
            <guid isPermaLink="false">https://cslant.com/tips/rolling-vs-blue-green-vs-canary-deploying-without-breaking-the-database</guid>
                        <pubDate>Tue, 10 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>Every deploy runs two versions at once against one database. Compare rolling, blue-green and canary, and learn expand/contract, feature flags and real rollbacks.</description>
                                    <content:encoded><![CDATA[<p>The deploy looked routine: rename <code>customers.name</code> to <code>customers.full_name</code>, update the model, ship. The migration ran first, the rolling update started second, and for the four minutes it took Kubernetes to replace eight pods, every old pod still running was querying a column that no longer existed. Checkout returned 500s for a third of users. Rolling back the code did not help either, because the old code needed the old column and the migration had already dropped it.</p>
<p>Picking a deployment strategy is not just about how traffic moves from version N to N+1. It is about what happens while <strong>both versions run at the same time</strong>, against the same database, the same queues and the same caches. Every strategy has that window. The differences are how long it lasts, how much traffic sees the new version, and how fast you can get out.</p>
<h2>Rolling Updates: The Default</h2>
<p>A rolling update replaces pods a few at a time. It is the Kubernetes default and costs no extra infrastructure:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
spec:
  replicas: 8
  minReadySeconds: 20          # a new pod must stay ready this long before it counts
  progressDeadlineSeconds: 600
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%            # up to 2 extra pods during the rollout
      maxUnavailable: 0        # never drop below 8 ready pods
</code></pre>
<p><code>maxUnavailable: 0</code> with a non-zero <code>maxSurge</code> keeps full capacity during the rollout. <code>minReadySeconds</code> stops a pod that passes readiness and then crashes ten seconds later from counting as "available", which otherwise lets a broken version roll through the whole fleet. If the rollout stalls past <code>progressDeadlineSeconds</code>, the Deployment is marked as failed, and <code>kubectl rollout undo deployment/shop-api</code> brings back the previous ReplicaSet.</p>
<p>The trade-off: during the rollout, users bounce between versions request by request. Old and new code must be compatible with each other's data, sessions and queued jobs. Rollback is another rolling update, so it takes as long as the deploy did.</p>
<h2>Blue-Green: Two Full Stacks, One Switch</h2>
<p>Blue-green runs the new version (green) next to the old one (blue) at full size, verifies it, then flips traffic in one step:</p>
<pre><code class="language-bash"># Both stacks run side by side; the Service selector decides who gets traffic.
kubectl apply -f shop-api-green.yaml
kubectl rollout status deployment/shop-api-green --timeout=5m

# Smoke test green through its own internal Service before the switch.
curl -fsS http://shop-api-green.internal/ready

# Switch: one atomic change to the selector.
kubectl patch service shop-api -p '{"spec":{"selector":{"app":"shop-api","color":"green"}}}'

# Rollback is the same command with color=blue. Keep blue running until you are sure.
</code></pre>
<p>The switch is near-instant and so is the rollback, as long as blue is still running. You can test green with production config and real dependencies before any user sees it. The costs: double capacity during the deploy, and long-lived connections (WebSockets, streaming responses) that stay on blue until they close. The database is still shared, so blue-green does nothing to make a breaking migration safe. It only makes the moment of the switch sharper.</p>
<h2>Canary: Real Traffic, Small Blast Radius</h2>
<p>A canary sends a small slice of real traffic to the new version, compares its metrics with the stable version, and widens the slice step by step: 5%, 25%, 50%, 100%. Service meshes and tools like Argo Rollouts or Flagger automate this; the core idea fits in a few lines of nginx:</p>
<pre><code class="language-text"># nginx: send ~5% of clients to the canary, sticky per client address
split_clients "${remote_addr}" $shop_backend {
    5%      canary;
    *       stable;
}

upstream stable { server 10.0.1.10:8080; server 10.0.1.11:8080; }
upstream canary { server 10.0.2.10:8080; }

server {
    listen 80;
    location / { proxy_pass http://$shop_backend; }
}
</code></pre>
<p>Canaries catch what staging never will: real data shapes, real client versions, real load. They need two things to work. First, enough traffic for the metrics to mean anything; 5% of a service with 20 requests per minute tells you nothing in ten minutes. Second, an automated, pre-agreed decision rule. A human staring at a dashboard at 5% will rationalise a small error spike every time.</p>
<h2>Choosing a Strategy</h2>
<div class="page-mermaid"><div class="mermaid">graph TD
A[New version built] --&gt; B{Schema change compatible with old code}
B --&gt;|no| C[Split into expand and contract releases]
C --&gt; B
B --&gt;|yes| D{Risk level}
D --&gt;|low| E[Rolling update]
D --&gt;|need instant switch| F[Blue-green]
D --&gt;|high, real traffic needed| G[Canary with metrics gate]
G --&gt; H{Metrics healthy}
H --&gt;|yes| I[Promote to 100 percent]
H --&gt;|no| J[Roll back]</div></div>
<p>Notice that the database question comes first. No traffic strategy fixes a schema change that breaks the version still running.</p>
<h2>Database Compatibility: Expand, Migrate, Contract</h2>
<p>The rule that makes every strategy safe: <strong>each release must work with the schema of the release before and the release after it.</strong> That means breaking changes are split across several deploys:</p>
<ol>
<li><strong>Expand</strong>: add the new column or table, nullable, and backfill it. Old code ignores it.</li>
<li><strong>Dual write</strong>: ship code that writes both <code>name</code> and <code>full_name</code> and reads the old one.</li>
<li><strong>Switch reads</strong>: ship code that reads <code>full_name</code>. Rolling back to step 2 is still safe.</li>
<li><strong>Contract</strong>: once nothing reads or writes <code>name</code>, drop it in a later release.</li>
</ol>
<pre><code class="language-php">use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

// Release 1 (expand): add the new column, nullable, no reads depend on it yet.
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('customers', function (Blueprint $table): void {
            $table-&gt;string('full_name')-&gt;nullable()-&gt;after('name');
        });

        // Backfill in chunks so the migration never locks the table for long.
        DB::table('customers')-&gt;whereNull('full_name')-&gt;chunkById(1000, function ($rows): void {
            foreach ($rows as $row) {
                DB::table('customers')-&gt;where('id', $row-&gt;id)-&gt;update(['full_name' =&gt; $row-&gt;name]);
            }
        });
    }
};
</code></pre>
<p>The same thinking applies beyond SQL. Queued jobs serialized by version N will be processed by version N+1, so job payloads need to stay backward compatible for at least one release. Cache keys that change format should get a new prefix rather than overwrite old entries. API responses consumed by mobile apps outlive every deploy you will ever do.</p>
<h2>Feature Flags: Deploy Is Not Release</h2>
<p>Feature flags separate shipping code from turning behaviour on. With Laravel Pennant, the new checkout can be deployed to 100% of pods while only staff and 5% of users see it:</p>
<pre><code class="language-php">namespace App\Providers;

use App\Models\User;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

final class FeatureServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Code ships dark; the flag decides who sees it. Stable per user.
        Feature::define('new-checkout', function (User $user): bool {
            return $user-&gt;is_staff || $user-&gt;id % 100 &lt; 5;
        });
    }
}

// In a controller:
// return Feature::active('new-checkout') ? $this-&gt;newCheckout($cart) : $this-&gt;legacyCheckout($cart);
</code></pre>
<p>Now the risky part (the new behaviour) is controlled by a flag that changes in seconds, and the deploy itself becomes boring. Flags are also the fastest rollback you have: turning one off does not need a build, a pipeline, or a database migration. Hashing on a stable key (here the user id) matters: users should not flip between old and new checkout on every request.</p>
<h2>Automating the Canary Decision</h2>
<p>Write the decision rule as code, agree on thresholds before the deploy, and let the pipeline call it after each step:</p>
<pre><code class="language-php">namespace App\Deploy;

final class CanaryJudge
{
    public function __construct(
        private int $minRequests = 500,
        private float $maxErrorDelta = 0.01,
        private float $maxLatencyRatio = 1.2,
    ) {}

    /** @param array{requests: int, errors: int, p95_ms: int} $baseline */
    public function decide(array $baseline, array $canary): string
    {
        if ($canary['requests'] &lt; $this-&gt;minRequests) {
            return 'wait';
        }

        if ($this-&gt;errorRate($canary) - $this-&gt;errorRate($baseline) &gt; $this-&gt;maxErrorDelta) {
            return 'rollback';
        }

        if ($baseline['p95_ms'] &gt; 0 &amp;&amp; $canary['p95_ms'] &gt; $baseline['p95_ms'] * $this-&gt;maxLatencyRatio) {
            return 'rollback';
        }

        return 'promote';
    }

    private function errorRate(array $m): float
    {
        return $m['requests'] &gt; 0 ? $m['errors'] / $m['requests'] : 0.0;
    }
}
</code></pre>
<p>Three outcomes, not two. <code>wait</code> stops the pipeline from promoting on a statistically meaningless sample. Comparing against the live baseline instead of a fixed number keeps the rule honest during an incident that affects both versions equally.</p>
<h2>Testing the Deploy Gate</h2>
<p>The judge decides whether broken code reaches every user, so it deserves tests that pin down each branch. Pest keeps them readable:</p>
<pre><code class="language-php">use App\Deploy\CanaryJudge;

$baseline = ['requests' =&gt; 20_000, 'errors' =&gt; 20, 'p95_ms' =&gt; 180];

it('waits until the canary has enough traffic', function () use ($baseline) {
    $canary = ['requests' =&gt; 120, 'errors' =&gt; 0, 'p95_ms' =&gt; 150];

    expect((new CanaryJudge())-&gt;decide($baseline, $canary))-&gt;toBe('wait');
});

it('rolls back when the error rate jumps', function () use ($baseline) {
    $canary = ['requests' =&gt; 1_000, 'errors' =&gt; 40, 'p95_ms' =&gt; 170];

    expect((new CanaryJudge())-&gt;decide($baseline, $canary))-&gt;toBe('rollback');
});

it('rolls back on a latency regression even with zero errors', function () use ($baseline) {
    $canary = ['requests' =&gt; 1_000, 'errors' =&gt; 0, 'p95_ms' =&gt; 260];

    expect((new CanaryJudge())-&gt;decide($baseline, $canary))-&gt;toBe('rollback');
});

it('promotes a healthy canary', function () use ($baseline) {
    $canary = ['requests' =&gt; 1_000, 'errors' =&gt; 2, 'p95_ms' =&gt; 190];

    expect((new CanaryJudge())-&gt;decide($baseline, $canary))-&gt;toBe('promote');
});
</code></pre>
<p>Beyond the unit tests, rehearse the rollback itself: in staging, deploy, roll back, and check that the previous version still works against the migrated schema. A rollback path you have never exercised is a hope, not a plan.</p>
<h2>Common Pitfalls</h2>
<p><strong>Destructive migrations in the same release as the code change.</strong> Dropping or renaming a column while old pods still run is the most common deploy outage. Expand first, contract later.</p>
<p><strong>Readiness that passes too early.</strong> If <code>/ready</code> returns 200 before caches are warm or config is loaded, the rolling update moves on and a broken version spreads. Combine a real readiness check with <code>minReadySeconds</code>.</p>
<p><strong>Canary without session or cache isolation.</strong> A canary that writes a new cache format can poison the cache for stable pods. Version cache keys and session payloads.</p>
<p><strong>Rollback that assumes the database rolls back too.</strong> It usually cannot. Data written by the new version must be readable by the old one, which is exactly what expand and contract give you.</p>
<p><strong>Flags that never get removed.</strong> Every flag doubles the code paths to test. Put a removal ticket in the same pull request that adds the flag.</p>
<h2>When to Use Which, and When Not To</h2>
<p><strong>Rolling</strong> is right for most stateless web services with backward-compatible changes. Do not use it when two versions must never serve traffic at the same time, for example a protocol change between client and server.</p>
<p><strong>Blue-green</strong> fits when you need an instant, atomic switch and a fast rollback, and can afford double capacity for the deploy window. It is a poor fit for large stateful systems where duplicating the stack is expensive.</p>
<p><strong>Canary</strong> pays off for high-traffic, high-risk changes where real traffic is the only meaningful test. Skip it for low-traffic services where the metrics will never reach significance; use flags and a careful rolling update instead.</p>
<p>Whatever you pick, the database rule does not change: two versions will overlap, so design every release to coexist with its neighbours.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Cancelling Work with AbortController: fetch, AbortSignal.timeout, AbortSignal.any and Search</title>
            <link>https://cslant.com/tips/cancelling-work-with-abortcontroller-fetch-abortsignaltimeout-abortsignalany-and-search</link>
            <guid isPermaLink="false">https://cslant.com/tips/cancelling-work-with-abortcontroller-fetch-abortsignaltimeout-abortsignalany-and-search</guid>
                        <pubDate>Mon, 09 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>JavaScript</category>
                                    <description>Stale search results and abandoned server work share one fix. Learn AbortController, AbortSignal.timeout and AbortSignal.any, plus cleanup that does not leak.</description>
                                    <content:encoded><![CDATA[<p>A product search box fires a request on every keystroke. The user types "lap", then "lapt", then "laptop". Three requests leave the browser; the one for "lap" is the slowest because it matches the most products, so it arrives <strong>last</strong> and overwrites the results for "laptop". The user sees phone cases and HDMI cables under a query for laptops, and the bug report says "search is random sometimes". Meanwhile the backend did three full-text queries when only one answer mattered.</p>
<p>On the server, the same shape shows up in reverse. A user opens a heavy report, gets bored after two seconds and closes the tab. Node keeps running the five downstream API calls and the database query for another twenty seconds, for a response nobody will read. Multiply that by a busy afternoon and you have a capacity problem made entirely of abandoned work.</p>
<p>The fix in both cases is the same primitive: <code>AbortController</code>. It is built into every modern browser and into Node.js, <code>fetch</code> understands it natively, and with <code>AbortSignal.timeout</code> and <code>AbortSignal.any</code> it covers timeouts and combined cancellation too.</p>
<h2>How AbortController Works</h2>
<p>The API is split into two objects with deliberately different powers:</p>
<ul>
<li><strong><code>AbortController</code></strong> is the remote control. Whoever holds it can call <code>controller.abort(reason)</code>.</li>
<li><strong><code>AbortSignal</code></strong> (<code>controller.signal</code>) is the read-only receiver. You pass it to the work you want to be cancellable. It exposes <code>signal.aborted</code>, <code>signal.reason</code>, <code>signal.throwIfAborted()</code> and an <code>abort</code> event.</li>
</ul>
<p>That split matters: you hand the signal to a function, and the function can observe cancellation but cannot cancel anyone else. The caller keeps the controller.</p>
<p>A few rules worth memorising:</p>
<ul>
<li><code>abort()</code> is idempotent. The first call wins; later calls do nothing, and <code>reason</code> keeps the first value.</li>
<li><code>abort()</code> with no argument sets <code>reason</code> to a <code>DOMException</code> named <code>AbortError</code>. <code>abort(reason)</code> uses whatever you pass, which is handy for telling apart "user navigated away" from "superseded by a newer query".</li>
<li>A signal cannot be reset. Every operation that can be cancelled independently gets a <strong>new</strong> controller.</li>
<li><code>abort</code> listeners run synchronously inside <code>abort()</code>. Keep them small.</li>
</ul>
<h2>Search-as-You-Type, Step by Step</h2>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
participant U as User
participant S as Search box
participant A as API
U-&gt;&gt;S: types lap
S-&gt;&gt;A: search lap with signal 1
U-&gt;&gt;S: types laptop
S-&gt;&gt;S: abort signal 1
S-&gt;&gt;A: search laptop with signal 2
A--&gt;&gt;S: results for laptop
S-&gt;&gt;U: render laptop results only</div></div>
<p>The rule is simple: when a new query starts, abort the previous one. Stale responses then never reach the code that renders results, because their promise rejects instead of resolving.</p>
<h2>Cancelling fetch</h2>
<p><code>fetch</code> takes a <code>signal</code> option. When the signal aborts, the request is torn down (the browser closes the connection or cancels the HTTP/2 stream) and the promise rejects with the signal's reason. If the response headers already arrived, reading the body with <code>res.json()</code> rejects as well.</p>
<pre><code class="language-ts">export function isAbortError(error: unknown): boolean {
  return error instanceof DOMException &amp;&amp; error.name === 'AbortError';
}

export async function loadUser(id: string, signal?: AbortSignal) {
  const res = await fetch(`/api/users/${id}`, { signal });
  if (!res.ok) {
    throw new Error(`Request failed with ${res.status}`);
  }

  return res.json();
}
</code></pre>
<p>Aborting is an expected outcome, not an error worth reporting. Filter it out before the error goes to Sentry or to a toast, otherwise every fast typist generates noise. Note what aborting does <strong>not</strong> do: if the server already received the request, it may still finish processing it. Cancellation saves the client from waiting and frees the connection; it is not a transaction rollback. Never rely on it to prevent a write from happening.</p>
<h2>Timeouts with AbortSignal.timeout</h2>
<p><code>fetch</code> has no timeout option. Before <code>AbortSignal.timeout</code>, people wrapped <code>fetch</code> in <code>Promise.race</code> with a timer, which stopped the waiting but left the request running. Now there is a static helper that returns a signal which aborts by itself:</p>
<pre><code class="language-ts">try {
  const res = await fetch('/api/reports/monthly', { signal: AbortSignal.timeout(8000) });
  const report = await res.json();
  render(report);
} catch (error) {
  if (error instanceof DOMException &amp;&amp; error.name === 'TimeoutError') {
    showMessage('The report is taking too long. Try a smaller date range.');
  } else {
    throw error;
  }
}
</code></pre>
<p>The reason here is a <code>DOMException</code> named <code>TimeoutError</code>, not <code>AbortError</code>, so you can tell "we gave up" from "the user cancelled". In browsers the timer counts active time, so it is effectively paused while a page sits in the back/forward cache. <code>AbortSignal.timeout</code> is available in Node.js 17.3+ and all evergreen browsers.</p>
<h2>Combining Signals with AbortSignal.any</h2>
<p>Real code often has several reasons to stop: the user typed again, the component unmounted, the request took too long, the whole server is shutting down. <code>AbortSignal.any([...])</code> returns a signal that aborts as soon as <strong>any</strong> input aborts, and its <code>reason</code> is the reason of the first one. It shipped in Node.js 20.3 and in all major browsers during 2023 and 2024.</p>
<pre><code class="language-ts">type Result = { id: string; title: string };

export function createSearch(
  fetchResults: (query: string, signal: AbortSignal) =&gt; Promise&lt;Result[]&gt;,
  timeoutMs = 5000,
) {
  let current: AbortController | null = null;

  return async function search(query: string): Promise&lt;Result[] | null&gt; {
    current?.abort();
    const controller = new AbortController();
    current = controller;

    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);

    try {
      return await fetchResults(query, signal);
    } catch (error) {
      if (controller.signal.aborted) {
        return null;
      }
      throw error;
    }
  };
}
</code></pre>
<p>Look at the <code>catch</code>: it checks <strong>our own</strong> controller, not the combined signal. If this call was superseded, returning <code>null</code> tells the UI "ignore me". If the timeout fired, the <code>TimeoutError</code> propagates so the UI can show a message. Different reasons, different handling, one signal passed down.</p>
<h2>React: Abort in the Effect Cleanup</h2>
<p>In React the natural place for cancellation is the cleanup function of <code>useEffect</code>. It runs before the next effect and on unmount, which is exactly "a newer query started" and "nobody is listening anymore". Combine it with a small debounce so you also send fewer requests.</p>
<pre><code class="language-tsx">import { useEffect, useState } from 'react';

export function useSearch(query: string) {
  const [results, setResults] = useState&lt;{ id: string; title: string }[]&gt;([]);
  const [error, setError] = useState&lt;Error | null&gt;(null);

  useEffect(() =&gt; {
    if (query.trim().length &lt; 2) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    const timer = setTimeout(() =&gt; {
      fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
        .then((res) =&gt; res.json())
        .then((data) =&gt; {
          setResults(data);
          setError(null);
        })
        .catch((err) =&gt; {
          if (err.name !== 'AbortError') setError(err);
        });
    }, 250);

    return () =&gt; {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  return { results, error };
}
</code></pre>
<p>This also makes the hook correct under React StrictMode in development, which mounts, unmounts and remounts effects on purpose: the first request is aborted by the cleanup and only the second one updates state. Data libraries like TanStack Query pass a <code>signal</code> into your query function for the same reason; forward it to <code>fetch</code> and you get cancellation for free.</p>
<h2>Making Your Own Code Abortable</h2>
<p>The signal only helps if the code that receives it listens. For your own async functions, follow two patterns: check <code>signal.throwIfAborted()</code> between steps, and turn timers or event waits into promises that reject on abort and clean up after themselves.</p>
<pre><code class="language-ts">export function sleep(ms: number, signal?: AbortSignal): Promise&lt;void&gt; {
  return new Promise((resolve, reject) =&gt; {
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }

    const onAbort = () =&gt; {
      clearTimeout(timer);
      reject(signal!.reason);
    };

    const timer = setTimeout(() =&gt; {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);

    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

export async function syncAll(ids: string[], signal: AbortSignal) {
  for (const id of ids) {
    signal.throwIfAborted();
    await syncOne(id, { signal });
    await sleep(100, signal);
  }
}
</code></pre>
<p>Removing the listener when the timer wins is not cosmetic. A long-lived signal (for example, a server shutdown signal) would otherwise collect one listener per call forever, and Node prints a <code>MaxListenersExceededWarning</code> when that happens. The same signal can also clean up DOM listeners: <code>element.addEventListener('scroll', handler, { signal })</code> removes the handler when the signal aborts.</p>
<p>On the server, wire the signal to the client connection so abandoned requests stop their downstream work:</p>
<pre><code class="language-js">app.get('/reports/:id', async (req, res, next) =&gt; {
  const controller = new AbortController();
  res.on('close', () =&gt; {
    if (!res.writableEnded) controller.abort(new Error('client disconnected'));
  });

  try {
    const report = await buildReport(req.params.id, { signal: controller.signal });
    res.json(report);
  } catch (error) {
    if (controller.signal.aborted) return;
    next(error);
  }
});
</code></pre>
<h2>Testing Cancellation with Vitest</h2>
<p>Cancellation bugs are race conditions, so test the race explicitly: start two searches, make the first one slower, and assert that only the second one produces results.</p>
<pre><code class="language-ts">import { describe, expect, it } from 'vitest';
import { createSearch, sleep } from './search';

describe('createSearch', () =&gt; {
  it('aborts the previous request when a new query starts', async () =&gt; {
    const signals: AbortSignal[] = [];

    const search = createSearch((query, signal) =&gt; {
      signals.push(signal);
      return new Promise((resolve, reject) =&gt; {
        signal.addEventListener('abort', () =&gt; reject(signal.reason), { once: true });
        setTimeout(() =&gt; resolve([{ id: query, title: query }]), query === 'lap' ? 50 : 10);
      });
    });

    const first = search('lap');
    const second = search('laptop');

    await expect(first).resolves.toBeNull();
    await expect(second).resolves.toEqual([{ id: 'laptop', title: 'laptop' }]);
    expect(signals[0].aborted).toBe(true);
    expect(signals[1].aborted).toBe(false);
  });
});

describe('sleep', () =&gt; {
  it('rejects with the abort reason', async () =&gt; {
    const controller = new AbortController();
    const pending = sleep(10_000, controller.signal);

    controller.abort(new Error('stop'));

    await expect(pending).rejects.toThrow('stop');
  });
});
</code></pre>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Reusing a controller.</strong> Once aborted, a signal stays aborted. Every new request that should be cancellable on its own needs a new <code>AbortController</code>.</li>
<li><strong>Reporting aborts as errors.</strong> Filter <code>AbortError</code> before logging or showing an error toast.</li>
<li><strong>Checking <code>error.name</code> only for <code>AbortError</code>.</strong> <code>AbortSignal.timeout</code> produces <code>TimeoutError</code>, and a custom <code>abort(reason)</code> produces whatever you passed. Decide based on which signal aborted when the difference matters.</li>
<li><strong>Assuming abort undoes server work.</strong> The request may already be processed. Use idempotency keys for writes.</li>
<li><strong>Leaking listeners on long-lived signals.</strong> Use <code>{ once: true }</code> and remove the listener on the success path.</li>
<li><strong>Swallowing the signal.</strong> A function that accepts <code>signal</code> but never forwards it to <code>fetch</code> or checks it gives a false sense of safety.</li>
</ul>
<h2>When to Use It and When Not To</h2>
<p>Use <code>AbortController</code> when:</p>
<ul>
<li>Responses can arrive out of order and only the latest matters: search, autocomplete, filters, tab switches.</li>
<li>A component or page can go away while work is in flight.</li>
<li>You need a real timeout on <code>fetch</code> or on any API that accepts a signal (Node streams, <code>timers/promises</code>, <code>events.once</code>, many SDKs).</li>
<li>Server work can be abandoned when the client disconnects or the process is shutting down.</li>
</ul>
<p>Do not reach for it when:</p>
<ul>
<li>The operation is a write that must complete once started. Make it idempotent instead, and let it finish.</li>
<li>The work is synchronous and CPU-bound. A signal cannot interrupt a busy loop unless that loop checks <code>signal.aborted</code>; move heavy work to a worker if it must be stoppable.</li>
<li>A request deduplicator or a data library already handles cancellation for you. Forward its signal rather than adding another layer.</li>
</ul>
<p>Every <code>fetch</code> that can become irrelevant should carry a signal. It is one extra argument, and it removes an entire class of "random" bugs.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Transactional Outbox in Laravel: Never Lose an Event Between Your Database and the Broker</title>
            <link>https://cslant.com/tips/transactional-outbox-in-laravel-never-lose-an-event-between-your-database-and-the-broker</link>
            <guid isPermaLink="false">https://cslant.com/tips/transactional-outbox-in-laravel-never-lose-an-event-between-your-database-and-the-broker</guid>
                        <pubDate>Sun, 08 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Laravel</category>
                                    <description>Writing to the database and a broker in one request loses or invents events on crashes. Fix the dual-write problem with an outbox table, relay and idempotent consumers.</description>
                                    <content:encoded><![CDATA[<p>A customer pays for an order. The order row is committed, the payment is captured, and then the warehouse service never ships it. The logs show the <code>orders.placed</code> message was never published: the PHP worker was killed by a deploy two milliseconds after the commit. A week earlier the opposite happened: the message was published first, the database transaction rolled back on a deadlock, and the warehouse started picking an order that does not exist.</p>
<p>Both incidents are the same bug: your code writes to the database and a broker, and no transaction spans both. Whichever one you write first, a crash between the two writes leaves them disagreeing. The transactional outbox pattern removes the second write from the request entirely.</p>
<h2>The Dual-Write Problem</h2>
<p>Here is the code that caused the first incident. It looks reasonable, and it works in every test you will ever write:</p>
<pre><code class="language-php">use App\Models\Order;
use Illuminate\Support\Facades\DB;

$order = DB::transaction(function () use ($cart) {
    $order = Order::createFromCart($cart);
    $order-&gt;items()-&gt;createMany($cart-&gt;lines());

    return $order;
});

// The process can die right here: OOM, deploy, network blip.
// The order exists, but nobody downstream ever hears about it.
$broker-&gt;publish('orders.placed', ['order_id' =&gt; $order-&gt;id]);
</code></pre>
<p>Publish inside the transaction instead and you get the second incident: the commit fails after the broker took the message. Laravel's <code>DB::afterCommit()</code> and <code>ShouldDispatchAfterCommit</code> events improve the ordering but not the guarantee: the callback still runs in the same PHP process after the commit, so a crash, timeout or broker outage at that moment still loses the message.</p>
<p>There is no clever ordering that fixes this. What fixes it is having only <strong>one</strong> write that matters, inside <strong>one</strong> transaction.</p>
<h2>The Outbox Table</h2>
<p>Instead of talking to the broker, the request writes the message into a table in the same database, in the same transaction as the business data. Either both the order and the message exist, or neither does. A separate relay process reads that table and publishes to the broker, retrying until it succeeds.</p>
<pre><code class="language-php">use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('outbox_messages', function (Blueprint $table) {
            $table-&gt;id();
            $table-&gt;uuid('message_id')-&gt;unique();
            $table-&gt;string('topic');
            $table-&gt;string('aggregate_type');
            $table-&gt;string('aggregate_id');
            $table-&gt;json('payload');
            $table-&gt;unsignedSmallInteger('attempts')-&gt;default(0);
            $table-&gt;timestamp('available_at')-&gt;useCurrent();
            $table-&gt;timestamp('published_at')-&gt;nullable();
            $table-&gt;timestamp('created_at')-&gt;useCurrent();

            $table-&gt;index(['published_at', 'available_at', 'id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('outbox_messages');
    }
};
</code></pre>
<p>Notes:</p>
<ul>
<li><code>message_id</code> is a UUID that travels with the message. Consumers use it to detect duplicates.</li>
<li><code>aggregate_type</code> and <code>aggregate_id</code> say what the message is about, for per-entity ordering and debugging.</li>
<li><code>available_at</code> and <code>attempts</code> give you backoff without a separate retry queue.</li>
<li>The <code>(published_at, available_at, id)</code> index keeps "oldest pending" a range scan.</li>
</ul>
<h2>Recording Messages in the Same Transaction</h2>
<p>A small service writes the row. It refuses to run outside a transaction, which turns the most common mistake into an immediate exception in development:</p>
<pre><code class="language-php">namespace App\Outbox;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use LogicException;

final class Outbox
{
    public function record(string $topic, string $aggregateType, string|int $aggregateId, array $payload): string
    {
        if (DB::transactionLevel() === 0) {
            throw new LogicException('Outbox messages must be recorded inside a database transaction.');
        }

        $messageId = (string) Str::uuid();

        DB::table('outbox_messages')-&gt;insert([
            'message_id' =&gt; $messageId,
            'topic' =&gt; $topic,
            'aggregate_type' =&gt; $aggregateType,
            'aggregate_id' =&gt; (string) $aggregateId,
            'payload' =&gt; json_encode($payload, JSON_THROW_ON_ERROR),
        ]);

        return $messageId;
    }
}
</code></pre>
<p>The use case then records the message right next to the business writes:</p>
<pre><code class="language-php">namespace App\Orders;

use App\Models\Cart;
use App\Models\Order;
use App\Outbox\Outbox;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(private Outbox $outbox) {}

    public function __invoke(Cart $cart): Order
    {
        return DB::transaction(function () use ($cart) {
            $order = Order::createFromCart($cart);
            $order-&gt;items()-&gt;createMany($cart-&gt;lines());

            $this-&gt;outbox-&gt;record('orders.placed', 'order', $order-&gt;id, [
                'order_id' =&gt; $order-&gt;id,
                'customer_id' =&gt; $order-&gt;customer_id,
                'total' =&gt; $order-&gt;total,
                'placed_at' =&gt; $order-&gt;created_at-&gt;toIso8601String(),
            ]);

            return $order;
        });
    }
}
</code></pre>
<p>If anything in the closure throws, the order, its items and the outbox row disappear together. If it commits, the message is guaranteed to be published eventually, even if the PHP process dies one microsecond later.</p>
<h2>The Relay Worker</h2>
<p>The relay is a loop: select a batch of pending messages, publish each one, mark it published. Here is the full flow, from the request to the consumer:</p>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
participant S as Order service
participant DB as Database
participant R as Relay worker
participant B as Broker
participant C as Consumer
S-&gt;&gt;DB: insert order and outbox row in one transaction
R-&gt;&gt;DB: select unpublished rows skip locked
R-&gt;&gt;B: publish message with message_id
R-&gt;&gt;DB: set published_at
B-&gt;&gt;C: deliver at least once
C-&gt;&gt;C: skip if message_id already processed</div></div>
<p>In Laravel, the relay is an Artisan command run by Supervisor (or a scheduled <code>--once</code> invocation for low volume):</p>
<pre><code class="language-php">namespace App\Console\Commands;

use App\Outbox\MessagePublisher;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;

final class RelayOutbox extends Command
{
    protected $signature = 'outbox:relay {--batch=100} {--once}';

    protected $description = 'Publish pending outbox messages to the broker';

    public function handle(MessagePublisher $publisher): int
    {
        do {
            $sent = $this-&gt;relayBatch($publisher, (int) $this-&gt;option('batch'));

            if ($sent === 0 &amp;&amp; ! $this-&gt;option('once')) {
                usleep(500_000);
            }
        } while (! $this-&gt;option('once'));

        return self::SUCCESS;
    }

    private function relayBatch(MessagePublisher $publisher, int $limit): int
    {
        return DB::transaction(function () use ($publisher, $limit) {
            $messages = DB::table('outbox_messages')
                -&gt;whereNull('published_at')
                -&gt;where('available_at', '&lt;=', now())
                -&gt;orderBy('id')
                -&gt;limit($limit)
                -&gt;lock('for update skip locked')
                -&gt;get();

            $sent = 0;

            foreach ($messages as $message) {
                try {
                    $publisher-&gt;publish($message-&gt;topic, $message-&gt;message_id, json_decode($message-&gt;payload, true));
                } catch (Throwable $e) {
                    report($e);
                    DB::table('outbox_messages')-&gt;where('id', $message-&gt;id)-&gt;update([
                        'attempts' =&gt; $message-&gt;attempts + 1,
                        'available_at' =&gt; now()-&gt;addSeconds(min(300, 2 ** $message-&gt;attempts)),
                    ]);

                    break;
                }

                DB::table('outbox_messages')-&gt;where('id', $message-&gt;id)-&gt;update(['published_at' =&gt; now()]);
                $sent++;
            }

            return $sent;
        });
    }
}
</code></pre>
<p>Design choices in that command:</p>
<ul>
<li><strong><code>FOR UPDATE SKIP LOCKED</code>.</strong> You will run more than one relay, during deploys if nothing else. Row locks with <code>SKIP LOCKED</code> (MySQL 8.0+ and PostgreSQL) let several workers share the table without publishing the same row twice and without blocking each other.</li>
<li><strong>Order by <code>id</code> and stop on the first failure.</strong> Breaking out of the loop keeps messages in commit order, which matters when consumers expect <code>orders.placed</code> before <code>orders.cancelled</code>. If you need parallelism, partition by <code>aggregate_id</code> instead of dropping ordering globally.</li>
<li><strong>Backoff via <code>available_at</code>.</strong> A broker outage does not become a hot loop.</li>
<li><strong>Publish, then mark.</strong> If the worker crashes after publishing but before the update commits, the message is published again on the next run. That is the price of the pattern: <strong>at-least-once</strong> delivery. Exactly-once delivery across a network does not exist, so the consumer has to cope.</li>
</ul>
<p>Keep batches small: locks are held while you talk to the broker, so 100 messages at 50 ms each hold locks for 5 seconds.</p>
<h2>Idempotent Consumers</h2>
<p>Because the relay can deliver a message twice, and most brokers redeliver on consumer crashes anyway, every consumer must be safe to run twice with the same message. The robust way is a <code>processed_messages</code> table with a unique key on <code>message_id</code>, written in the same transaction as the consumer's own side effects:</p>
<pre><code class="language-php">namespace App\Warehouse;

use Illuminate\Support\Facades\DB;

final class HandleOrderPlaced
{
    public function __construct(private ShipmentPlanner $planner) {}

    public function __invoke(string $messageId, array $payload): void
    {
        DB::transaction(function () use ($messageId, $payload) {
            $inserted = DB::table('processed_messages')-&gt;insertOrIgnore([
                'message_id' =&gt; $messageId,
                'consumer' =&gt; 'warehouse.order-placed',
                'processed_at' =&gt; now(),
            ]);

            if ($inserted === 0) {
                return;
            }

            $this-&gt;planner-&gt;planFor((int) $payload['order_id']);
        });
    }
}
</code></pre>
<p><code>insertOrIgnore</code> returns the number of inserted rows. Zero means another delivery of the same message already got there first, so you return without doing the work. Because the dedupe row and the shipment plan commit together, a crash halfway leaves neither, and the redelivery runs the full handler again.</p>
<p>Naturally idempotent handlers ("set status to shipped", an upsert keyed by <code>order_id</code>) can skip the table. "Add loyalty points" or "send an email" cannot.</p>
<h2>Testing the Outbox</h2>
<p>The properties to prove are the ones that failed in production: the message is written atomically with the order, nothing is written on rollback, and a broker failure is retried rather than lost. Bind a fake publisher in the container and drive the relay with <code>--once</code>:</p>
<pre><code class="language-php">use App\Models\Cart;
use App\Orders\PlaceOrder;
use App\Outbox\MessagePublisher;
use Illuminate\Support\Facades\DB;

final class FakePublisher implements MessagePublisher
{
    public array $sent = [];

    public bool $failNext = false;

    public function publish(string $topic, string $messageId, array $payload): void
    {
        if ($this-&gt;failNext) {
            $this-&gt;failNext = false;

            throw new RuntimeException('broker down');
        }

        $this-&gt;sent[] = [$topic, $messageId, $payload];
    }
}

beforeEach(function () {
    $this-&gt;publisher = new FakePublisher();
    $this-&gt;app-&gt;instance(MessagePublisher::class, $this-&gt;publisher);
});

it('writes the order and its outbox message in one transaction', function () {
    $order = app(PlaceOrder::class)(Cart::factory()-&gt;withLines(2)-&gt;create());

    $this-&gt;assertDatabaseHas('outbox_messages', [
        'topic' =&gt; 'orders.placed',
        'aggregate_id' =&gt; (string) $order-&gt;id,
        'published_at' =&gt; null,
    ]);
});

it('publishes pending messages and retries after a broker failure', function () {
    app(PlaceOrder::class)(Cart::factory()-&gt;withLines(1)-&gt;create());
    $this-&gt;publisher-&gt;failNext = true;

    $this-&gt;artisan('outbox:relay', ['--once' =&gt; true])-&gt;assertSuccessful();
    expect($this-&gt;publisher-&gt;sent)-&gt;toBeEmpty();

    $this-&gt;travel(5)-&gt;seconds();
    $this-&gt;artisan('outbox:relay', ['--once' =&gt; true])-&gt;assertSuccessful();

    expect($this-&gt;publisher-&gt;sent)-&gt;toHaveCount(1)
        -&gt;and(DB::table('outbox_messages')-&gt;whereNull('published_at')-&gt;count())-&gt;toBe(0);
});
</code></pre>
<p><code>$this-&gt;travel()</code> moves Laravel's clock past the backoff window, so the retry test does not sleep. Also test the rollback case (throw inside an outer transaction, assert <code>outbox_messages</code> is empty) and the consumer: call it twice with the same <code>message_id</code> and assert the side effect happened once.</p>
<h2>Common Pitfalls</h2>
<ul>
<li>
<p><strong>Recording outside the transaction.</strong> Calling <code>record()</code> after <code>DB::transaction()</code> returns brings the dual-write problem straight back. The <code>transactionLevel()</code> guard catches this early.</p>
</li>
<li>
<p><strong>Huge payloads.</strong> The outbox is not a document store. Put IDs and the fields consumers need, not the entire model graph with relations.</p>
</li>
<li>
<p><strong>Unbounded table growth.</strong> Published rows pile up fast. Schedule a daily job that deletes rows published more than seven days ago, in chunks of a few thousand.</p>
</li>
<li>
<p><strong>Assuming exactly-once.</strong> Every consumer you do not control will receive duplicates eventually. Send <code>message_id</code> in a header and document that it is the dedupe key.</p>
</li>
<li>
<p><strong>Never restarting the relay.</strong> Exit after N batches, like <code>queue:work --max-jobs</code>, and let Supervisor restart it with fresh code and config.</p>
</li>
<li>
<p><strong>Monitoring nothing.</strong> Alert on the age of the oldest unpublished message. If it grows, the relay is stuck, and orders are silently waiting.</p>
</li>
</ul>
<h2>When to Use the Outbox and When Not To</h2>
<p>Use the transactional outbox when:</p>
<ul>
<li>a database change <strong>must</strong> be followed by a message to another service or broker, and losing or inventing that message has a business cost: orders, payments, inventory, account state;</li>
<li>you integrate services over Kafka, RabbitMQ, SQS or webhooks and cannot share a database transaction with them;</li>
</ul>
<p>You probably do not need it when:</p>
<ul>
<li>the side effect is best effort: analytics pings, cache warming, a "welcome" email where a rare miss is acceptable. <code>afterCommit</code> dispatch is simpler;</li>
<li>everything happens inside one Laravel app with the database queue driver. A job dispatched inside the transaction on the <code>database</code> queue is already written atomically with your data, which is effectively an outbox;</li>
<li>you already run change data capture (Debezium reading the binlog). CDC can replace the polling relay, though the outbox table is still a useful explicit contract.</li>
</ul>
<p>One table, one worker, one dedupe check per consumer, and "paid but never shipped" stops being a 2 AM incident.</p>]]></content:encoded>
                                </item>
                <item>
            <title>CORS Explained: Same-Origin Policy, Preflight, Credentials and Laravel config/cors.php</title>
            <link>https://cslant.com/tips/cors-explained-same-origin-policy-preflight-credentials-and-laravel-configcorsphp</link>
            <guid isPermaLink="false">https://cslant.com/tips/cors-explained-same-origin-policy-preflight-credentials-and-laravel-configcorsphp</guid>
                        <pubDate>Sat, 07 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Security</category>
                                    <description>CORS errors tempt you to reflect any Origin and move on. Learn what the browser checks, when preflight happens, how credentials change the rules, and safe config.</description>
                                    <content:encoded><![CDATA[<p>The new React dashboard on <code>app.example.com</code> calls the Laravel API on <code>api.example.com</code>. In local dev everything went through a Vite proxy, so it worked. In staging the console fills with red: "has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present". Someone pastes a Stack Overflow answer that sets <code>Access-Control-Allow-Origin: *</code>, then the login request fails with a different CORS error because it sends cookies. Someone else "fixes" that by echoing back whatever <code>Origin</code> the request had. Now it works, and now every website on the internet can read your users' data with their own cookies.</p>
<p>CORS is one of those topics people configure by trial and error until the error goes away. It is worth fifteen minutes to understand what the browser is actually doing, because the "make the error go away" fixes are exactly the ones that open a hole.</p>
<h2>Same-Origin Policy First</h2>
<p>An origin is the triple <strong>scheme + host + port</strong>. <code>https://app.example.com</code> and <code>https://api.example.com</code> are different origins; so are <code>http://</code> and <code>https://</code> of the same host, and <code>:443</code> vs <code>:8443</code>.</p>
<p>The same-origin policy is a browser rule: JavaScript running on one origin may <em>send</em> many kinds of requests to another origin, but it may not <em>read</em> the response unless that other origin explicitly allows it. It protects the user, not the server. Without it, any page you visit could call <code>fetch('https://your-bank.com/api/accounts', {credentials: 'include'})</code> and read the result with your session cookie attached.</p>
<p>CORS (Cross-Origin Resource Sharing) is the opt-out: a set of response headers with which a server says "these origins may read my responses, with these methods and headers, with or without credentials". Two consequences people often miss:</p>
<ul>
<li>CORS is enforced <strong>by the browser</strong>. curl, Postman, server-to-server calls and mobile apps ignore it completely. CORS is not authentication and not an API firewall.</li>
<li>CORS is a <strong>relaxation</strong>. A server that sends no CORS headers at all is the most locked-down configuration. Every header you add loosens something.</li>
</ul>
<h2>Simple Requests vs Preflight</h2>
<p>Browsers split cross-origin requests into two groups.</p>
<p>A <strong>simple request</strong> is one an HTML form could already send before CORS existed: method <code>GET</code>, <code>HEAD</code> or <code>POST</code>, only safelisted headers (such as <code>Accept</code>, <code>Accept-Language</code>, <code>Content-Language</code>), and a <code>Content-Type</code> of <code>application/x-www-form-urlencoded</code>, <code>multipart/form-data</code> or <code>text/plain</code>. The browser sends it immediately with an <code>Origin</code> header, then decides whether JavaScript may see the response.</p>
<p>Anything else (a <code>PUT</code> or <code>DELETE</code>, a JSON <code>Content-Type</code>, an <code>Authorization</code> header, any custom <code>X-</code> header) triggers a <strong>preflight</strong>: an <code>OPTIONS</code> request that asks permission first. Only if the answer allows the method, the headers and the origin does the browser send the real request.</p>
<div class="page-mermaid"><div class="mermaid">graph TD
A[Script on app.example.com calls api.example.com] --&gt; B{Simple request?}
B --&gt;|yes| C[Browser sends request with Origin header]
B --&gt;|no| D[Browser sends OPTIONS preflight]
D --&gt; E{Origin, method and headers allowed?}
E --&gt;|no| F[Real request is never sent]
E --&gt;|yes| C
C --&gt; G{Matching Allow-Origin in response?}
G --&gt;|yes| H[Script can read the response]
G --&gt;|no| I[Script sees a network error]</div></div>
<p>A real preflight exchange for a JSON POST with a bearer token:</p>
<pre><code class="language-text">OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
</code></pre>
<p>Two important details. First, a simple request <strong>does reach your server and run</strong>, even when the browser later hides the response. CORS does not protect a state-changing <code>POST</code> with a form content type; that is CSRF territory and needs CSRF tokens or SameSite cookies. Second, <code>Access-Control-Max-Age</code> lets the browser cache the preflight answer. Chromium caps it at 7200 seconds and Firefox at 86400, so larger values are silently reduced.</p>
<h2>Credentials: Cookies Change the Rules</h2>
<p>By default <code>fetch()</code> does not send cookies cross-origin. To send them you set <code>credentials: 'include'</code> (or <code>withCredentials: true</code> in axios):</p>
<pre><code class="language-js">// Runs on https://app.example.com
const res = await fetch('https://api.example.com/api/orders', {
  method: 'POST',
  credentials: 'include', // send cookies; requires Allow-Credentials and an explicit origin
  headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken },
  body: JSON.stringify({ sku: 'LAP-14', qty: 1 }),
});
</code></pre>
<p>Once credentials are involved, the server's response must satisfy stricter rules, or the browser refuses to expose it:</p>
<ul>
<li><code>Access-Control-Allow-Credentials: true</code> must be present.</li>
<li><code>Access-Control-Allow-Origin</code> must be the <strong>exact origin</strong>, never <code>*</code>.</li>
<li>For credentialed requests, <code>*</code> in <code>Allow-Headers</code>, <code>Allow-Methods</code> and <code>Expose-Headers</code> is treated as a literal string, not a wildcard. List them explicitly.</li>
</ul>
<p>Whenever the <code>Allow-Origin</code> value depends on the request, add <code>Vary: Origin</code>. Otherwise a CDN or shared cache can store the response for origin A and serve it to origin B, which either breaks B or, worse, grants it access.</p>
<h2>Laravel 13: config/cors.php</h2>
<p>Laravel ships the <code>HandleCors</code> middleware in the global stack, powered by <code>fruitcake/php-cors</code>. It only acts on paths listed in <code>config/cors.php</code>, which you publish with <code>php artisan config:publish cors</code>. A production config for a first-party SPA that uses cookie auth looks like this:</p>
<pre><code class="language-php">// config/cors.php
return [
    'paths' =&gt; ['api/*', 'sanctum/csrf-cookie'],

    'allowed_methods' =&gt; ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],

    'allowed_origins' =&gt; array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', ''))),

    // Anchored on both ends: preview deploys like https://pr-123.preview.example.com
    'allowed_origins_patterns' =&gt; ['#^https://pr-\d+\.preview\.example\.com$#'],

    'allowed_headers' =&gt; ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],

    'exposed_headers' =&gt; ['X-RateLimit-Remaining'],

    'max_age' =&gt; 7200,

    'supports_credentials' =&gt; true,
];
</code></pre>
<p>What each choice buys you:</p>
<ul>
<li><code>paths</code> limits CORS to the API and the Sanctum CSRF cookie endpoint. Web routes and the admin panel get no CORS headers at all.</li>
<li><code>allowed_origins</code> comes from an env var (<code>CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com</code>), so staging and production differ without code changes.</li>
<li><code>allowed_origins_patterns</code> are full regexes. Anchor them with <code>^</code> and <code>$</code> and escape the dots, or <code>example.com.evil.io</code> will match.</li>
<li><code>exposed_headers</code> controls which response headers JavaScript can read beyond the safelisted ones. Without it, <code>res.headers.get('X-RateLimit-Remaining')</code> returns <code>null</code> even though the header is in the network tab.</li>
<li><code>supports_credentials</code> must be <code>true</code> for cookie-based auth (Sanctum SPA mode). With Sanctum, also set <code>SANCTUM_STATEFUL_DOMAINS</code> and a <code>SESSION_DOMAIN</code> like <code>.example.com</code> so the session cookie is shared across subdomains.</li>
</ul>
<p>Watch out for one default: with <code>allowed_origins =&gt; ['*']</code> <strong>and</strong> <code>supports_credentials =&gt; true</code>, the library does not send a literal <code>*</code> (browsers would reject it). It echoes back the request's origin instead. That makes the browser happy and makes your API readable, with cookies, from any site. Never combine the two.</p>
<h2>Doing It by Hand in Express</h2>
<p>When there is no framework helper, the logic fits in one small middleware. The important parts are the allowlist lookup, <code>Vary: Origin</code>, and answering preflights without hitting your route handlers:</p>
<pre><code class="language-js">const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
const METHODS = 'GET, POST, PUT, PATCH, DELETE';
const HEADERS = 'content-type, authorization, x-xsrf-token';

function cors(req, res, next) {
  const origin = req.get('Origin');
  res.vary('Origin'); // caches must not mix responses for different origins

  if (origin &amp;&amp; ALLOWED.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Access-Control-Allow-Credentials', 'true');
  }

  if (req.method === 'OPTIONS' &amp;&amp; req.get('Access-Control-Request-Method')) {
    if (!origin || !ALLOWED.has(origin)) {
      return res.sendStatus(403);
    }
    res.set('Access-Control-Allow-Methods', METHODS);
    res.set('Access-Control-Allow-Headers', HEADERS);
    res.set('Access-Control-Max-Age', '7200');
    return res.sendStatus(204);
  }

  next();
}

module.exports = { cors };
</code></pre>
<p>And here are the patterns to grep for in code review, because each one has shipped in real products:</p>
<pre><code class="language-js">// All of these are real-world bugs. Do not ship them.
res.set('Access-Control-Allow-Origin', req.get('Origin'));        // reflects ANY origin
if (origin.endsWith('example.com')) allow(origin);                // matches evil-example.com
if (/https:\/\/.*\.example\.com/.test(origin)) allow(origin);     // unanchored: example.com.evil.io
if (origin === 'null' || ALLOWED.has(origin)) allow(origin);      // sandboxed iframes send null
</code></pre>
<h2>Testing Your CORS Setup</h2>
<p>CORS bugs show up in browsers, but you can pin down the server side with ordinary feature tests. Assert both the happy path and that hostile origins get nothing:</p>
<pre><code class="language-php">it('answers a preflight from an allowed origin', function () {
    config(['cors.allowed_origins' =&gt; ['https://app.example.com']]);

    $this-&gt;withHeaders([
        'Origin' =&gt; 'https://app.example.com',
        'Access-Control-Request-Method' =&gt; 'POST',
        'Access-Control-Request-Headers' =&gt; 'content-type, authorization',
    ])-&gt;options('/api/orders')
        -&gt;assertHeader('Access-Control-Allow-Origin', 'https://app.example.com')
        -&gt;assertHeader('Access-Control-Allow-Credentials', 'true')
        -&gt;assertHeader('Access-Control-Max-Age', '7200');
});

it('gives no CORS headers to an unknown origin', function () {
    config(['cors.allowed_origins' =&gt; ['https://app.example.com']]);

    $this-&gt;withHeaders(['Origin' =&gt; 'https://app.example.com.evil.io'])
        -&gt;getJson('/api/health')
        -&gt;assertHeaderMissing('Access-Control-Allow-Origin');
});

it('never combines a wildcard with credentials', function () {
    $origins = config('cors.allowed_origins');

    expect(config('cors.supports_credentials') &amp;&amp; in_array('*', $origins, true))-&gt;toBeFalse();
});
</code></pre>
<p>The third test is a cheap guard rail: it fails the build the moment someone sets the dangerous wildcard plus credentials combination. For an end-to-end check, run your SPA's Playwright suite against a staging API on its real domain, not through a dev proxy that hides CORS entirely.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Reflecting the Origin header.</strong> Equivalent to <code>*</code> with credentials. Always check against an allowlist.</li>
<li><strong>Suffix and unanchored regex matching.</strong> <code>endsWith('example.com')</code> accepts <code>evil-example.com</code>. Match exact origins, or anchored patterns.</li>
<li><strong>Allowing <code>null</code>.</strong> Sandboxed iframes, <code>file://</code> pages and some redirects send <code>Origin: null</code>. An attacker can produce it on purpose.</li>
<li><strong>Forgetting <code>Vary: Origin</code>.</strong> Leads to cached responses with the wrong origin, which looks like random CORS failures behind a CDN.</li>
<li><strong>CORS errors that are really 401, 413 or 502.</strong> If Nginx, a load balancer or a crashed PHP worker answers without CORS headers, the browser reports a CORS error and hides the real status. Check the network tab or server logs before touching CORS config.</li>
<li><strong>Treating CORS as access control.</strong> It stops browsers from reading responses. It does not stop curl, does not stop a simple POST from executing, and does not replace authentication or CSRF protection.</li>
<li><strong>Preflight hitting auth middleware.</strong> <code>OPTIONS</code> requests carry no <code>Authorization</code> header. If your auth layer runs before CORS and returns 401, the preflight fails. In Laravel, <code>HandleCors</code> is global and runs first, so keep it there.</li>
</ul>
<h2>When to Use CORS (and When Not To)</h2>
<ul>
<li><strong>Use it</strong> when browser JavaScript on one origin legitimately needs another origin's API: an SPA on <code>app.</code> calling <code>api.</code>, a public read-only API used by third-party sites (here <code>*</code> without credentials is fine), or a widget embedded on customer domains with an explicit allowlist.</li>
<li><strong>Avoid it</strong> when you can serve the frontend and API from the same origin. Putting the API under <code>https://example.com/api</code> behind the same reverse proxy (or using Next.js rewrites) removes CORS, preflight latency and cookie domain headaches entirely.</li>
<li><strong>Do not reach for it</strong> for server-to-server calls, mobile apps or CLI tools. They do not enforce it, so it neither helps nor protects.</li>
<li><strong>Keep it narrow</strong>: specific paths, explicit origins, explicit headers, and credentials only where you really use cookies.</li>
</ul>
<p>If you remember one rule: CORS is the server telling the browser whom it trusts. Make that list short, exact and boring.</p>]]></content:encoded>
                                </item>
                <item>
            <title>PHP Generators for Memory-Efficient Processing: yield, yield from, send() and LazyCollection</title>
            <link>https://cslant.com/tips/php-generators-for-memory-efficient-processing-yield-yield-from-send-and-lazycollection</link>
            <guid isPermaLink="false">https://cslant.com/tips/php-generators-for-memory-efficient-processing-yield-yield-from-send-and-lazycollection</guid>
                        <pubDate>Fri, 06 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>PHP</category>
                                    <description>Stream a 2 GB CSV in 2 MB of RAM. How PHP generators work: yield with keys, yield from, send(), Laravel LazyCollection, cursor vs lazyById.</description>
                                    <content:encoded><![CDATA[<p>The nightly import worked fine for a year. Then a partner started sending their full order history instead of deltas: one 2.1 GB CSV. The job died on line 1 with <code>Allowed memory size of 536870912 bytes exhausted</code>. Someone bumped <code>memory_limit</code> to 4G, the job got further, and then the container got OOM-killed and took the queue worker with it.</p>
<p>The code was the usual one: read the file into an array, map it into another array, loop. Memory grows with input size, so every larger file is a new incident. Generators flip that around: you process one row at a time and memory stays flat no matter how big the file gets. They have been in PHP since 5.5 and are still underused, so here is the full toolbox: <code>yield</code>, keys, <code>yield from</code>, <code>send()</code>, and how Laravel's <code>LazyCollection</code> builds on them.</p>
<h2>Why the Naive Version Explodes</h2>
<pre><code class="language-php">// Loads the whole file, then builds a second full copy as arrays.
function importOrders(string $path): int
{
    $lines = file($path, FILE_IGNORE_NEW_LINES);   // 2 GB of strings
    $header = str_getcsv(array_shift($lines), ',', '"', '');
    $rows = array_map(
        fn (string $line) =&gt; array_combine($header, str_getcsv($line, ',', '"', '')),
        $lines,
    );                                              // another few GB of arrays

    foreach ($rows as $row) {
        // insert...
    }

    return count($rows);
}
</code></pre>
<p>PHP strings and arrays carry overhead. A 2 GB CSV loaded with <code>file()</code> costs more than 2 GB, and turning each line into an associative array multiplies that several times. Even <code>array_map</code> over a smaller file keeps the source and the result alive at the same time. The fix is not more RAM. The fix is never holding more than one row.</p>
<h2>How a Generator Works</h2>
<p>Any function that contains <code>yield</code> becomes a generator function. Calling it runs <strong>no code</strong>; it returns a <code>Generator</code> object. Each time the consumer asks for the next value, the function body resumes from where it paused, runs until the next <code>yield</code>, hands over a value, and freezes again with its local variables intact.</p>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
    participant C as Consumer foreach
    participant G as Generator
    participant F as CSV file
    C-&gt;&gt;G: next
    G-&gt;&gt;F: fgetcsv one line
    F--&gt;&gt;G: fields
    G--&gt;&gt;C: yield line number and row
    C-&gt;&gt;C: process row then drop it
    C-&gt;&gt;G: next
    G--&gt;&gt;C: return when file ends</div></div>
<p>Only the current row exists in memory. The file handle, the header and the line counter live in the generator's paused stack frame. When the consumer stops (a <code>break</code>, an exception, or the generator going out of scope), PHP destroys the generator and runs any pending <code>finally</code> blocks, so the file still gets closed.</p>
<h2>Implementation: Streaming a CSV</h2>
<pre><code class="language-php">/**
 * Stream a CSV file one row at a time.
 *
 * @return Generator&lt;int, array&lt;string, string&gt;&gt; line number =&gt; associative row
 */
function readCsv(string $path): Generator
{
    $handle = fopen($path, 'rb');
    if ($handle === false) {
        throw new RuntimeException("Cannot open $path");
    }

    try {
        $header = fgetcsv($handle, null, ',', '"', '');
        $line = 1;

        while (($fields = fgetcsv($handle, null, ',', '"', '')) !== false) {
            $line++;
            if ($fields === [null]) {
                continue; // blank line
            }
            yield $line =&gt; array_combine($header, $fields);
        }
    } finally {
        fclose($handle); // runs even if the consumer stops early
    }
}

foreach (readCsv('/data/orders.csv') as $line =&gt; $row) {
    if ($row['status'] === 'cancelled') {
        continue;
    }
    // process one row; previous rows are already garbage
}

echo round(memory_get_peak_usage() / 1024 / 1024, 1), " MB\n";
</code></pre>
<p>A few details that matter in production:</p>
<ul>
<li><strong>Keys.</strong> <code>yield $key =&gt; $value</code> sets the key. Without it, keys are auto-incremented integers like a list. Using the line number as the key gives you precise error messages for free.</li>
<li><strong><code>try</code>/<code>finally</code>.</strong> Put cleanup in <code>finally</code>, not after the loop. If the consumer breaks early, code after the loop never runs, but <code>finally</code> does when the generator is destroyed.</li>
<li><strong>Explicit CSV escape.</strong> Since PHP 8.4, relying on the default <code>$escape</code> argument of <code>fgetcsv()</code> and <code>str_getcsv()</code> is deprecated. Pass <code>''</code> to get RFC 4180 behaviour.</li>
<li><strong>Blank lines.</strong> <code>fgetcsv()</code> returns <code>[null]</code> for an empty line. Skip it explicitly or <code>array_combine()</code> will throw a <code>ValueError</code> about mismatched counts.</li>
</ul>
<p>With this version the peak memory for the 2.1 GB file was about 2 MB, the same as for a 2 KB file.</p>
<h2>Composing Generators with <code>yield from</code></h2>
<p><code>yield from</code> delegates to another generator (or any iterable) and passes its values straight through. Its value as an expression is the inner generator's <code>return</code> value, which is a neat way to aggregate stats while streaming:</p>
<pre><code class="language-php">/** Concatenate several CSV exports into one stream and report the total. */
function readAll(array $paths): Generator
{
    $total = 0;
    foreach ($paths as $path) {
        // yield from forwards every key/value of the inner generator
        // and evaluates to the inner generator's return value.
        $total += yield from countingReader($path);
    }

    return $total;
}

function countingReader(string $path): Generator
{
    $n = 0;
    foreach (readCsv($path) as $line =&gt; $row) {
        $n++;
        yield $line =&gt; $row;
    }

    return $n;
}

$stream = readAll(['/data/2026-10.csv', '/data/2026-11.csv']);
foreach ($stream as $line =&gt; $row) {
    // keys restart at 2 for each file: they are NOT unique across files
}
echo $stream-&gt;getReturn(), " rows\n";

// Pitfall: iterator_to_array() keeps keys by default, so duplicate keys overwrite.
$rows = iterator_to_array(readAll(['/data/a.csv', '/data/b.csv']), preserve_keys: false);
</code></pre>
<p><code>$generator-&gt;getReturn()</code> is available only after the generator has finished; calling it earlier throws. And watch the keys: <code>yield from</code> forwards inner keys unchanged, so two files both produce key <code>2</code>. <code>iterator_to_array()</code> preserves keys by default and silently overwrites, which is how "the import only kept the last file's rows" bugs are born. Pass <code>preserve_keys: false</code> or do not materialize at all.</p>
<h2>Two-Way Generators with <code>send()</code></h2>
<p>Generators can also receive values. <code>$gen-&gt;send($value)</code> resumes the generator and makes the paused <code>yield</code> expression evaluate to <code>$value</code>. That turns a generator into a small coroutine, which is handy for a batching sink:</p>
<pre><code class="language-php">/**
 * A coroutine: receives rows through send(), flushes them in batches,
 * and returns the number of rows written when it receives null.
 */
function batchWriter(Closure $flush, int $size = 1000): Generator
{
    $buffer = [];
    $written = 0;

    while (true) {
        $row = yield;              // pauses here until send($row)
        if ($row === null) {
            break;
        }
        $buffer[] = $row;
        if (count($buffer) &gt;= $size) {
            $flush($buffer);
            $written += count($buffer);
            $buffer = [];
        }
    }

    if ($buffer !== []) {
        $flush($buffer);
        $written += count($buffer);
    }

    return $written;
}

$writer = batchWriter(fn (array $rows) =&gt; printf("flush %d\n", count($rows)), 2);
$writer-&gt;current();               // run to the first yield
foreach ([['id' =&gt; 1], ['id' =&gt; 2], ['id' =&gt; 3]] as $row) {
    $writer-&gt;send($row);
}
$writer-&gt;send(null);              // flush 2, flush 1
echo $writer-&gt;getReturn(), "\n";  // 3
</code></pre>
<p>The first <code>send()</code> implicitly runs the generator to its first <code>yield</code> before delivering the value, but calling <code>current()</code> first makes the intent explicit. Most application code never needs <code>send()</code>; reach for it when a consumer-side state machine (batching, rate counting, a parser that receives chunks) reads more clearly as one function than as a class with fields.</p>
<h2>Laravel: LazyCollection</h2>
<p><code>LazyCollection</code> wraps a generator and gives you the Collection API (<code>filter</code>, <code>map</code>, <code>chunk</code>, <code>take</code>, <code>each</code>, <code>reduce</code>) while staying lazy: each operation pulls items through one at a time instead of building intermediate arrays. Here is the import job rewritten as an Artisan command:</p>
<pre><code class="language-php">namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;

final class ImportOrders extends Command
{
    protected $signature = 'orders:import {path}';

    protected $description = 'Stream a large CSV into the orders table';

    public function handle(): int
    {
        $path = $this-&gt;argument('path');

        $imported = self::rows($path)
            -&gt;filter(fn (array $row) =&gt; $row['status'] !== 'cancelled')
            -&gt;map(fn (array $row) =&gt; [
                'external_id' =&gt; $row['id'],
                'email' =&gt; strtolower(trim($row['email'])),
                'total_cents' =&gt; (int) round(((float) $row['total']) * 100),
                'created_at' =&gt; $row['created_at'],
            ])
            -&gt;chunk(1000)
            -&gt;reduce(function (int $count, LazyCollection $chunk) {
                DB::table('orders')-&gt;upsert($chunk-&gt;values()-&gt;all(), ['external_id']);

                return $count + $chunk-&gt;count();
            }, 0);

        $this-&gt;info("Imported {$imported} rows, peak "
            . round(memory_get_peak_usage(true) / 1048576) . ' MB');

        return self::SUCCESS;
    }

    /** @return LazyCollection&lt;int, array&lt;string, string&gt;&gt; */
    public static function rows(string $path): LazyCollection
    {
        return LazyCollection::make(function () use ($path) {
            $handle = fopen($path, 'rb');
            try {
                $header = fgetcsv($handle, null, ',', '"', '');
                while (($fields = fgetcsv($handle, null, ',', '"', '')) !== false) {
                    if ($fields !== [null]) {
                        yield array_combine($header, $fields);
                    }
                }
            } finally {
                fclose($handle);
            }
        });
    }
}
</code></pre>
<p><code>chunk(1000)</code> yields <code>LazyCollection</code> chunks, so you batch the database writes (one <code>upsert</code> per 1000 rows instead of 1000 queries) while still holding at most 1000 rows in memory. <code>upsert</code> keyed on <code>external_id</code> makes the import safe to rerun after a crash halfway through.</p>
<p>Eloquent has lazy readers too, and they are not interchangeable:</p>
<pre><code class="language-php">use App\Models\Order;

// One query, models hydrated one at a time.
foreach (Order::query()-&gt;where('status', 'paid')-&gt;cursor() as $order) {
    // ...
}

// Many small queries keyed by id: stable memory, safe while updating rows.
Order::query()-&gt;where('status', 'paid')-&gt;lazyById(1000)-&gt;each(function (Order $order) {
    $order-&gt;update(['exported_at' =&gt; now()]);
});
</code></pre>
<p><code>cursor()</code> runs one query and hydrates models one by one, but on MySQL the PDO driver buffers the entire result set by default, so very large results can still eat memory. <code>lazy()</code> and <code>lazyById()</code> page through the table with many small queries; <code>lazyById()</code> is the safe choice when you update the rows you are iterating, because it pages by primary key instead of offset.</p>
<h2>Testing It</h2>
<p>Two things deserve a test: the stream really is lazy, and memory stays bounded on a large input. Generating a 200k-row file in the test takes about a second and catches the regression where someone adds <code>-&gt;all()</code> or <code>-&gt;collect()</code> in the middle of the pipeline, which forces the whole file into memory.</p>
<pre><code class="language-php">// tests/Feature/ImportOrdersTest.php
use App\Console\Commands\ImportOrders;
use Illuminate\Support\Facades\DB;

function writeCsv(int $rows): string
{
    $path = tempnam(sys_get_temp_dir(), 'orders');
    $h = fopen($path, 'wb');
    fwrite($h, "id,email,total,status,created_at\n");
    for ($i = 1; $i &lt;= $rows; $i++) {
        $status = $i % 10 === 0 ? 'cancelled' : 'paid';
        fwrite($h, "{$i}, User{$i}@Example.com ,19.99,{$status},2026-11-01 10:00:00\n");
    }
    fclose($h);

    return $path;
}

it('streams rows lazily', function () {
    $path = writeCsv(5);
    $rows = ImportOrders::rows($path);

    expect($rows-&gt;first())-&gt;toMatchArray(['id' =&gt; '1', 'status' =&gt; 'paid'])
        -&gt;and($rows-&gt;take(3)-&gt;count())-&gt;toBe(3);
});

it('imports a big file in bounded memory', function () {
    DB::table('orders')-&gt;delete();
    $path = writeCsv(200_000);
    $before = memory_get_usage();

    $this-&gt;artisan('orders:import', ['path' =&gt; $path])-&gt;assertSuccessful();

    expect(DB::table('orders')-&gt;count())-&gt;toBe(180_000)
        -&gt;and(DB::table('orders')-&gt;where('external_id', '1')-&gt;value('email'))-&gt;toBe('user1@example.com')
        -&gt;and(memory_get_peak_usage() - $before)-&gt;toBeLessThan(64 * 1024 * 1024);
});
</code></pre>
<p>The memory assertion is deliberately generous. For unit-level checks of pure generator functions, assert on <code>iterator_to_array($gen, false)</code> with small inputs and feed an infinite generator to prove that only the needed items are pulled.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Iterating twice.</strong> A generator is single-use. A second <code>foreach</code> throws <code>Exception: Cannot traverse an already closed generator</code>. <code>LazyCollection::make(fn () =&gt; ...)</code> takes a <em>closure</em> exactly so it can create a fresh generator for every pass, which also means each pass re-reads the file.</li>
<li><strong>Calling <code>count()</code> or <code>-&gt;all()</code> on the stream.</strong> Anything that needs the whole sequence materializes it. <code>LazyCollection::count()</code> iterates everything; <code>-&gt;all()</code> builds the full array.</li>
<li><strong><code>iterator_to_array()</code> with duplicate keys.</strong> Values get overwritten silently. Use <code>preserve_keys: false</code>.</li>
<li><strong>Cleanup after the loop instead of in <code>finally</code>.</strong> Early <code>break</code> skips it and you leak file handles or database cursors.</li>
<li><strong>Expecting laziness from <code>cursor()</code> on MySQL.</strong> The query is lazy, the result buffer is not. Use <code>lazyById()</code> for huge tables.</li>
<li><strong>Holding references.</strong> Pushing each row into <code>$this-&gt;seen[]</code> for deduplication rebuilds the memory problem. Keep a set of ids or hashes, or deduplicate in the database.</li>
</ul>
<h2>When to Use Generators and When Not To</h2>
<p>Use them when:</p>
<ul>
<li>input size is unbounded or large: files, exports, API pagination, database tables;</li>
<li>you want a pipeline (read, filter, transform, batch, write) without intermediate arrays;</li>
<li>you need to stop early: <code>take(10)</code> on a lazy stream reads only what it needs;</li>
<li>you want to separate "how to fetch" from "what to do with each item" without callbacks.</li>
</ul>
<p>Avoid them when:</p>
<ul>
<li>the data is small and you need random access, sorting, or multiple passes; a plain array is simpler and faster;</li>
<li>you need the total count up front, for example for a progress bar, unless you can get it cheaply from somewhere else;</li>
<li>the work is CPU-bound per item and you want parallelism; generators are sequential, so use queued jobs per chunk instead;</li>
<li>debugging lazy chains confuses your team more than the memory saving helps.</li>
</ul>
<p>Memory usage should depend on the batch size you pick, not on the size of the input file.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Optimistic Locking With a Version Column: Fix Lost Updates in Laravel (vs lockForUpdate)</title>
            <link>https://cslant.com/tips/optimistic-locking-with-a-version-column-fix-lost-updates-in-laravel-vs-lockforupdate</link>
            <guid isPermaLink="false">https://cslant.com/tips/optimistic-locking-with-a-version-column-fix-lost-updates-in-laravel-vs-lockforupdate</guid>
                        <pubDate>Thu, 05 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>Two users save the same record and one change silently disappears. Fix lost updates with a version column, 409 responses and retries, and know when lockForUpdate fits better.</description>
                                    <content:encoded><![CDATA[<p>Two support agents open the same product in the admin panel. Agent A lowers the price from 100 to 90 and saves. Agent B, still looking at the form she loaded a minute earlier, fixes the stock count and saves. The price is back to 100. No error, no warning, no log line. The next morning finance asks why a promotion never went live, and nobody can explain it, because from the database's point of view nothing went wrong.</p>
<p>This is the <strong>lost update</strong> problem. Each request did a perfectly valid read-modify-write; the bug is the gap between the read and the write. Database transactions alone do not fix it, because the read happened in one HTTP request and the write in another, minutes apart. The fix is to make the write conditional on the data not having changed since it was read.</p>
<h2>The Lost Update, Step by Step</h2>
<p>Here is what the two saves look like at the SQL level. Most CRUD forms send every field back, not just the changed ones, which is exactly what makes the second save overwrite the first:</p>
<pre><code class="language-sql">-- 10:00:00  Agent A loads product 42: price = 100, stock = 7
-- 10:00:02  Agent B loads product 42: price = 100, stock = 7
-- 10:00:05  Agent A saves a new price
UPDATE products SET price = 90, stock = 7 WHERE id = 42;
-- 10:00:09  Agent B saves a stock correction, using the price it loaded
UPDATE products SET price = 100, stock = 5 WHERE id = 42;
-- Result: price is back to 100. Agent A's change is gone and nobody got an error.
</code></pre>
<p>Even if your form only sent changed fields, the problem returns as soon as a value is derived from what was read: "add 10% to the price", "subtract 2 from stock". Any read-modify-write that spans time is exposed.</p>
<h2>Optimistic Locking With a Version Column</h2>
<p>Optimistic locking assumes conflicts are rare and checks for them at write time instead of preventing them up front. Add an integer <code>version</code> to the row. Every reader gets the version along with the data. Every writer says "apply this change only if the version is still the one I saw" and bumps it in the same statement:</p>
<pre><code class="language-sql">-- Each writer sends the version it read. Only one of them can match.
UPDATE products
SET price = 90, version = version + 1
WHERE id = 42 AND version = 7;   -- Agent A: 1 row affected, version is now 8

UPDATE products
SET stock = 5, version = version + 1
WHERE id = 42 AND version = 7;   -- Agent B: 0 rows affected, conflict detected
</code></pre>
<p>The <code>WHERE ... AND version = 7</code> makes the check and the write a single atomic operation. The database already serializes updates to the same row, so exactly one of the two statements can match. The loser sees zero affected rows and knows its view of the data is stale.</p>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
participant A as Agent A
participant App as Laravel app
participant DB as Database
participant B as Agent B
A-&gt;&gt;App: load product, gets version 7
B-&gt;&gt;App: load product, gets version 7
A-&gt;&gt;App: save price with version 7
App-&gt;&gt;DB: update where version is 7
DB--&gt;&gt;App: 1 row, version now 8
B-&gt;&gt;App: save stock with version 7
App-&gt;&gt;DB: update where version is 7
DB--&gt;&gt;App: 0 rows
App--&gt;&gt;B: 409 Conflict, reload</div></div>
<p>You could use <code>updated_at</code> as the version, but timestamps have second or millisecond resolution and two saves inside the same tick would both match. An integer that only ever increments is unambiguous.</p>
<h2>Laravel Implementation</h2>
<p>Start with the column. A default of 1 makes existing rows valid immediately:</p>
<pre><code class="language-php">use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table-&gt;unsignedInteger('version')-&gt;default(1)-&gt;after('id');
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table-&gt;dropColumn('version');
        });
    }
};
</code></pre>
<p>Eloquent's <code>save()</code> does not tell you how many rows an update affected, so do the conditional update through the query builder, where <code>update()</code> returns the affected row count:</p>
<pre><code class="language-php">namespace App\Services;

use App\Exceptions\StaleModelException;
use App\Models\Product;
use Illuminate\Support\Facades\DB;

final class ProductWriter
{
    /**
     * @param array&lt;string, mixed&gt; $changes
     * @throws StaleModelException when someone else saved first
     */
    public function update(int $productId, int $expectedVersion, array $changes): int
    {
        $affected = Product::query()
            -&gt;whereKey($productId)
            -&gt;where('version', $expectedVersion)
            -&gt;update([...$changes, 'version' =&gt; DB::raw('version + 1')]);

        if ($affected === 0) {
            throw new StaleModelException(
                Product::class,
                $productId,
                $expectedVersion,
                Product::whereKey($productId)-&gt;value('version'),
            );
        }

        return $expectedVersion + 1;
    }
}
</code></pre>
<p>A dedicated exception carries enough context to log and to answer the client. Laravel calls <code>render()</code> on exceptions that define it, so controllers stay clean:</p>
<pre><code class="language-php">namespace App\Exceptions;

use RuntimeException;

final class StaleModelException extends RuntimeException
{
    public function __construct(
        public readonly string $model,
        public readonly int $id,
        public readonly int $expectedVersion,
        public readonly ?int $currentVersion,
    ) {
        parent::__construct(
            $currentVersion === null
                ? "{$model} #{$id} no longer exists."
                : "{$model} #{$id} was modified: expected version {$expectedVersion}, found {$currentVersion}."
        );
    }

    public function render(): \Illuminate\Http\JsonResponse
    {
        return response()-&gt;json([
            'message' =&gt; 'This record was changed by someone else. Reload and try again.',
            'current_version' =&gt; $this-&gt;currentVersion,
        ], $this-&gt;currentVersion === null ? 404 : 409);
    }
}
</code></pre>
<p>The version has to travel with the data. For a JSON API, return it in the body and as an <code>ETag</code>, and require the client to send back the version it edited. For a Blade or Livewire form, a hidden <code>version</code> field does the same job:</p>
<pre><code class="language-php">namespace App\Http\Controllers;

use App\Models\Product;
use App\Services\ProductWriter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class ProductController
{
    public function show(Product $product): JsonResponse
    {
        return response()
            -&gt;json($product)
            -&gt;header('ETag', '"'.$product-&gt;version.'"');
    }

    public function update(Request $request, Product $product, ProductWriter $writer): JsonResponse
    {
        $data = $request-&gt;validate([
            'price' =&gt; ['sometimes', 'integer', 'min:0'],
            'stock' =&gt; ['sometimes', 'integer', 'min:0'],
            'version' =&gt; ['required', 'integer', 'min:1'],
        ]);

        $version = $writer-&gt;update($product-&gt;id, $data['version'], collect($data)-&gt;except('version')-&gt;all());

        return response()-&gt;json(['version' =&gt; $version])-&gt;header('ETag', '"'.$version.'"');
    }
}
</code></pre>
<p>The 409 response is a feature. The frontend can show "Someone changed this product while you were editing", reload the latest data, and let the user reapply their change knowingly. That is far better than silently discarding work.</p>
<h2>Retrying When There Is No Human</h2>
<p>Background jobs and scheduled tasks have nobody to show a conflict dialog to. For them, the right response to a stale version is usually: re-read, recompute, try again, a bounded number of times. Laravel's <code>retry()</code> helper fits well:</p>
<pre><code class="language-php">namespace App\Services;

use App\Exceptions\StaleModelException;
use App\Models\Product;
use Closure;

final class PriceAdjuster
{
    public function __construct(private ProductWriter $writer) {}

    /** Background jobs have no human to ask, so they re-read and retry a few times. */
    public function applyDiscount(int $productId, int $percent, int $maxAttempts = 3): void
    {
        retry($maxAttempts, function () use ($productId, $percent): void {
            $product = Product::findOrFail($productId);
            $newPrice = intdiv($product-&gt;price * (100 - $percent), 100);

            $this-&gt;writer-&gt;update($product-&gt;id, $product-&gt;version, ['price' =&gt; $newPrice]);
        }, sleepMilliseconds: fn (int $attempt): int =&gt; random_int(10, 50) * $attempt,
            when: fn (\Throwable $e): bool =&gt; $e instanceof StaleModelException);
    }
}
</code></pre>
<p>The re-read is essential: retrying the same write with the same old version would fail forever. The small random sleep spreads out competing workers so they do not collide again on the next attempt.</p>
<h2>Optimistic vs Pessimistic: lockForUpdate</h2>
<p>The alternative is to prevent conflicts instead of detecting them. <code>lockForUpdate()</code> issues <code>SELECT ... FOR UPDATE</code>, which locks the row until the transaction commits; any other transaction that wants the same lock waits:</p>
<pre><code class="language-php">use App\Models\Product;
use Illuminate\Support\Facades\DB;

// Pessimistic: lock the row, everyone else waits until commit.
DB::transaction(function () use ($productId, $quantity): void {
    $product = Product::whereKey($productId)-&gt;lockForUpdate()-&gt;firstOrFail();

    if ($product-&gt;stock &lt; $quantity) {
        throw new \DomainException('Not enough stock.');
    }

    $product-&gt;decrement('stock', $quantity);
}, attempts: 3);

// Often better for a single counter: let the database do the check atomically.
$reserved = Product::whereKey($productId)
    -&gt;where('stock', '&gt;=', $quantity)
    -&gt;decrement('stock', $quantity);
</code></pre>
<p>Pessimistic locking only works <strong>inside a single transaction</strong>. You cannot hold a row lock while an admin thinks for two minutes; that would block every other writer and exhaust connections. So the two approaches cover different shapes of work:</p>
<ul>
<li><strong>Pessimistic</strong> for short, server-side critical sections where conflicts are frequent: reserving stock at checkout, allocating seats, moving money between wallets.</li>
<li><strong>Optimistic</strong> for anything with think time between read and write (forms, editors, mobile apps syncing offline changes) and for rows that are read often but rarely edited at the same time.</li>
</ul>
<p>The second snippet above shows a third option that beats both for simple counters: an atomic conditional update. <code>stock = stock - 3 WHERE stock &gt;= 3</code> needs no version and no lock held across round trips.</p>
<h2>Testing the Conflict Path</h2>
<p>The conflict path is the code you hope never runs, which is exactly why it needs a test. Simulate the two agents by writing twice with the same starting version:</p>
<pre><code class="language-php">use App\Exceptions\StaleModelException;
use App\Models\Product;
use App\Services\ProductWriter;

it('rejects the second writer that loaded the same version', function () {
    $product = Product::factory()-&gt;create(['price' =&gt; 100, 'stock' =&gt; 7, 'version' =&gt; 1]);
    $writer = app(ProductWriter::class);

    expect($writer-&gt;update($product-&gt;id, 1, ['price' =&gt; 90]))-&gt;toBe(2);

    expect(fn () =&gt; $writer-&gt;update($product-&gt;id, 1, ['stock' =&gt; 5]))
        -&gt;toThrow(StaleModelException::class);

    expect($product-&gt;fresh())
        -&gt;price-&gt;toBe(90)
        -&gt;stock-&gt;toBe(7)
        -&gt;version-&gt;toBe(2);
});

it('returns 409 with the current version over HTTP', function () {
    $product = Product::factory()-&gt;create(['version' =&gt; 3]);

    $this-&gt;actingAs(\App\Models\User::factory()-&gt;create())
        -&gt;putJson("/api/products/{$product-&gt;id}", ['price' =&gt; 50, 'version' =&gt; 2])
        -&gt;assertStatus(409)
        -&gt;assertJsonPath('current_version', 3);
});
</code></pre>
<p>The first test proves that the losing write changes nothing (stock is still 7). The second proves the HTTP contract the frontend depends on. If you have a retrying writer, add a test where the first attempt is made stale on purpose, as the challenge below does.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Bumping the version in PHP instead of SQL.</strong> <code>['version' =&gt; $model-&gt;version + 1]</code> without the <code>WHERE version = ?</code> check is just a counter. The condition is the lock.</li>
<li><strong>Ignoring the affected row count.</strong> On MySQL, an <code>UPDATE</code> that matches a row but changes no values can report 0 affected rows by default. Because the version always increments, a matching row always changes, so 0 really means "no match".</li>
<li><strong>Taking the version from the database at save time.</strong> If the controller loads the model and uses its current version, the check always passes. The version must come from what the <em>client</em> read.</li>
<li><strong>Mass updates that skip the version.</strong> A maintenance script or <code>Product::query()-&gt;update([...])</code> that does not bump <code>version</code> lets stale clients overwrite its changes. Every writer must participate.</li>
<li><strong>Retrying forever.</strong> Unbounded retries on a hot row turn a conflict into a livelock. Cap attempts and surface the failure.</li>
</ul>
<h2>When to Use It, and When Not To</h2>
<p>Use optimistic locking when edits are human-paced, conflicts are uncommon, and losing a write is unacceptable: admin panels, CMS content, settings, orders being edited by staff, offline-first mobile sync. It costs one integer column and one extra <code>WHERE</code> condition, and it never blocks readers or other writers.</p>
<p>Do not use it for highly contended rows where many processes write constantly, such as a global counter or a popular item's stock during a flash sale; most writers will lose and retry. Use an atomic update or a short pessimistic lock there. And do not add it to append-only tables or rows that only one process ever writes; there is no conflict to detect.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Health Checks Done Right: Liveness vs Readiness vs Startup Probes</title>
            <link>https://cslant.com/tips/health-checks-done-right-liveness-vs-readiness-vs-startup-probes</link>
            <guid isPermaLink="false">https://cslant.com/tips/health-checks-done-right-liveness-vs-readiness-vs-startup-probes</guid>
                        <pubDate>Wed, 04 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>A database query in a liveness probe can turn a 40-second failover into a full outage. Learn what each probe should check, with Kubernetes, Docker and Laravel /up.</description>
                                    <content:encoded><![CDATA[<p>At 02:14 the primary database fails over. It takes about 40 seconds. Annoying, but the app is built to retry. Except that someone added a database query to the liveness probe last quarter, "to make the health check more thorough". Kubernetes sees every pod failing liveness at the same moment and restarts all of them. Now the database is back, but every pod is cold-booting, running config caching and warming connections at the same time, and the 40-second blip becomes a 9-minute outage with a thundering herd on top.</p>
<p>Health checks look like a one-line YAML concern, but each probe type answers a different question and triggers a different action. Mix them up and the platform that is supposed to heal your app amplifies every incident instead.</p>
<h2>Three Probes, Three Questions</h2>
<p>Kubernetes has three probe types. Docker has one <code>HEALTHCHECK</code>. What matters is the question each one asks and what happens when the answer is "no":</p>
<ul>
<li><strong>Liveness: "Is this process broken beyond self-repair?"</strong> On failure the kubelet kills the container and restarts it. Only deadlocks, wedged event loops or corrupted in-memory state justify this. A restart fixes nothing outside the container.</li>
<li><strong>Readiness: "Should this pod receive traffic right now?"</strong> On failure the pod is removed from the Service endpoints. Nothing is restarted. When the probe passes again, traffic comes back. This is where dependency awareness belongs, carefully.</li>
<li><strong>Startup: "Has the app finished booting?"</strong> While it has not passed, liveness and readiness are not run. It protects slow starters (cache warmup, migration checks, JIT warmup) from being killed by a liveness probe tuned for steady state.</li>
</ul>
<p>The defaults are <code>periodSeconds: 10</code>, <code>timeoutSeconds: 1</code>, <code>failureThreshold: 3</code>, <code>successThreshold: 1</code>. That one-second timeout bites PHP apps under load more often than any other setting.</p>
<h2>The Probe Lifecycle</h2>
<div class="page-mermaid"><div class="mermaid">graph TD
A[Container starts] --&gt; B{Startup probe passes}
B --&gt;|no, threshold hit| C[Kubelet restarts container]
B --&gt;|yes| D[Liveness and readiness begin]
D --&gt; E{Liveness fails}
E --&gt;|yes| C
D --&gt; F{Readiness fails}
F --&gt;|yes| G[Removed from Service endpoints]
F --&gt;|no| H[Receives traffic]</div></div>
<p>Read it as two separate loops after startup: liveness decides whether to restart, readiness decides whether to route. A pod can be alive and unready (draining, dependency down, warming up) for as long as needed without being restarted, which is exactly the state you want during a database failover.</p>
<h2>What to Check, and What Not To</h2>
<p><strong>Liveness should check almost nothing.</strong> The request reaching your code and getting a response is the check. In Laravel that is the built-in <code>/up</code> route: it boots the framework, fires a <code>DiagnosingHealth</code> event, and returns 200, or 500 if something throws. No database, no Redis, no external API. If PHP-FPM is out of workers the probe times out, which is a fair signal that the container is stuck.</p>
<p><strong>Readiness may check dependencies this pod cannot work without</strong>, and only those. If every request needs MySQL, a failing MySQL check is a reason to stop routing to <em>this</em> pod. But think about the fleet: if all pods share the same database, all of them go unready together and the Service has zero endpoints. Users get connection errors from the load balancer instead of a clean 503 from your app. For shared dependencies, many teams deliberately keep readiness green and let the app return proper errors, circuit-break, or serve cached data.</p>
<p><strong>Never check third-party APIs in any probe.</strong> A payment provider outage should degrade checkout, not pull your whole API out of rotation.</p>
<p><strong>Keep probes cheap and bounded.</strong> A probe runs every few seconds on every pod. A <code>SELECT COUNT(*)</code> on a large table, or an uncapped connection attempt that waits for the default TCP timeout, turns the probe into load and makes it time out on its own.</p>
<h2>Laravel: /up for Liveness, /ready for Readiness</h2>
<p>Laravel 11 and later register the health route in <code>bootstrap/app.php</code>. Keep it as the liveness endpoint:</p>
<pre><code class="language-php">// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    -&gt;withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up', // liveness: boots the framework, touches no dependency
    )
    -&gt;withMiddleware(function (Middleware $middleware): void {})
    -&gt;withExceptions(function (Exceptions $exceptions): void {})
    -&gt;create();
</code></pre>
<p>For readiness, add a separate invokable controller that runs fast checks and returns 503 when a critical one fails:</p>
<pre><code class="language-php">namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Throwable;

final class ReadinessController
{
    public function __invoke(): JsonResponse
    {
        $checks = [
            'database' =&gt; ['critical' =&gt; true, 'run' =&gt; fn () =&gt; DB::select('select 1')],
            'redis' =&gt; ['critical' =&gt; true, 'run' =&gt; fn () =&gt; Redis::connection()-&gt;ping()],
        ];

        $results = [];
        $ready = true;

        foreach ($checks as $name =&gt; $check) {
            try {
                ($check['run'])();
                $results[$name] = 'ok';
            } catch (Throwable $e) {
                report($e);
                $results[$name] = 'fail';
                $ready = $ready &amp;&amp; ! $check['critical'];
            }
        }

        return response()-&gt;json(
            ['status' =&gt; $ready ? 'ready' : 'not_ready', 'checks' =&gt; $results],
            $ready ? 200 : 503,
        );
    }
}
</code></pre>
<p>Register it with <code>Route::get('/ready', ReadinessController::class);</code> outside any auth or session middleware, and exclude both routes from rate limiting. The <code>critical</code> flag is the important design choice: a non-critical dependency (a search index, a recommendations service) can fail without taking the pod out of rotation. Reporting the exception with <code>report()</code> gives you the cause in your error tracker instead of a bare 503 in the kubelet events.</p>
<h2>Kubernetes Configuration</h2>
<p>Point liveness and startup at <code>/up</code>, readiness at <code>/ready</code>, and size the startup window from measured boot time:</p>
<pre><code class="language-yaml">containers:
  - name: app
    image: registry.example.com/shop-api:2026.11.04
    ports:
      - containerPort: 8080
    startupProbe:            # gate: liveness/readiness wait until this passes
      httpGet: { path: /up, port: 8080 }
      periodSeconds: 5
      failureThreshold: 24   # up to 120s to boot
    livenessProbe:           # "is the process wedged?" -&gt; restart
      httpGet: { path: /up, port: 8080 }
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3
    readinessProbe:          # "can it serve traffic right now?" -&gt; remove from Service
      httpGet: { path: /ready, port: 8080 }
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 2
    lifecycle:
      preStop:
        exec: { command: ["sleep", "5"] }  # let endpoints update before SIGTERM
</code></pre>
<p><code>failureThreshold * periodSeconds</code> on the startup probe is the maximum boot time you allow: 24 x 5 = 120 seconds here. The <code>preStop</code> sleep covers the gap between the pod being marked terminating and every node's proxy dropping it from the endpoints; without it, a rolling deploy sends a few requests to a container that is already shutting down. That command needs a <code>sleep</code> binary in the image.</p>
<h2>Docker and Compose Healthchecks</h2>
<p>Plain Docker only has one healthcheck, and it is informational: Docker marks the container <code>healthy</code> or <code>unhealthy</code> but does not restart it (Swarm and Compose <code>depends_on</code> conditions do act on it). Use the liveness endpoint:</p>
<pre><code class="language-text"># Dockerfile
HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=3 \
  CMD curl -fsS http://127.0.0.1:8080/up || exit 1
</code></pre>
<p><code>--start-period</code> is Docker's version of a startup probe: failures during that window do not count against <code>--retries</code>. Use <code>127.0.0.1</code> rather than <code>localhost</code> to avoid IPv6 resolution surprises, and make sure <code>curl</code> (or <code>wget</code>) actually exists in your final image; distroless and slim images often lack both.</p>
<p>When something goes wrong, look at what the platform saw, not what you think the endpoint returns:</p>
<pre><code class="language-bash">docker inspect --format '{{json .State.Health}}' shop-api | jq '.Status, .Log[-1]'
kubectl describe pod shop-api-7c9f8 | grep -A3 -E 'Liveness|Readiness|Startup'
kubectl get endpoints shop-api   # unready pods disappear from this list
</code></pre>
<h2>Testing Health Endpoints</h2>
<p>Health endpoints are code, and the most important property to test is the <em>negative</em> one: liveness must stay green when dependencies fail. A Pest feature test with facade mocks covers it:</p>
<pre><code class="language-php">use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;

it('keeps /up green even when the database is down', function () {
    DB::shouldReceive('select')-&gt;andThrow(new RuntimeException('db down'));

    $this-&gt;get('/up')-&gt;assertOk();
});

it('reports not ready when a critical dependency fails', function () {
    DB::shouldReceive('select')-&gt;andThrow(new RuntimeException('db down'));
    Redis::shouldReceive('connection-&gt;ping')-&gt;andReturn(true);

    $this-&gt;getJson('/ready')
        -&gt;assertStatus(503)
        -&gt;assertJsonPath('checks.database', 'fail')
        -&gt;assertJsonPath('checks.redis', 'ok');
});

it('reports ready when every check passes', function () {
    DB::shouldReceive('select')-&gt;andReturn([]);
    Redis::shouldReceive('connection-&gt;ping')-&gt;andReturn(true);

    $this-&gt;getJson('/ready')-&gt;assertOk()-&gt;assertJsonPath('status', 'ready');
});
</code></pre>
<p>The first test is the one that would have prevented the 02:14 incident. If someone later adds a database call to a <code>DiagnosingHealth</code> listener, it fails in CI instead of in production. Back it up with one end-to-end check in your deploy pipeline: start the container, wait for the healthcheck to report <code>healthy</code>, and fail the pipeline if it never does.</p>
<h2>Common Pitfalls</h2>
<p><strong>Dependencies in liveness.</strong> The classic cascading failure. A shared dependency fails, every pod restarts at once, and recovery gets slower and noisier. Liveness checks the process, nothing else.</p>
<p><strong>Same endpoint for liveness and readiness.</strong> It forces you to choose between restarts on dependency blips and no traffic control at all. Two endpoints, two questions.</p>
<p><strong>Timeouts tighter than real latency.</strong> A one-second timeout on a pod at 90% CPU fails intermittently, the pod flaps between ready and unready, and the load shifts to the others, which then flap too. Measure p99 of the probe under load and set the timeout well above it.</p>
<p><strong>No startup probe for slow boots.</strong> Teams compensate with a huge <code>initialDelaySeconds</code> on liveness, which also delays detection of real hangs forever after a restart. Use a startup probe instead.</p>
<p><strong>Probes behind auth, sessions or rate limits.</strong> A probe that hits session middleware writes a session per call; one behind a rate limiter fails during traffic spikes, exactly when you need it to be accurate.</p>
<p><strong>Health checks that log every call.</strong> At three probes every five seconds across 30 pods, access logs fill with noise. Exclude the probe paths from access logging or sample them.</p>
<h2>When to Use Each Probe, and When Not To</h2>
<p>Use <strong>readiness</strong> on every service that receives traffic through a Service or load balancer. It is the probe with the best risk/benefit ratio, because failing it never destroys anything.</p>
<p>Use <strong>liveness</strong> only when you have a realistic failure mode that a restart fixes: memory leaks that wedge workers, deadlocks, an event loop that stops processing. If you cannot name one, leaving liveness off is a valid choice; a missing liveness probe is safer than a wrong one.</p>
<p>Use <strong>startup</strong> whenever boot time is longer or more variable than your liveness budget, which is typical for apps that warm caches or wait for config at boot.</p>
<p>Skip dependency checks in readiness when the dependency is shared by the whole fleet and the app can return meaningful errors on its own. Skip HTTP probes entirely for queue workers that expose no port; an <code>exec</code> probe that checks a heartbeat file written by the worker loop is a better fit there.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Promise.all vs allSettled vs race vs any: Semantics, Error Handling, Timeouts and Concurrency Limits</title>
            <link>https://cslant.com/tips/promiseall-vs-allsettled-vs-race-vs-any-semantics-error-handling-timeouts-and-concurrency-limits</link>
            <guid isPermaLink="false">https://cslant.com/tips/promiseall-vs-allsettled-vs-race-vs-any-semantics-error-handling-timeouts-and-concurrency-limits</guid>
                        <pubDate>Tue, 03 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>JavaScript</category>
                                    <description>One flaky service took down a whole dashboard through Promise.all. Learn what each combinator really does, how to add timeouts, and how to cap concurrency.</description>
                                    <content:encoded><![CDATA[<p>The account dashboard loads six widgets: profile, orders, invoices, recommendations, notifications and loyalty points. Someone wrapped all six calls in <code>Promise.all</code> because it was faster than awaiting them one by one. Then the recommendations service had a bad afternoon, and every dashboard request returned a 500, even though the other five services were perfectly healthy. The same week, a nightly job did <code>Promise.all(customers.map(sendEmail))</code> over 8,000 customers, opened thousands of sockets at once and got rate limited by the email provider within seconds.</p>
<p>Both incidents come from the same gap: treating the promise combinators as interchangeable "run things in parallel" helpers. They are not. <code>Promise.all</code>, <code>Promise.allSettled</code>, <code>Promise.race</code> and <code>Promise.any</code> differ in when they settle, what they do with failures, and what they return. None of them limits concurrency, and none of them cancels anything. Pick the right one and add the missing pieces yourself.</p>
<h2>The Four Combinators at a Glance</h2>
<p>All four take an iterable of promises (or plain values) and return a single promise. The difference is the rule for settling:</p>
<table>
<thead>
<tr>
<th>Combinator</th>
<th>Fulfils when</th>
<th>Rejects when</th>
<th>Result</th>
<th>Empty input</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Promise.all</code></td>
<td>every input fulfils</td>
<td>the first input rejects</td>
<td>array of values, input order</td>
<td>fulfils with <code>[]</code></td>
</tr>
<tr>
<td><code>Promise.allSettled</code></td>
<td>every input settles</td>
<td>never</td>
<td>array of <code>{ status, value / reason }</code></td>
<td>fulfils with <code>[]</code></td>
</tr>
<tr>
<td><code>Promise.race</code></td>
<td>the first input settles, if it fulfils</td>
<td>the first input settles, if it rejects</td>
<td>that single value or reason</td>
<td>stays pending forever</td>
</tr>
<tr>
<td><code>Promise.any</code></td>
<td>the first input fulfils</td>
<td>every input rejects</td>
<td>first value, or <code>AggregateError</code></td>
<td>rejects with <code>AggregateError</code></td>
</tr>
</tbody>
</table>
<p>Two facts apply to all of them and cause most real bugs:</p>
<ol>
<li><strong>The work starts before the combinator is called.</strong> <code>fetch(url)</code> starts the request the moment you call it. <code>Promise.all</code> only observes promises that are already running. That is why <code>Promise.all(customers.map(sendEmail))</code> fires 8,000 requests at once: the <code>map</code> started them all.</li>
<li><strong>Nothing gets cancelled.</strong> When <code>Promise.all</code> rejects early, or <code>Promise.race</code> picks a winner, the other operations keep running to completion. Their results are just ignored. If you need cancellation, you need <code>AbortController</code> on top.</li>
</ol>
<h2>Choosing the Right One</h2>
<div class="page-mermaid"><div class="mermaid">graph TD
A[Several async tasks] --&gt; B{Need every result}
B --&gt;|one failure ruins all| C[Promise.all]
B --&gt;|partial results are fine| D[Promise.allSettled]
A --&gt; E{Need just one result}
E --&gt;|first to settle wins| F[Promise.race]
E --&gt;|first success wins| G[Promise.any]
A --&gt; H[Many items: add a concurrency limit]</div></div>
<h2>Promise.all: All or Nothing, Fail Fast</h2>
<p>Use <code>Promise.all</code> when every result is required and one failure means the whole operation is meaningless. Building an invoice needs the customer, the line items and the tax rates; if any is missing, there is no invoice.</p>
<pre><code class="language-ts">async function buildInvoice(orderId: string) {
  const [customer, items, taxRates] = await Promise.all([
    customers.forOrder(orderId),
    orderItems.list(orderId),
    tax.ratesFor(orderId),
  ]);

  return renderInvoice(customer, items, taxRates);
}
</code></pre>
<p>The result array is in <strong>input order</strong>, not completion order, and destructuring keeps the types precise in TypeScript. The promise rejects with the first rejection reason as soon as it happens, which is great for latency: you do not wait for slow siblings when the answer is already "error".</p>
<p>The flip side: the other promises are still running and, if they reject later, those rejections are handled by <code>Promise.all</code> internally, so you will not get unhandled rejection warnings, but you also will not hear about them. If a sibling had side effects (writes, payments), they happen anyway.</p>
<h2>Promise.allSettled: Partial Results Are Fine</h2>
<p>The dashboard is the classic case for <code>allSettled</code>: each widget is independent, and a broken widget should render as "unavailable", not take the whole page down.</p>
<pre><code class="language-ts">type Widget = 'profile' | 'orders' | 'recommendations';

async function loadDashboard(userId: string) {
  const loaders: Record&lt;Widget, () =&gt; Promise&lt;unknown&gt;&gt; = {
    profile: () =&gt; api.profile(userId),
    orders: () =&gt; api.orders(userId),
    recommendations: () =&gt; api.recommendations(userId),
  };

  const names = Object.keys(loaders) as Widget[];
  const results = await Promise.allSettled(names.map((name) =&gt; loaders[name]()));

  return Object.fromEntries(
    results.map((result, i) =&gt; {
      if (result.status === 'fulfilled') {
        return [names[i], { ok: true, data: result.value }];
      }

      logger.warn('widget failed', { widget: names[i], reason: String(result.reason) });

      return [names[i], { ok: false, data: null }];
    }),
  );
}
</code></pre>
<p><code>allSettled</code> never rejects, which means <strong>you</strong> are responsible for looking at the failures. A common mistake is to call <code>allSettled</code>, map over <code>value</code>, and silently render <code>undefined</code> for failed entries. Always branch on <code>status</code>, and log or count the <code>reason</code>, or you lose all visibility into failures. In TypeScript, narrowing on <code>result.status === 'fulfilled'</code> gives you <code>PromiseFulfilledResult&lt;T&gt;</code>, so <code>result.value</code> is typed.</p>
<h2>Promise.race: Timeouts, and the Timer You Forgot</h2>
<p><code>race</code> settles with whichever input settles first, fulfilled or rejected. Its main practical use is a timeout around an operation that does not support one natively:</p>
<pre><code class="language-ts">export function withTimeout&lt;T&gt;(promise: Promise&lt;T&gt;, ms: number, label = 'operation'): Promise&lt;T&gt; {
  let timer: ReturnType&lt;typeof setTimeout&gt; | undefined;

  const timeout = new Promise&lt;never&gt;((_, reject) =&gt; {
    timer = setTimeout(() =&gt; reject(new Error(`${label} timed out after ${ms}ms`)), ms);
  });

  return Promise.race([promise, timeout]).finally(() =&gt; clearTimeout(timer));
}
</code></pre>
<p>Two things are easy to get wrong here. First, the <code>clearTimeout</code> in <code>finally</code>: without it, every successful call leaves a live timer behind, which keeps a Node process alive at shutdown and, in a long-running server, means thousands of pending timers. Second, the timeout only stops you from <strong>waiting</strong>. The underlying operation is still running. For <code>fetch</code>, pass <code>AbortSignal.timeout(ms)</code> as the <code>signal</code> instead so the request is really cancelled; keep the <code>race</code> pattern for things that cannot be aborted, like a third-party SDK call.</p>
<p>Also remember that <code>race</code> with a rejection wins too. If the operation fails in 5 ms, the race rejects with that error immediately, which is usually what you want.</p>
<h2>Promise.any: First Success Wins</h2>
<p><code>any</code> ignores rejections until every input has rejected. It fits redundancy: query three mirrors or replicas and use whichever answers successfully first.</p>
<pre><code class="language-ts">async function fetchFromFastestMirror(path: string): Promise&lt;unknown&gt; {
  const mirrors = ['https://eu.cdn.example.com', 'https://us.cdn.example.com', 'https://ap.cdn.example.com'];

  try {
    return await Promise.any(
      mirrors.map(async (base) =&gt; {
        const res = await fetch(`${base}${path}`);
        if (!res.ok) {
          throw new Error(`${base} answered ${res.status}`);
        }
        return res.json();
      }),
    );
  } catch (error) {
    if (error instanceof AggregateError) {
      console.error('all mirrors failed', error.errors.map(String));
    }
    throw error;
  }
}
</code></pre>
<p>Note the <code>if (!res.ok) throw</code>. <code>fetch</code> only rejects on network errors, not on HTTP 500, so without that check a mirror that quickly returns an error page would "win". <code>AggregateError.errors</code> holds every individual reason in input order, which is exactly what you want in the log line. Using <code>any</code> here triples the load on your mirrors, so reserve it for small, idempotent reads.</p>
<h2>Concurrency Limiting: What None of Them Do</h2>
<p>For the 8,000 emails, none of the combinators help, because the work already started inside <code>map</code>. The fix is to control <strong>when</strong> each task starts: a small pool of workers that pull the next item from a shared index.</p>
<pre><code class="language-ts">export async function mapWithConcurrency&lt;T, R&gt;(
  items: readonly T[],
  limit: number,
  fn: (item: T, index: number) =&gt; Promise&lt;R&gt;,
): Promise&lt;R[]&gt; {
  if (!Number.isInteger(limit) || limit &lt; 1) {
    throw new RangeError('limit must be a positive integer');
  }

  const results = new Array&lt;R&gt;(items.length);
  let next = 0;
  let failed = false;

  async function worker(): Promise&lt;void&gt; {
    while (!failed &amp;&amp; next &lt; items.length) {
      const index = next++;
      try {
        results[index] = await fn(items[index], index);
      } catch (error) {
        failed = true;
        throw error;
      }
    }
  }

  const workers = Array.from({ length: Math.min(limit, items.length) }, () =&gt; worker());
  await Promise.all(workers);

  return results;
}
</code></pre>
<p>Because JavaScript is single-threaded, <code>const index = next++</code> is atomic; no lock is needed. Results go into <code>results[index]</code>, so output order matches input order regardless of which task finishes first. The <code>failed</code> flag gives fail-fast semantics like <code>Promise.all</code>, and also stops the pool from <strong>starting</strong> new items after the first error, which matters when each item sends an email or charges a card. If you prefer collect-all semantics, catch inside the worker and store <code>{ ok: false, error }</code> instead.</p>
<p>Libraries like <code>p-limit</code> and <code>p-map</code> implement the same idea with more options. The point is to understand the shape: a fixed number of in-flight promises, not a fixed number of batches. Chunking into groups of 50 with <code>Promise.all</code> per chunk works, but each chunk waits for its slowest member, so throughput drops to the speed of the worst request in every batch.</p>
<h2>Testing Combinator Logic with Vitest</h2>
<p>Concurrency code is where "it worked on my machine" lives. Test the properties that matter: peak concurrency, order, and failure behaviour. Fake timers make timeout tests instant and deterministic.</p>
<pre><code class="language-ts">import { afterEach, describe, expect, it, vi } from 'vitest';
import { mapWithConcurrency, withTimeout } from './async-utils';

const sleep = (ms: number) =&gt; new Promise((resolve) =&gt; setTimeout(resolve, ms));

describe('mapWithConcurrency', () =&gt; {
  it('never runs more than limit tasks and keeps input order', async () =&gt; {
    let active = 0;
    let peak = 0;

    const out = await mapWithConcurrency([30, 10, 20, 5], 2, async (ms) =&gt; {
      active++;
      peak = Math.max(peak, active);
      await sleep(ms);
      active--;
      return ms * 2;
    });

    expect(out).toEqual([60, 20, 40, 10]);
    expect(peak).toBe(2);
  });

  it('rejects with the first error and starts no new work', async () =&gt; {
    const started: number[] = [];

    await expect(
      mapWithConcurrency([1, 2, 3, 4], 1, async (n) =&gt; {
        started.push(n);
        if (n === 2) throw new Error('boom');
        return n;
      }),
    ).rejects.toThrow('boom');

    expect(started).toEqual([1, 2]);
  });
});

describe('withTimeout', () =&gt; {
  afterEach(() =&gt; vi.useRealTimers());

  it('rejects when the promise is too slow', async () =&gt; {
    vi.useFakeTimers();
    const pending = withTimeout(new Promise(() =&gt; {}), 1000, 'report');
    const assertion = expect(pending).rejects.toThrow('report timed out after 1000ms');

    await vi.advanceTimersByTimeAsync(1000);
    await assertion;
  });
});
</code></pre>
<p>Attach the <code>rejects</code> assertion <strong>before</strong> advancing the timers; otherwise the rejection happens with no handler attached and Vitest reports it as unhandled.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Expecting <code>Promise.all</code> to cancel siblings.</strong> It does not. Pass an <code>AbortSignal</code> to the operations and abort it in a <code>catch</code> if you need that.</li>
<li><strong><code>Promise.all</code> over an unbounded array.</strong> <code>items.map(asyncFn)</code> starts everything at once. Use a concurrency limit for anything that touches the network or the database.</li>
<li><strong>Ignoring <code>allSettled</code> failures.</strong> It never rejects, so errors vanish unless you check <code>status</code> and log <code>reason</code>.</li>
<li><strong><code>Promise.race([])</code> hangs forever.</strong> If the list can be empty, guard it.</li>
<li><strong>Timeouts that leak timers.</strong> Always clear the timer in <code>finally</code>.</li>
<li><strong>Treating HTTP errors as success.</strong> <code>fetch</code> resolves on 4xx and 5xx. Check <code>res.ok</code> before handing the promise to <code>any</code> or <code>all</code>.</li>
<li><strong>Sequential <code>await</code> in a loop when work is independent.</strong> <code>for (const id of ids) await load(id)</code> takes the sum of all latencies. That is fine when order or rate matters, but it is a common accidental slowdown.</li>
</ul>
<h2>When to Use Which, and When Not To</h2>
<ul>
<li><strong><code>Promise.all</code></strong>: a fixed, small set of required results, where any failure invalidates the whole. Not for large or user-controlled lists without a concurrency limit.</li>
<li><strong><code>Promise.allSettled</code></strong>: independent tasks where partial success is acceptable and you will report failures individually. Not when one failure should stop the rest quickly; it always waits for the slowest.</li>
<li><strong><code>Promise.race</code></strong>: timeouts and "whichever settles first" signals, such as racing work against a shutdown signal. Not for picking the fastest successful source, because a fast failure wins.</li>
<li><strong><code>Promise.any</code></strong>: redundant, idempotent reads where any success is good enough. Not for writes, and not when the extra load on the backends is a problem.</li>
<li><strong>A concurrency limiter</strong>: any batch job over many items. Pair it with <code>all</code>-style or <code>allSettled</code>-style error handling depending on whether one failure should stop the batch.</li>
</ul>
<p>The combinators answer "how do I wait?". Concurrency limits and cancellation answer "how much do I start?" and "how do I stop?". Production code usually needs all three.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Laravel Pipelines: Turn a 340-Line Checkout Controller into Small, Testable Steps</title>
            <link>https://cslant.com/tips/laravel-pipelines-turn-a-340-line-checkout-controller-into-small-testable-steps</link>
            <guid isPermaLink="false">https://cslant.com/tips/laravel-pipelines-turn-a-340-line-checkout-controller-into-small-testable-steps</guid>
                        <pubDate>Mon, 02 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Laravel</category>
                                    <description>Use the Laravel Pipeline facade to split checkout flows and query filters into pipe classes you can reorder, reuse and unit test, with the pitfalls to avoid.</description>
                                    <content:encoded><![CDATA[<p>Every Laravel project has one: the <code>CheckoutController@store</code> method that started as 30 lines and is now 340. It checks stock, recalculates prices, validates a coupon, adds VAT, creates the order, reserves inventory, calls the payment gateway, and somewhere in the middle there is an <code>if ($request-&gt;has('gift_wrap'))</code> that someone added on a Friday. Nobody wants to touch it, every change breaks something unrelated, and the only test is a 200-line feature test that mocks half the app.</p>
<p>The work itself is fine. It is a sequence of steps, each one taking the same piece of data, doing one thing to it, and passing it on. Laravel already has a tool built for exactly that shape, and you use it on every request without noticing: the pipeline that runs your middleware. It is available to your own code through the <code>Pipeline</code> facade.</p>
<h2>How the Pipeline Works</h2>
<p>A pipeline takes a "passable" (any value: a DTO, a query builder, a string) and sends it through a list of pipes. Each pipe receives the passable and a <code>$next</code> closure. It can modify the passable, call <code>$next($passable)</code> to continue, do work after <code>$next</code> returns, or stop the chain by returning or throwing without calling <code>$next</code>. That is exactly the middleware contract.</p>
<pre><code class="language-text">Pipeline::send($passable)   the value travelling through
    -&gt;through([...pipes])   classes, class strings or closures
    -&gt;via('handle')         method to call on class pipes (default: handle)
    -&gt;then($destination)    final closure, receives the processed value
    -&gt;thenReturn()          shortcut: return the processed value as is
</code></pre>
<p>Internally Laravel builds an onion: it wraps the destination closure in the last pipe, wraps that in the previous pipe, and so on with <code>array_reduce</code>, then calls the outermost one. Class-string pipes are resolved from the container at call time, so their constructors can type-hint any dependency.</p>
<h2>Designing the Passenger Object</h2>
<p>Before writing pipes, decide what travels through them. Passing the raw <code>Request</code> or a loose array works for a demo but makes every pipe guess at keys. A small mutable DTO, the "passenger", gives each step a typed place to read inputs and write results:</p>
<pre><code class="language-php">namespace App\Checkout;

use App\Models\Cart;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\User;

final class CheckoutData
{
    public int $subtotal = 0;
    public int $discount = 0;
    public int $tax = 0;
    public ?Coupon $coupon = null;
    public ?Order $order = null;
    public ?string $paymentReference = null;

    public function __construct(
        public readonly User $user,
        public readonly Cart $cart,
        public readonly ?string $couponCode = null,
    ) {}

    public function total(): int
    {
        return max(0, $this-&gt;subtotal - $this-&gt;discount) + $this-&gt;tax;
    }
}
</code></pre>
<p>Inputs are <code>readonly</code> because no pipe should change who is checking out or which cart is being bought. Outputs are plain public properties that pipes fill in order. Money is stored as integer minor units (for VND that is simply the amount) so there is no float rounding.</p>
<h2>Pipes as Classes</h2>
<p>Each step becomes a class with one <code>handle</code> method. Here is the coupon step:</p>
<pre><code class="language-php">namespace App\Checkout\Pipes;

use App\Checkout\CheckoutData;
use App\Checkout\CheckoutFailed;
use App\Models\Coupon;
use Closure;

final class ApplyCoupon
{
    public function handle(CheckoutData $data, Closure $next): mixed
    {
        if ($data-&gt;couponCode === null) {
            return $next($data);
        }

        $coupon = Coupon::query()
            -&gt;where('code', $data-&gt;couponCode)
            -&gt;where('expires_at', '&gt;', now())
            -&gt;first();

        if ($coupon === null) {
            throw CheckoutFailed::because('Coupon is invalid or expired.');
        }

        $data-&gt;coupon = $coupon;
        $data-&gt;discount = min($data-&gt;subtotal, $coupon-&gt;amountOff($data-&gt;subtotal));

        return $next($data);
    }
}
</code></pre>
<p>Read it top to bottom and you know everything the step does. It does not know about tax, payment or the HTTP request. It has one reason to change. The early <code>return $next($data)</code> for "no coupon" is the pattern you will use most often: a pipe that does not apply simply passes the data along.</p>
<p>Throwing is how a pipe aborts the whole flow. Because the next pipes are only called through <code>$next</code>, an exception in <code>ApplyCoupon</code> means <code>CreateOrder</code> and <code>ChargePayment</code> never run.</p>
<h2>Building the Checkout Pipeline</h2>
<p>The action that ties it together is now short enough to fit on one screen, and the order of steps reads like the business process:</p>
<pre><code class="language-php">namespace App\Checkout;

use App\Checkout\Pipes\ApplyCoupon;
use App\Checkout\Pipes\CalculateSubtotal;
use App\Checkout\Pipes\CalculateTax;
use App\Checkout\Pipes\ChargePayment;
use App\Checkout\Pipes\CreateOrder;
use App\Checkout\Pipes\EnsureItemsInStock;
use App\Checkout\Pipes\ReserveInventory;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;

final class PlaceOrder
{
    /** @var list&lt;class-string&gt; */
    private array $pipes = [
        EnsureItemsInStock::class,
        CalculateSubtotal::class,
        ApplyCoupon::class,
        CalculateTax::class,
        CreateOrder::class,
        ReserveInventory::class,
        ChargePayment::class,
    ];

    public function __invoke(CheckoutData $data): Order
    {
        return DB::transaction(fn () =&gt; Pipeline::send($data)
            -&gt;through($this-&gt;pipes)
            -&gt;then(fn (CheckoutData $data): Order =&gt; $data-&gt;order));
    }
}
</code></pre>
<div class="page-mermaid"><div class="mermaid">graph LR
A[CheckoutData] --&gt; B[Ensure items in stock]
B --&gt; C[Calculate subtotal]
C --&gt; D[Apply coupon]
D --&gt; E[Calculate tax]
E --&gt; F[Create order]
F --&gt; G[Reserve inventory]
G --&gt; H[Charge payment]
H --&gt; I[Order returned]</div></div>
<p>Wrapping the pipeline in <code>DB::transaction</code> means any exception rolls back the order row and the inventory reservation together. Keep in mind that the payment call is a side effect outside the database: if <code>ChargePayment</code> succeeds and the commit fails afterwards, you have charged a customer for an order that does not exist. Put the charge last, keep it idempotent with a reference you store on the order, and consider the transactional outbox pattern for anything that must happen exactly once after commit.</p>
<p>Because a pipe controls when <code>$next</code> runs, you can also write steps that wrap the rest of the chain, just like middleware measures response time:</p>
<pre><code class="language-php">namespace App\Checkout\Pipes;

use App\Checkout\CheckoutData;
use Closure;
use Illuminate\Support\Facades\Log;

final class LogCheckoutDuration
{
    public function handle(CheckoutData $data, Closure $next): mixed
    {
        $startedAt = hrtime(true);

        $result = $next($data);

        Log::info('checkout.completed', [
            'user_id' =&gt; $data-&gt;user-&gt;id,
            'total' =&gt; $data-&gt;total(),
            'ms' =&gt; (hrtime(true) - $startedAt) / 1e6,
        ]);

        return $result;
    }
}
</code></pre>
<p>Put it first in the list and it measures everything after it. Adding cross-cutting behaviour like this no longer means editing the business steps.</p>
<h2>Pipelines for Query Filters</h2>
<p>The second classic use is filtering a listing endpoint. The passable is an Eloquent <code>Builder</code>, and each filter adds its own <code>where</code> clause only when its query string parameter is present:</p>
<pre><code class="language-php">namespace App\Http\Controllers;

use App\Models\Product;
use App\QueryFilters\Category;
use App\QueryFilters\InStock;
use App\QueryFilters\PriceRange;
use App\QueryFilters\Search;
use App\QueryFilters\Sort;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Support\Facades\Pipeline;

final class ProductIndexController
{
    public function __invoke(): CursorPaginator
    {
        return Pipeline::send(Product::query()-&gt;where('is_active', true))
            -&gt;through([
                Search::class,
                Category::class,
                PriceRange::class,
                InStock::class,
                Sort::class,
            ])
            -&gt;thenReturn()
            -&gt;cursorPaginate(24)
            -&gt;withQueryString();
    }
}
</code></pre>
<pre><code class="language-php">namespace App\QueryFilters;

use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

final class PriceRange
{
    public function __construct(private Request $request) {}

    public function handle(Builder $query, Closure $next): mixed
    {
        $min = $this-&gt;request-&gt;integer('min_price');
        $max = $this-&gt;request-&gt;integer('max_price');

        if ($min &gt; 0) {
            $query-&gt;where('price', '&gt;=', $min);
        }

        if ($max &gt; 0) {
            $query-&gt;where('price', '&lt;=', $max);
        }

        return $next($query);
    }
}
</code></pre>
<p>Each filter is resolved from the container, so <code>Request</code> is injected through the constructor. Adding a new filter is one class and one line in the array, with no changes to the controller logic. <code>thenReturn()</code> hands back the builder, and you paginate as usual.</p>
<p>Two more features are worth knowing. A class string can carry parameters after a colon, which arrive as extra string arguments to <code>handle</code> (exactly like <code>throttle:60,1</code> in middleware). And closures can be mixed with classes for tiny one-off steps. <code>via()</code> changes the method name if your pipes use something other than <code>handle</code>:</p>
<pre><code class="language-php">use App\Checkout\Pipes\EnsureMinimumTotal;
use Illuminate\Support\Facades\Pipeline;

$result = Pipeline::send($data)
    -&gt;through([
        EnsureMinimumTotal::class . ':50000,VND',
        fn ($data, $next) =&gt; $next($data),
    ])
    -&gt;via('process')
    -&gt;thenReturn();
</code></pre>
<h2>Testing Pipes and Pipelines</h2>
<p>Because each pipe is a class with one method and an explicit <code>$next</code>, unit tests do not need the HTTP layer. Call <code>handle</code> directly and pass a closure as <code>$next</code> that records whether it was called:</p>
<pre><code class="language-php">use App\Checkout\CheckoutData;
use App\Checkout\CheckoutFailed;
use App\Checkout\Pipes\ApplyCoupon;
use App\Models\Cart;
use App\Models\Coupon;
use App\Models\User;

function checkoutData(?string $code, int $subtotal): CheckoutData
{
    $data = new CheckoutData(User::factory()-&gt;make(), Cart::factory()-&gt;make(), $code);
    $data-&gt;subtotal = $subtotal;

    return $data;
}

it('applies a valid coupon and passes the data on', function () {
    Coupon::factory()-&gt;create(['code' =&gt; 'TET2027', 'amount' =&gt; 50_000, 'expires_at' =&gt; now()-&gt;addDay()]);
    $called = false;

    $result = (new ApplyCoupon())-&gt;handle(checkoutData('TET2027', 300_000), function ($data) use (&amp;$called) {
        $called = true;

        return $data;
    });

    expect($called)-&gt;toBeTrue()
        -&gt;and($result-&gt;discount)-&gt;toBe(50_000)
        -&gt;and($result-&gt;total())-&gt;toBe(250_000);
});

it('stops the pipeline when the coupon is expired', function () {
    Coupon::factory()-&gt;create(['code' =&gt; 'OLD', 'expires_at' =&gt; now()-&gt;subDay()]);

    (new ApplyCoupon())-&gt;handle(checkoutData('OLD', 300_000), fn () =&gt; throw new LogicException('next must not run'));
})-&gt;throws(CheckoutFailed::class);

it('skips silently when no coupon code is given', function () {
    $result = (new ApplyCoupon())-&gt;handle(checkoutData(null, 120_000), fn ($data) =&gt; $data);

    expect($result-&gt;discount)-&gt;toBe(0);
});
</code></pre>
<p>Three cases cover most pipes: it transforms the data and calls <code>$next</code>, it aborts without calling <code>$next</code>, and it passes through untouched when it does not apply. Make the "abort" test's <code>$next</code> throw, so a pipe that forgets to stop is caught loudly.</p>
<p>Then one or two tests at the pipeline level check the order and the contract between steps. Because class pipes are resolved from the container, you can swap one for a mock:</p>
<pre><code class="language-php">use App\Checkout\Pipes\ChargePayment;
use App\Checkout\PlaceOrder;

it('never charges the card when an earlier pipe fails', function () {
    $this-&gt;mock(ChargePayment::class)-&gt;shouldNotReceive('handle');

    $data = checkoutData('DOES-NOT-EXIST', 300_000);

    expect(fn () =&gt; app(PlaceOrder::class)($data))-&gt;toThrow(App\Checkout\CheckoutFailed::class);
    $this-&gt;assertDatabaseCount('orders', 0);
});
</code></pre>
<p>That test protects the most expensive mistake in a checkout flow: charging money when the order should have been rejected.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Forgetting to return <code>$next($data)</code>.</strong> Calling <code>$next($data);</code> without <code>return</code> drops the result, and <code>then</code> receives <code>null</code>. Static analysis catches this if <code>handle</code> declares a return type.</li>
<li><strong>Hidden ordering dependencies.</strong> <code>CalculateTax</code> needs <code>subtotal</code> and <code>discount</code>, so it must come after <code>ApplyCoupon</code>. Document these in the passenger class, or assert preconditions at the top of the pipe and throw early.</li>
<li><strong>Passing the Request around.</strong> If pipes read from the request directly, they cannot be reused from a queued job or an Artisan command. Copy the inputs into the passenger once at the boundary.</li>
<li><strong>Using a pipeline for two steps.</strong> <code>Pipeline::send()-&gt;through([A::class, B::class])</code> for a two-line function is indirection without benefit. A plain method call is clearer.</li>
<li><strong>Swallowing exceptions in a wrapping pipe.</strong> A logging or timing pipe that catches <code>Throwable</code> and returns something else turns a failed payment into a "successful" checkout. Rethrow.</li>
<li><strong>Side effects that cannot be rolled back.</strong> Database work inside the transaction is safe; emails, webhooks and payments are not. Dispatch those after commit (<code>ShouldQueueAfterCommit</code>, <code>DB::afterCommit()</code>, or an outbox).</li>
</ul>
<h2>When to Use a Pipeline and When Not To</h2>
<p>A pipeline fits well when:</p>
<ul>
<li>a process is a <strong>linear sequence</strong> of steps over the same data: checkout, user onboarding, import row processing, document publishing;</li>
<li>steps are <strong>optional or configurable</strong>: build the pipe list from feature flags, tenant settings or the order type;</li>
<li>you want <strong>filters</strong> on a listing where each parameter maps to one query clause;</li>
<li>you need <strong>around</strong> behaviour (timing, logging, locking) without touching each step.</li>
</ul>
<p>Reach for something else when:</p>
<ul>
<li>the flow <strong>branches</strong> heavily ("if B2B then this five-step path, else that one"): a state machine or separate actions per path are easier to follow;</li>
<li>steps need to run <strong>in parallel</strong> or survive restarts: use queued jobs with <code>Bus::chain()</code> or <code>Bus::batch()</code>, which add retries and persistence that an in-memory pipeline does not have;</li>
<li>there are only <strong>one or two</strong> steps: just call them.</li>
</ul>
<p>Back to that 340-line controller: after the refactor it validates the request, builds a <code>CheckoutData</code>, calls <code>PlaceOrder</code>, and returns a resource. Each business rule lives in a class you can test in isolation, and the next "gift wrap" request is a new pipe and one line in the list.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Verifying Webhook Signatures with HMAC: Raw Body, Timing-Safe Compare and Replay Protection</title>
            <link>https://cslant.com/tips/verifying-webhook-signatures-with-hmac-raw-body-timing-safe-compare-and-replay-protection</link>
            <guid isPermaLink="false">https://cslant.com/tips/verifying-webhook-signatures-with-hmac-raw-body-timing-safe-compare-and-replay-protection</guid>
                        <pubDate>Sun, 01 Nov 2026 09:00:00 +0700</pubDate>
                                                <category>Security</category>
                                    <description>A webhook URL is a public endpoint that changes your data. Verify HMAC signatures on the raw body, compare in constant time and block replays with timestamps.</description>
                                    <content:encoded><![CDATA[<p>Your app exposes <code>POST /webhooks/billing</code> so the payment provider can tell you an invoice was paid. The handler marks the order as paid and ships the goods. It works perfectly in staging. Then someone finds the URL in a mobile app bundle, sends <code>{"type":"invoice.paid","order_id":1234}</code> with curl, and gets a laptop for free. A webhook endpoint is a public, unauthenticated URL that changes state in your system. Without signature verification, anyone who can guess the payload format can drive it.</p>
<p>Almost every serious provider (Stripe, GitHub, Shopify, Svix-based services, Paddle) solves this the same way: they sign each request with HMAC using a secret only you and they know. Verifying that signature correctly takes about 40 lines, but there are four details that people get wrong over and over: the raw body, timing-safe comparison, the timestamp window, and replay protection.</p>
<h2>How an HMAC Webhook Signature Works</h2>
<p>HMAC-SHA256 takes a secret key and a message and produces a 32-byte tag. Without the key you cannot produce a valid tag for a modified message, and you cannot recover the key from tags you have seen. The provider computes the tag over the exact bytes it sends and puts it in a header. You recompute it over the exact bytes you received and compare.</p>
<p>A typical scheme looks like this (the header names vary by provider; the structure is nearly identical):</p>
<pre><code class="language-text">Webhook-Id: evt_01JC8Z4K
Webhook-Signature: t=1761900000,v1=&lt;64 lowercase hex chars&gt;

signed string = "1761900000" + "." + &lt;exact raw request body bytes&gt;
v1            = hex( HMAC-SHA256( secret, signed string ) )
</code></pre>
<p>Two things make this scheme stronger than "HMAC the body". The timestamp is part of the signed string, so an attacker cannot take an old captured request and change its timestamp. And the header can carry several <code>v1</code> values, which lets the provider sign with both the old and new secret during a secret rotation.</p>
<h2>The Verification Flow</h2>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
participant P as Provider
participant M as Middleware
participant C as Cache
participant H as Controller
P-&gt;&gt;M: POST raw body and signature header
M-&gt;&gt;M: Timestamp within 5 minutes
M-&gt;&gt;M: HMAC of timestamp.body, constant time compare
M-&gt;&gt;C: Add event id if absent
C--&gt;&gt;M: Added or already seen
M-&gt;&gt;H: Forward verified request
H--&gt;&gt;P: 2xx quickly, work goes to a queue</div></div>
<p>The order matters. Cheap checks first (timestamp), then the cryptographic check, then the stateful replay check. Only a request that passed all three reaches your controller. Note what is <em>not</em> in the list: parsing JSON. You verify bytes, and only then trust and decode them.</p>
<h2>Laravel Middleware Implementation</h2>
<p>Put the verification in middleware so every webhook route gets the same guarantees and controllers stay focused on business logic. The provider name is a middleware parameter, so one class serves every integration.</p>
<pre><code class="language-php">namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

final class VerifyWebhookSignature
{
    private const TOLERANCE_SECONDS = 300;

    public function handle(Request $request, Closure $next, string $provider): Response
    {
        $secrets = (array) config("services.{$provider}.webhook_secrets", []);
        $header = (string) $request-&gt;header('Webhook-Signature', '');
        $parts = $this-&gt;parseHeader($header);
        $timestamp = (int) ($parts['t'][0] ?? 0);

        abort_if(abs(now()-&gt;getTimestamp() - $timestamp) &gt; self::TOLERANCE_SECONDS, 401, 'Stale timestamp.');

        // getContent() is the raw body. Never rebuild it from $request-&gt;all().
        $payload = $request-&gt;getContent();

        abort_unless($this-&gt;matchesAny($secrets, "{$timestamp}.{$payload}", $parts['v1'] ?? []), 401, 'Bad signature.');

        $key = "webhook:{$provider}:".$request-&gt;header('Webhook-Id', hash('sha256', $header));

        if (! Cache::add($key, true, self::TOLERANCE_SECONDS * 2)) {
            return response()-&gt;noContent(); // replay or duplicate delivery: ack, do nothing
        }

        $response = $next($request);

        if (! $response-&gt;isSuccessful()) {
            Cache::forget($key); // let the provider's retry through
        }

        return $response;
    }

    /** @return array&lt;string, list&lt;string&gt;&gt; */
    private function parseHeader(string $header): array
    {
        $parts = [];

        foreach (explode(',', $header) as $item) {
            [$key, $value] = array_pad(explode('=', trim($item), 2), 2, '');
            $parts[$key][] = $value;
        }

        return $parts;
    }

    /** @param list&lt;string&gt; $signatures */
    private function matchesAny(array $secrets, string $signed, array $signatures): bool
    {
        foreach ($secrets as $secret) {
            $expected = hash_hmac('sha256', $signed, $secret);

            foreach ($signatures as $signature) {
                if (hash_equals($expected, $signature)) {
                    return true;
                }
            }
        }

        return false;
    }
}
</code></pre>
<p>A few deliberate choices in there:</p>
<ul>
<li><code>$request-&gt;getContent()</code> returns the body exactly as received. <code>json_encode($request-&gt;all())</code> would not: key order, whitespace, slash escaping and Unicode escaping can all differ, and the signature will fail randomly for some payloads.</li>
<li><code>hash_equals()</code> compares in constant time. A plain <code>===</code> returns as soon as the first byte differs, which in principle leaks how many leading characters an attacker got right. Over a network the signal is noisy, but it is free to avoid, so avoid it.</li>
<li>Secrets come from <code>config('services.billing.webhook_secrets')</code>, an array. During rotation, set it to <code>array_filter([env('BILLING_WEBHOOK_SECRET'), env('BILLING_WEBHOOK_SECRET_PREVIOUS')])</code> and drop the old one when the provider confirms the switch.</li>
<li>Replay protection uses <code>Cache::add()</code>, which only writes if the key does not exist and is atomic on Redis, Memcached and the database store. The TTL just needs to outlive the timestamp window: after 5 minutes the timestamp check rejects the request anyway.</li>
<li>If the controller fails, the key is removed so the provider's retry is processed instead of silently swallowed.</li>
</ul>
<p>Register the alias and attach it to the route:</p>
<pre><code class="language-php">// bootstrap/app.php
use App\Http\Middleware\VerifyWebhookSignature;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    -&gt;withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
    )
    -&gt;withMiddleware(function (Middleware $middleware): void {
        $middleware-&gt;alias(['webhook.signed' =&gt; VerifyWebhookSignature::class]);
    })
    -&gt;withExceptions(function (Exceptions $exceptions): void {
        //
    })
    -&gt;create();
</code></pre>
<pre><code class="language-php">// routes/api.php
use App\Http\Controllers\BillingWebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/billing', BillingWebhookController::class)
    -&gt;middleware('webhook.signed:billing');
</code></pre>
<p>Routes in <code>routes/api.php</code> do not run CSRF verification. If you put webhook routes in <code>web.php</code>, you must exclude them with <code>$middleware-&gt;validateCsrfTokens(except: ['webhooks/*'])</code>, otherwise every delivery gets a 419.</p>
<h2>The Same Check in Node.js and Python</h2>
<p>In Express, the classic bug is a global <code>app.use(express.json())</code> that consumes and parses the body before your route sees it. Use <code>express.raw()</code> on the webhook route so <code>req.body</code> stays a <code>Buffer</code>.</p>
<pre><code class="language-js">const crypto = require('node:crypto');
const express = require('express');

const app = express();

function safeEqualHex(a, b) {
  const left = Buffer.from(a, 'hex');
  const right = Buffer.from(b, 'hex');
  // timingSafeEqual throws on different lengths, so check first
  return left.length === right.length &amp;&amp; crypto.timingSafeEqual(left, right);
}

// express.raw keeps req.body as a Buffer; register it BEFORE any global express.json()
app.post('/webhooks/billing', express.raw({ type: 'application/json' }), (req, res) =&gt; {
  const parts = Object.fromEntries(
    (req.get('Webhook-Signature') ?? '').split(',').map((p) =&gt; p.trim().split('=', 2)),
  );
  const ts = Number(parts.t);

  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) &gt; 300) {
    return res.sendStatus(401);
  }

  const expected = crypto
    .createHmac('sha256', process.env.BILLING_WEBHOOK_SECRET)
    .update(`${ts}.`)
    .update(req.body)
    .digest('hex');

  if (!safeEqualHex(expected, parts.v1 ?? '')) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // enqueue event.id + payload, then answer quickly
  res.sendStatus(200);
});
</code></pre>
<p><code>crypto.timingSafeEqual()</code> throws a <code>RangeError</code> when the buffers have different lengths, which an attacker can trigger with a short signature. Check the length first and treat a mismatch as invalid. In Python, <code>hmac.compare_digest()</code> handles different lengths for you:</p>
<pre><code class="language-python">import hashlib
import hmac
import time


def verify_webhook(secret: str, header: str, body: bytes, tolerance: int = 300) -&gt; bool:
    parts: dict[str, list[str]] = {}
    for item in header.split(","):
        key, _, value = item.strip().partition("=")
        parts.setdefault(key, []).append(value)

    raw_ts = parts.get("t", [""])[0]
    if not raw_ts.isdigit() or abs(time.time() - int(raw_ts)) &gt; tolerance:
        return False

    expected = hmac.new(secret.encode(), raw_ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in parts.get("v1", []))
</code></pre>
<p>In Django or Flask, use <code>request.body</code> / <code>request.get_data()</code>, which are bytes, never the parsed JSON.</p>
<h2>Testing the Middleware</h2>
<p>The tests that matter are the negative ones: a valid request is easy to get right by accident, a rejected forgery is what you are actually buying. With Pest you can register a throwaway route in the test and drive it with raw content:</p>
<pre><code class="language-php">use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

beforeEach(function () {
    config(['services.billing.webhook_secrets' =&gt; ['whsec_test']]);

    Route::post('/_test/webhook', function () {
        Cache::increment('handled');

        return response()-&gt;noContent();
    })-&gt;middleware('webhook.signed:billing');
});

function sendWebhook(string $body, int $timestamp, string $secret = 'whsec_test'): Illuminate\Testing\TestResponse
{
    $signature = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);

    return test()-&gt;call('POST', '/_test/webhook', [], [], [], [
        'HTTP_WEBHOOK_SIGNATURE' =&gt; "t={$timestamp},v1={$signature}",
        'HTTP_WEBHOOK_ID' =&gt; 'evt_1',
        'CONTENT_TYPE' =&gt; 'application/json',
    ], $body);
}

it('accepts a correctly signed payload once', function () {
    $body = '{"id":"evt_1","type":"invoice.paid"}';

    sendWebhook($body, now()-&gt;getTimestamp())-&gt;assertNoContent();
    sendWebhook($body, now()-&gt;getTimestamp())-&gt;assertNoContent(); // replay

    expect(Cache::get('handled'))-&gt;toBe(1);
});

it('rejects a tampered body', function () {
    $signature = hash_hmac('sha256', now()-&gt;getTimestamp().'.{"amount":100}', 'whsec_test');

    test()-&gt;call('POST', '/_test/webhook', [], [], [], [
        'HTTP_WEBHOOK_SIGNATURE' =&gt; 't='.now()-&gt;getTimestamp().",v1={$signature}",
    ], '{"amount":1000000}')-&gt;assertUnauthorized();
});

it('rejects stale timestamps and wrong secrets', function () {
    sendWebhook('{}', now()-&gt;subMinutes(6)-&gt;getTimestamp())-&gt;assertUnauthorized();
    sendWebhook('{}', now()-&gt;getTimestamp(), 'whsec_wrong')-&gt;assertUnauthorized();

    expect(Cache::get('handled'))-&gt;toBeNull();
});
</code></pre>
<p>Laravel's test clock helpers (<code>$this-&gt;travel()</code> / <code>travelTo()</code>) work here because the middleware uses <code>now()</code> instead of <code>time()</code>. If you switch to a real Redis cache in tests, flush the replay keys between tests or each test will look like a replay of the previous one.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Verifying re-encoded JSON.</strong> Always hash the raw bytes. This is the number one cause of "signature works in tests, fails for some production events".</li>
<li><strong>Comparing with <code>==</code>.</strong> Besides the timing leak, PHP's loose comparison treats <code>"0e1234..."</code> hex strings as equal numbers. Use <code>hash_equals()</code>.</li>
<li><strong>No timestamp check.</strong> A valid request captured from a log or a proxy can be replayed forever. GitHub-style signatures (<code>X-Hub-Signature-256: sha256=...</code>) do not sign a timestamp, so for those, deduplicate on the delivery id header instead.</li>
<li><strong>Too tight a window.</strong> Clock skew and provider retries exist. Five minutes is the common default; do not go below one minute.</li>
<li><strong>Doing slow work before responding.</strong> Providers time out after a few seconds and retry, so you get duplicates. Verify, store or queue the event, return 2xx, and process it in a job.</li>
<li><strong>Trusting fields that are not signed.</strong> Headers such as <code>Webhook-Id</code> are only safe to use for dedup if the provider includes them in the signed string or you dedup on the event id inside the verified body.</li>
<li><strong>Leaking the secret.</strong> Do not log full headers, do not commit <code>.env</code>, and keep a separate secret per environment and per tenant so staging leaks cannot forge production events.</li>
</ul>
<h2>When to Use This (and When Not To)</h2>
<p>Use HMAC verification for any inbound webhook from a provider that supports it, and for webhooks you send to your own customers: sign outgoing requests the same way so they can verify you.</p>
<p>HMAC is not the right tool when:</p>
<ul>
<li><strong>The sender cannot keep a secret.</strong> Browsers and mobile apps cannot hold an HMAC key; anyone can extract it. Authenticate users instead.</li>
<li><strong>Many independent receivers must verify the same message.</strong> A shared secret means every receiver can also forge messages. Use asymmetric signatures (Ed25519, or the RS256/ES256 used in JWTs) so only the sender holds the private key.</li>
<li><strong>You need confidentiality.</strong> HMAC proves who sent the bytes and that they were not changed. It does not hide them. The transport still needs TLS.</li>
</ul>
<p>As a final layer, some providers publish their source IP ranges. An IP allowlist is a nice extra, but it is not a replacement: IP ranges change, and the signature is what actually proves authenticity.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Transaction Isolation Levels: Dirty, Non-Repeatable and Phantom Reads in MySQL and PostgreSQL</title>
            <link>https://cslant.com/tips/transaction-isolation-levels-dirty-non-repeatable-and-phantom-reads-in-mysql-and-postgresql</link>
            <guid isPermaLink="false">https://cslant.com/tips/transaction-isolation-levels-dirty-non-repeatable-and-phantom-reads-in-mysql-and-postgresql</guid>
                        <pubDate>Sat, 31 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>Why DB::transaction() did not stop your oversold stock: what each isolation level guarantees in MySQL vs PostgreSQL and how to pick one in Laravel.</description>
                                    <content:encoded><![CDATA[<p>Flash sale, 9:00 sharp. The product page says 5 left. By 9:01 the warehouse has 14 paid orders for that product. Every checkout ran inside <code>DB::transaction()</code>, so the team assumed it was safe. It was not: the code read <code>stock</code> into PHP, subtracted one, and wrote the number back. Two transactions read the same 5, both wrote 4, and both committed without a single error.</p>
<p>A transaction gives you atomicity: all of it commits or none of it does. It does <strong>not</strong>, on its own, make concurrent transactions behave as if they ran one after another. How much they can see of each other is controlled by the isolation level, and MySQL and PostgreSQL ship with different defaults and different behaviour under the same names. Here is what each level really guarantees and how to pick one in Laravel.</p>
<h2>The Bug in Slow Motion</h2>
<p>This is a lost update. Nothing in it is exotic:</p>
<pre><code class="language-sql">-- Session A                                  -- Session B
BEGIN;                                        BEGIN;
SELECT stock FROM products WHERE id = 7;      SELECT stock FROM products WHERE id = 7;
-- app sees 5, computes 5 - 1 = 4             -- app sees 5, computes 5 - 1 = 4
UPDATE products SET stock = 4 WHERE id = 7;
COMMIT;
                                              UPDATE products SET stock = 4 WHERE id = 7;
                                              COMMIT;
-- Two units sold, stock went from 5 to 4.
</code></pre>
<p>Under MySQL's default <code>REPEATABLE READ</code> and PostgreSQL's default <code>READ COMMITTED</code>, this interleaving commits happily. PostgreSQL <code>REPEATABLE READ</code> would reject session B's update with <code>could not serialize access due to concurrent update</code>. MySQL <code>REPEATABLE READ</code> would not, because InnoDB's <code>UPDATE</code> reads the latest committed row (a "current read"), not the transaction's snapshot. Same name, different outcome.</p>
<h2>The Read Anomalies</h2>
<p>The SQL standard defines isolation levels by which anomalies they forbid:</p>
<ul>
<li><strong>Dirty read</strong>: you see data another transaction has not committed and may roll back.</li>
<li><strong>Non-repeatable read</strong>: you read the same row twice and get different values because someone committed in between.</li>
<li><strong>Phantom read</strong>: you run the same range query twice and new rows appear (or vanish) because someone inserted or deleted matching rows.</li>
</ul>
<table>
<thead>
<tr>
<th>Level</th>
<th>Dirty</th>
<th>Non-repeatable</th>
<th>Phantom (standard)</th>
</tr>
</thead>
<tbody>
<tr>
<td>READ UNCOMMITTED</td>
<td>possible</td>
<td>possible</td>
<td>possible</td>
</tr>
<tr>
<td>READ COMMITTED</td>
<td>no</td>
<td>possible</td>
<td>possible</td>
</tr>
<tr>
<td>REPEATABLE READ</td>
<td>no</td>
<td>no</td>
<td>possible</td>
</tr>
<tr>
<td>SERIALIZABLE</td>
<td>no</td>
<td>no</td>
<td>no</td>
</tr>
</tbody>
</table>
<p>The standard says what <em>must not</em> happen. Engines are free to forbid more, and both of the big ones do.</p>
<h2>MySQL InnoDB vs PostgreSQL</h2>
<p><strong>MySQL InnoDB, default <code>REPEATABLE READ</code>.</strong> Plain <code>SELECT</code>s are consistent reads from a snapshot taken at the first read of the transaction, so they see neither non-repeatable reads nor phantoms. Locking reads (<code>SELECT ... FOR UPDATE</code>, <code>FOR SHARE</code>) and <code>UPDATE</code>/<code>DELETE</code> work on the <em>latest committed</em> rows and take next-key locks (row plus gap) on the scanned index range, which blocks phantoms for writes but also blocks inserts into those gaps, a common source of deadlocks. <code>READ UNCOMMITTED</code> really does allow dirty reads. <code>SERIALIZABLE</code> turns plain <code>SELECT</code> into <code>SELECT ... FOR SHARE</code> when autocommit is off, so it is lock based and prone to lock waits.</p>
<p><strong>PostgreSQL, default <code>READ COMMITTED</code>.</strong> Each <em>statement</em> sees a fresh snapshot of committed data. <code>READ UNCOMMITTED</code> is accepted but behaves exactly like <code>READ COMMITTED</code>; Postgres never shows dirty data. <code>REPEATABLE READ</code> is snapshot isolation: one snapshot for the whole transaction, no phantoms, and if you try to update a row that someone else changed after your snapshot, you get SQLSTATE <code>40001</code> and must retry. <code>SERIALIZABLE</code> adds Serializable Snapshot Isolation (SSI): it tracks read/write dependencies and aborts one transaction when the combination could not have happened serially. It uses no extra blocking locks for reads, but you must be ready to retry.</p>
<p>Snapshot isolation in both engines still allows <strong>write skew</strong>: two transactions read the same facts, each writes a <em>different</em> row, and together they break an invariant.</p>
<pre><code class="language-sql">-- Rule: at least one doctor must stay on call for shift 42.
-- Both transactions run under REPEATABLE READ (snapshot isolation).

-- A: SELECT COUNT(*) FROM on_call WHERE shift_id = 42;   -&gt; 2, ok to leave
-- B: SELECT COUNT(*) FROM on_call WHERE shift_id = 42;   -&gt; 2, ok to leave
-- A: DELETE FROM on_call WHERE shift_id = 42 AND doctor_id = 1; COMMIT;
-- B: DELETE FROM on_call WHERE shift_id = 42 AND doctor_id = 2; COMMIT;
-- Result: nobody on call. Different rows were written, so no write conflict.
-- PostgreSQL SERIALIZABLE detects this and aborts one of them with SQLSTATE 40001.
</code></pre>
<p>Only PostgreSQL <code>SERIALIZABLE</code> (or explicit locking) prevents that.</p>
<p>Setting the level is also slightly different:</p>
<pre><code class="language-sql">-- MySQL: applies to the NEXT transaction in this session only
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;

-- PostgreSQL: must be the first statement inside the transaction
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;

-- Check the current level
SELECT @@transaction_isolation;        -- MySQL
SHOW transaction_isolation;            -- PostgreSQL
</code></pre>
<h2>Choosing a Level</h2>
<p>The isolation level is a blunt instrument. Most correctness bugs have a sharper fix, and the decision usually goes like this:</p>
<div class="page-mermaid"><div class="mermaid">flowchart TD
    A[Need consistency for a write] --&gt; B{One row, read then write?}
    B --&gt;|yes| C[Atomic UPDATE or lockForUpdate]
    B --&gt;|no| D{Invariant spans several rows?}
    D --&gt;|no| E[Default level is fine]
    D --&gt;|yes| F{Can you lock the rows that decide it?}
    F --&gt;|yes| G[SELECT FOR UPDATE on those rows]
    F --&gt;|no| H[SERIALIZABLE plus retry on 40001]</div></div>
<ul>
<li><strong>Single-row read-modify-write</strong> (stock, balances, counters): do the math in SQL with an atomic <code>UPDATE ... SET stock = stock - 1 WHERE stock &gt;= 1</code>, or lock the row with <code>lockForUpdate()</code>. Works at any level, on both engines.</li>
<li><strong>Reports and multi-query reads</strong> that must be internally consistent (totals matching line items): <code>REPEATABLE READ</code> gives one snapshot for the whole transaction. MySQL already does this by default; on PostgreSQL ask for it.</li>
<li><strong>Invariants spanning rows you cannot easily lock</strong> (on-call rules, booking overlaps, "max 3 active sessions"): PostgreSQL <code>SERIALIZABLE</code> with retries, or a lock on a parent row (lock the <code>shift</code>, then touch <code>on_call</code>).</li>
<li><strong>High-throughput OLTP on MySQL</strong>: many teams switch the session default to <code>READ COMMITTED</code> to reduce gap locks and deadlocks, then rely on explicit locks where needed.</li>
</ul>
<h2>Implementation in Laravel</h2>
<p>Make the level explicit and type-safe with a backed enum, so nobody can pass arbitrary SQL into a <code>SET</code> statement:</p>
<pre><code class="language-php">namespace App\Database;

enum IsolationLevel: string
{
    case ReadCommitted = 'READ COMMITTED';
    case RepeatableRead = 'REPEATABLE READ';
    case Serializable = 'SERIALIZABLE';
}
</code></pre>
<p>Laravel's <code>DB::transaction($callback, $attempts)</code> already retries when it detects a concurrency error (deadlocks, lock wait timeouts, SQLSTATE <code>40001</code>). What it does not do is set the isolation level for you. The helper below wraps that, and handles the driver difference: PostgreSQL needs <code>SET TRANSACTION</code> as the first statement inside the transaction, MySQL needs it right before <code>BEGIN</code>, and since it only applies to the next transaction it has to be reissued on every retry.</p>
<pre><code class="language-php">namespace App\Database;

use Closure;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Throwable;

final class Isolated
{
    /** SQLSTATEs that mean "safe to retry the whole transaction". */
    private const RETRYABLE = ['40001', '40P01'];

    /** @param Closure(ConnectionInterface): mixed $callback */
    public static function run(
        IsolationLevel $level,
        Closure $callback,
        int $attempts = 3,
        ?string $connection = null,
    ): mixed {
        $db = DB::connection($connection);

        for ($attempt = 1; ; $attempt++) {
            try {
                if ($db-&gt;getDriverName() === 'pgsql') {
                    return $db-&gt;transaction(function () use ($db, $level, $callback) {
                        // Must be the first statement of the transaction in PostgreSQL.
                        $db-&gt;statement('SET TRANSACTION ISOLATION LEVEL ' . $level-&gt;value);

                        return $callback($db);
                    });
                }

                // MySQL / MariaDB: SET TRANSACTION affects the next transaction only,
                // so it has to be issued before BEGIN, on every attempt.
                $db-&gt;statement('SET TRANSACTION ISOLATION LEVEL ' . $level-&gt;value);

                return $db-&gt;transaction(fn () =&gt; $callback($db));
            } catch (QueryException $e) {
                if ($attempt &gt;= $attempts || ! self::isRetryable($e)) {
                    throw $e;
                }
                usleep(random_int(5_000, 20_000) * $attempt);
            }
        }
    }

    public static function isRetryable(Throwable $e): bool
    {
        $state = $e instanceof QueryException ? ($e-&gt;errorInfo[0] ?? $e-&gt;getCode()) : $e-&gt;getCode();

        return in_array((string) $state, self::RETRYABLE, true);
    }
}
</code></pre>
<p>Then use the cheapest tool that is correct for each case:</p>
<pre><code class="language-php">namespace App\Actions;

use App\Database\Isolated;
use App\Database\IsolationLevel;
use App\Models\OnCall;
use App\Models\Product;
use DomainException;
use Illuminate\Support\Facades\DB;

final class InventoryActions
{
    /** Atomic decrement: no isolation tricks needed, the row lock does the work. */
    public function sell(int $productId, int $qty): void
    {
        $affected = Product::query()
            -&gt;whereKey($productId)
            -&gt;where('stock', '&gt;=', $qty)
            -&gt;decrement('stock', $qty);

        if ($affected === 0) {
            throw new DomainException('Out of stock');
        }
    }

    /** Invariant across several rows: let SERIALIZABLE catch write skew, then retry. */
    public function leaveShift(int $shiftId, int $doctorId): void
    {
        Isolated::run(IsolationLevel::Serializable, function () use ($shiftId, $doctorId) {
            $count = OnCall::query()-&gt;where('shift_id', $shiftId)-&gt;count();

            if ($count &lt;= 1) {
                throw new DomainException('Someone has to stay on call');
            }

            OnCall::query()
                -&gt;where('shift_id', $shiftId)
                -&gt;where('doctor_id', $doctorId)
                -&gt;delete();
        }, attempts: 5);
    }
}
</code></pre>
<h2>Testing Isolation Behaviour</h2>
<p>Isolation is about concurrency, so a meaningful test needs <strong>two real connections</strong> to a real database. Clone the connection config under a second name, open a transaction on one, commit a change through the other, and assert what the first one sees. Do not use <code>RefreshDatabase</code> here: it wraps the test in a transaction on the default connection, which hides exactly the behaviour you want to observe.</p>
<pre><code class="language-php">// tests/Feature/IsolationLevelTest.php  (runs against a real PostgreSQL database)
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

beforeEach(function () {
    config(['database.connections.pgsql_b' =&gt; config('database.connections.pgsql')]);
    Schema::connection('pgsql')-&gt;dropIfExists('iso_accounts');
    Schema::connection('pgsql')-&gt;create('iso_accounts', function ($table) {
        $table-&gt;id();
        $table-&gt;integer('balance');
    });
    DB::connection('pgsql')-&gt;table('iso_accounts')-&gt;insert(['id' =&gt; 1, 'balance' =&gt; 100]);
});

afterEach(function () {
    DB::purge('pgsql_b');
    Schema::connection('pgsql')-&gt;dropIfExists('iso_accounts');
});

function balance(string $connection): int
{
    return (int) DB::connection($connection)-&gt;table('iso_accounts')-&gt;where('id', 1)-&gt;value('balance');
}

it('sees a non-repeatable read under READ COMMITTED', function () {
    $a = DB::connection('pgsql');
    $a-&gt;beginTransaction();
    expect(balance('pgsql'))-&gt;toBe(100);

    DB::connection('pgsql_b')-&gt;table('iso_accounts')-&gt;where('id', 1)-&gt;update(['balance' =&gt; 50]);

    expect(balance('pgsql'))-&gt;toBe(50);
    $a-&gt;rollBack();
});

it('keeps a stable snapshot under REPEATABLE READ', function () {
    $a = DB::connection('pgsql');
    $a-&gt;beginTransaction();
    $a-&gt;statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
    expect(balance('pgsql'))-&gt;toBe(100);

    DB::connection('pgsql_b')-&gt;table('iso_accounts')-&gt;where('id', 1)-&gt;update(['balance' =&gt; 50]);

    expect(balance('pgsql'))-&gt;toBe(100);

    expect(fn () =&gt; $a-&gt;table('iso_accounts')-&gt;where('id', 1)-&gt;update(['balance' =&gt; 90]))
        -&gt;toThrow(QueryException::class, 'could not serialize access');
    $a-&gt;rollBack();
});
</code></pre>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Thinking <code>DB::transaction()</code> prevents lost updates.</strong> It does not. Read-modify-write in PHP needs <code>lockForUpdate()</code> or an atomic SQL update.</li>
<li><strong>Assuming "REPEATABLE READ" means the same thing everywhere.</strong> MySQL's version lets <code>UPDATE</code> see newer data than your <code>SELECT</code>; PostgreSQL's version aborts with <code>40001</code>.</li>
<li><strong>Using SERIALIZABLE without retries.</strong> Serialization failures are normal, not exceptional. No retry loop means random 500s under load.</li>
<li><strong>Side effects inside a retried transaction.</strong> A retry replays the closure. Charging a card or dispatching a job inside it can happen twice.</li>
<li><strong>Setting the level at the wrong time.</strong> In PostgreSQL, <code>SET TRANSACTION</code> after any query in the transaction fails. In MySQL, issuing it inside an active transaction fails; issuing it once and then retrying silently runs the retry at the session default.</li>
<li><strong>Long transactions under REPEATABLE READ.</strong> Old snapshots keep old row versions alive (InnoDB undo log, Postgres dead tuples), which bloats storage and slows vacuum and purge.</li>
<li><strong>Gap-lock deadlocks on MySQL.</strong> <code>SELECT ... FOR UPDATE</code> on a range in <code>REPEATABLE READ</code> locks gaps too. Lock by primary key where you can, keep a consistent lock order, and keep transactions short.</li>
</ul>
<h2>When to Use What, and When Not To</h2>
<p>Use the defaults when your writes are single-row and expressed as atomic SQL, or protected by <code>lockForUpdate()</code>. That covers most CRUD apps.</p>
<p>Use <code>REPEATABLE READ</code> for multi-statement reads that must agree with each other, such as exports, invoices and reconciliation jobs, especially on PostgreSQL where it is not the default.</p>
<p>Use PostgreSQL <code>SERIALIZABLE</code> for business invariants across rows when locking is awkward, and only together with a retry loop and idempotent transaction bodies.</p>
<p>Do not raise the isolation level globally "to be safe". It increases aborts, lock waits and deadlocks across the whole app and still does not fix application-level read-modify-write on MySQL. Never use <code>READ UNCOMMITTED</code> for business logic.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Next.js App Router Caching: fetch Cache, revalidate, ISR, revalidateTag and updateTag</title>
            <link>https://cslant.com/tips/nextjs-app-router-caching-fetch-cache-revalidate-isr-revalidatetag-and-updatetag</link>
            <guid isPermaLink="false">https://cslant.com/tips/nextjs-app-router-caching-fetch-cache-revalidate-isr-revalidatetag-and-updatetag</guid>
                        <pubDate>Fri, 30 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>Next.js</category>
                                    <description>Stale prices and leaked user data both come from misunderstood caching. Learn the Next.js layers, ISR, tags, updateTag vs revalidateTag, and when a route goes dynamic.</description>
                                    <content:encoded><![CDATA[<p>The marketing team changes a product price in the CMS, refreshes the storefront, and still sees the old price. Ten minutes later it is right. Meanwhile, on a different page, a logged-in customer briefly saw <em>another</em> customer's order list because someone added <code>cache: 'force-cache'</code> to a fetch that carried an auth header. Both bugs come from the same place: not knowing which layer of the Next.js App Router cache is holding what, and for how long.</p>
<p>The App Router gives you powerful caching, but it is explicit now. Since Next.js 15, <code>fetch</code> is not cached unless you ask, and Next.js 16 tightened the revalidation APIs (<code>revalidateTag</code> takes a profile, <code>updateTag</code> was added for Server Actions). This tip covers the model most production apps still run on: fetch caching, segment-level <code>revalidate</code>, ISR, and on-demand invalidation. Cache Components (<code>'use cache'</code>, behind the <code>cacheComponents</code> flag) is a separate opt-in model and is mentioned only where it changes the advice.</p>
<h2>The Layers, From Request to Origin</h2>
<p>There are three server-side ideas to keep apart:</p>
<ul>
<li><strong>Request memoization.</strong> Identical <code>GET</code> fetches during one render are executed once. It lasts for a single render pass and needs no configuration.</li>
<li><strong>Data Cache.</strong> The persistent cache for <code>fetch</code> results (and <code>unstable_cache</code> results). It survives across requests and deployments on platforms that persist it. You opt in with <code>cache: 'force-cache'</code> or <code>next.revalidate</code>.</li>
<li><strong>Full Route Cache.</strong> The rendered HTML and RSC payload of a static route. This is what ISR regenerates.</li>
</ul>
<p>On top of that, the browser keeps a client-side router cache for navigations, which <code>revalidatePath</code> and <code>updateTag</code> also refresh when called from a Server Action.</p>
<div class="page-mermaid"><div class="mermaid">graph TD
A[Visitor requests a page] --&gt; B[Full Route Cache]
B --&gt;|fresh| C[Serve cached HTML]
B --&gt;|stale| D[Serve stale HTML and regenerate]
B --&gt;|miss or dynamic route| E[Render on the server]
E --&gt; F[Data Cache for fetch]
F --&gt;|miss| G[Origin API or database]
H[revalidateTag or updateTag] --&gt;|mark stale or expire| F
H --&gt;|affected routes| B</div></div>
<h2>Caching fetch and Tagging Data</h2>
<p>Put data access in one module so every caller gets the same cache settings. Each fetch states its maximum age and the tags that can invalidate it:</p>
<pre><code class="language-ts">// lib/products.ts
import 'server-only'

export type Product = { id: string; slug: string; name: string; price: number; stock: number }

const API = process.env.CATALOG_API_URL!

export async function getProduct(slug: string): Promise&lt;Product | null&gt; {
  const res = await fetch(`${API}/products/${slug}`, {
    next: {
      revalidate: 300, // at most 5 minutes old
      tags: ['products', `product:${slug}`],
    },
  })

  if (res.status === 404) return null
  if (!res.ok) throw new Error(`Catalog API failed: ${res.status}`)

  return res.json()
}

export async function getProductSlugs(): Promise&lt;string[]&gt; {
  const res = await fetch(`${API}/products?fields=slug&amp;limit=500`, {
    cache: 'force-cache',
    next: { tags: ['products'] },
  })

  return (await res.json()).map((p: { slug: string }) =&gt; p.slug)
}
</code></pre>
<p><code>next.revalidate: 300</code> means the response may be up to five minutes old. After that, the next request still gets the cached value while Next.js refetches in the background: classic stale-while-revalidate. Tags are free-form strings; a broad tag (<code>products</code>) plus a precise one (<code>product:${slug}</code>) lets you invalidate one item or the whole catalog.</p>
<p>For data that does not come from <code>fetch</code>, such as an ORM call, wrap the function with <code>unstable_cache</code>. Despite the name, it is the documented way to cache non-fetch work in this model:</p>
<pre><code class="language-ts">// lib/categories.ts
import 'server-only'
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

export const getCategoryTree = unstable_cache(
  async (locale: string) =&gt; db.category.findMany({ where: { locale }, orderBy: { position: 'asc' } }),
  ['category-tree'], // key prefix; the locale argument is part of the key too
  { revalidate: 3600, tags: ['categories'] },
)
</code></pre>
<h2>ISR: Static Pages That Stay Fresh</h2>
<p>Incremental Static Regeneration is just a static route with a revalidation time. Combine <code>generateStaticParams</code> to prebuild the hot pages, <code>dynamicParams</code> to render the long tail on first request, and a segment <code>revalidate</code>:</p>
<pre><code class="language-tsx">// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getProduct, getProductSlugs } from '@/lib/products'

// ISR: route default is one hour. getProduct() asks for 300 seconds, and the
// lowest value wins, so this route actually regenerates every 5 minutes at most.
export const revalidate = 3600

// Slugs not returned below are rendered on first request, then cached too.
export const dynamicParams = true

export async function generateStaticParams() {
  const slugs = await getProductSlugs()
  return slugs.slice(0, 100).map((slug) =&gt; ({ slug })) // prebuild the top 100 only
}

export default async function ProductPage({ params }: { params: Promise&lt;{ slug: string }&gt; }) {
  const { slug } = await params
  const product = await getProduct(slug)

  if (!product) notFound()

  return (
    &lt;main&gt;
      &lt;h1&gt;{product.name}&lt;/h1&gt;
      &lt;p&gt;{product.price.toLocaleString('en-US')} USD&lt;/p&gt;
      &lt;p&gt;{product.stock &gt; 0 ? 'In stock' : 'Sold out'}&lt;/p&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>Read the comment on <code>revalidate</code> twice. The lowest revalidation value among the route's layouts, page and fetches decides how often the route regenerates. A single fetch with <code>revalidate: 300</code> silently turns your "hourly" page into a five-minute page. That is usually fine, but it should be a decision, not a surprise.</p>
<h2>When a Route Becomes Dynamic</h2>
<p>A route is rendered per request as soon as it uses a request-time API (<code>cookies()</code>, <code>headers()</code>, <code>searchParams</code>, <code>connection()</code>) or a fetch with <code>cache: 'no-store'</code>. That is exactly what you want for personal data:</p>
<pre><code class="language-tsx">// app/dashboard/page.tsx
import { cookies } from 'next/headers'

export default async function Dashboard() {
  // Reading cookies is a request-time API: this route now renders per request.
  const session = (await cookies()).get('session')?.value

  const orders = await fetch(`${process.env.ORDERS_API_URL}/me/orders`, {
    headers: { authorization: `Bearer ${session}` },
    cache: 'no-store', // per-user data: never put it in the shared cache
  }).then((r) =&gt; r.json())

  return &lt;pre&gt;{JSON.stringify(orders, null, 2)}&lt;/pre&gt;
}
</code></pre>
<p>The rule that prevents the leak from the intro: <strong>never cache a response that depends on who is asking.</strong> <code>force-cache</code> caches requests even when they carry <code>authorization</code> or <code>cookie</code> headers, and the cache key is built from the URL, method, headers and body. Different tokens get different keys, so it will not mix users up by accident, but it will happily store personal data in a shared cache where it outlives the session. Use <code>no-store</code> for per-user data and keep cached data public.</p>
<p>You can also force the mode with <code>export const dynamic = 'force-dynamic'</code> or <code>'force-static'</code>. Prefer letting the APIs you call decide; the forced modes are useful as guard rails (<code>dynamic = 'error'</code> fails the build if a route you expect to be static turns dynamic).</p>
<h2>On-Demand Revalidation: updateTag, revalidateTag, revalidatePath</h2>
<p>Time-based revalidation is a ceiling. When you <em>know</em> data changed, invalidate it. The three functions behave differently:</p>
<ul>
<li><code>updateTag(tag)</code> works only in Server Actions. It expires the tagged entries immediately, so the user who just saved sees their own change on the next render. Use it after a mutation in your app.</li>
<li><code>revalidateTag(tag, 'max')</code> marks tagged data stale. The next visitor gets the old content while it regenerates. Use it from webhooks and Route Handlers where nobody is waiting to read their own write. Pass <code>{ expire: 0 }</code> instead of <code>'max'</code> if the data must be gone right away. The single-argument form is deprecated.</li>
<li><code>revalidatePath(path)</code> invalidates a specific route path. For a dynamic segment pattern, add the type: <code>revalidatePath('/products/[slug]', 'page')</code>.</li>
</ul>
<p>The admin action, which needs read-your-own-writes:</p>
<pre><code class="language-ts">// app/admin/products/actions.ts
'use server'

import { revalidatePath, updateTag } from 'next/cache'
import { requireAdmin } from '@/lib/auth'
import { catalog } from '@/lib/catalog-client'

export async function updatePrice(slug: string, price: number) {
  await requireAdmin()
  await catalog.updatePrice(slug, price)

  // The admin who clicked Save must see the new price on the next render:
  // updateTag expires the entry immediately (read-your-own-writes).
  updateTag(`product:${slug}`)

  // The listing page has its own cached HTML; refresh that path too.
  revalidatePath('/products')
}
</code></pre>
<p>The CMS webhook, which only needs eventual freshness:</p>
<pre><code class="language-ts">// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  if (request.headers.get('x-webhook-secret') !== process.env.CATALOG_WEBHOOK_SECRET) {
    return Response.json({ ok: false }, { status: 401 })
  }

  const { slug } = (await request.json()) as { slug?: string }
  if (!slug) return Response.json({ ok: false, error: 'slug required' }, { status: 400 })

  // Stale-while-revalidate: visitors keep getting the old page while it regenerates.
  revalidateTag(`product:${slug}`, 'max')

  return Response.json({ ok: true, revalidated: `product:${slug}` })
}
</code></pre>
<h2>Testing Cache Invalidation</h2>
<p>You cannot meaningfully unit-test Next.js's cache itself, but you can test the part that breaks in practice: whether your actions invalidate the right tags, in the right order, and only after a successful write. Mock <code>next/cache</code> with Vitest:</p>
<pre><code class="language-ts">// app/admin/products/actions.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('next/cache', () =&gt; ({ updateTag: vi.fn(), revalidatePath: vi.fn(), revalidateTag: vi.fn() }))
vi.mock('@/lib/auth', () =&gt; ({ requireAdmin: vi.fn().mockResolvedValue({ id: 'u1' }) }))
vi.mock('@/lib/catalog-client', () =&gt; ({ catalog: { updatePrice: vi.fn().mockResolvedValue(undefined) } }))

import { revalidatePath, updateTag } from 'next/cache'
import { catalog } from '@/lib/catalog-client'
import { updatePrice } from './actions'

describe('updatePrice', () =&gt; {
  beforeEach(() =&gt; vi.clearAllMocks())

  it('writes first, then expires the product tag and the listing path', async () =&gt; {
    await updatePrice('blue-mug', 12)

    expect(catalog.updatePrice).toHaveBeenCalledWith('blue-mug', 12)
    expect(updateTag).toHaveBeenCalledWith('product:blue-mug')
    expect(revalidatePath).toHaveBeenCalledWith('/products')
  })

  it('does not touch the cache when the write fails', async () =&gt; {
    vi.mocked(catalog.updatePrice).mockRejectedValueOnce(new Error('API down'))

    await expect(updatePrice('blue-mug', 12)).rejects.toThrow('API down')
    expect(updateTag).not.toHaveBeenCalled()
  })
})
</code></pre>
<p>Then verify the build output and the response headers against a production build, because development mode always renders on demand:</p>
<pre><code class="language-bash">next build
# The route table marks each route:
#   ○ Static   ● SSG (prerendered via generateStaticParams)   ƒ Dynamic
# If /products/[slug] shows ƒ, something in it reads cookies, headers or no-store data.

next start &amp;
curl -sI http://localhost:3000/products/blue-mug | grep -i x-nextjs-cache
# x-nextjs-cache: HIT   (MISS on the first visit, STALE while regenerating)
</code></pre>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Testing caching in <code>next dev</code>.</strong> Pages are always rendered on demand in development. Check behaviour with <code>next build &amp;&amp; next start</code>.</li>
<li><strong>Expecting fetch to be cached by default.</strong> It is not since Next.js 15. Without <code>force-cache</code> or <code>revalidate</code>, a fetch is fetched at build time for a static route and on every request for a dynamic one.</li>
<li><strong>Revalidating a tag nobody set.</strong> <code>revalidateTag('product-1')</code> does nothing if the fetch used <code>product:1</code>. Build tag strings in one helper and reuse it on both sides.</li>
<li><strong>Conflicting fetch options.</strong> <code>{ cache: 'no-store', next: { revalidate: 60 } }</code> is invalid; both are ignored with a warning in development.</li>
<li><strong>Calling <code>revalidateTag</code> in a Server Action and wondering why the admin sees old data.</strong> With <code>'max'</code>, the first read after invalidation is stale by design. Use <code>updateTag</code> there.</li>
<li><strong>Unprotected revalidation endpoints.</strong> Anyone who can hit <code>/api/revalidate</code> can force regeneration of your whole catalog. Check a secret or signature.</li>
</ul>
<h2>When to Use What, and When Not To</h2>
<p>Use <strong>time-based <code>revalidate</code></strong> for content where a few minutes of staleness is acceptable and you have no change events: exchange rates, public stats, third-party feeds.</p>
<p>Use <strong>ISR with tags</strong> for catalog, blog and docs pages: static speed, with webhooks for freshness.</p>
<p>Use <strong><code>updateTag</code></strong> in Server Actions when a user edits something and expects to see it immediately.</p>
<p>Use <strong>dynamic rendering</strong> for anything personal, permission-dependent or truly real-time. Do not try to cache your way around it with clever keys; the cost of a leak is far higher than the cost of a render.</p>
<p>If you are starting a new Next.js 16 app, look at Cache Components too: <code>'use cache'</code> with <code>cacheLife</code> and <code>cacheTag</code> replaces most segment config, and <code>dynamic</code> / <code>revalidate</code> exports do not apply there. The invalidation ideas in this tip carry over unchanged.</p>]]></content:encoded>
                                </item>
                <item>
            <title>PHP Enums Beyond the Basics: Backed Enums, Methods, tryFrom and Eloquent Casts</title>
            <link>https://cslant.com/tips/php-enums-beyond-the-basics-backed-enums-methods-tryfrom-and-eloquent-casts</link>
            <guid isPermaLink="false">https://cslant.com/tips/php-enums-beyond-the-basics-backed-enums-methods-tryfrom-and-eloquent-casts</guid>
                        <pubDate>Thu, 29 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>PHP</category>
                                    <description>One &#039;canceled&#039; vs &#039;cancelled&#039; typo can strand orders for days. Use backed enums with methods, interfaces, tryFrom and Eloquent casts to kill magic strings.</description>
                                    <content:encoded><![CDATA[<p>A customer writes in: their order has been "processing" for nine days. You dig in and find a queue job that sets <code>status = 'canceled'</code> while every other part of the app checks for <code>'cancelled'</code>. The refund listener never fired, the dashboard filter never matched, and nobody noticed because both strings are perfectly valid PHP. A grep finds the status strings in 40-odd files.</p>
<p>Native enums landed in PHP 8.1 and they fix exactly this class of bug. Most teams adopt them halfway, though: they declare <code>case Paid = 'paid'</code> and keep the business rules in <code>if</code> statements spread around the codebase. This tip goes further: backed enums as the single source of truth, behaviour living on the enum, interfaces and constants, safe parsing with <code>tryFrom()</code>, Eloquent casts, and a migration path off magic strings that does not break production data.</p>
<h2>Pure Enums vs Backed Enums</h2>
<p>An enum is a closed set of named values. PHP gives you two flavours:</p>
<pre><code class="language-php">enum Suit { case Hearts; case Spades; } // pure: no scalar value

// backed: every case maps to a unique int or string you can persist
enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

echo OrderStatus::Paid-&gt;name;   // "Paid"  (the identifier in code)
echo OrderStatus::Paid-&gt;value;  // "paid"  (what goes into the database)

var_dump(OrderStatus::Paid === OrderStatus::from('paid')); // bool(true): cases are singletons
</code></pre>
<p>A <strong>pure enum</strong> has cases and nothing else. Use it for states that never leave the process: a parser mode, a UI variant, a strategy selector. A <strong>backed enum</strong> gives each case a scalar (<code>int</code> or <code>string</code>, nothing else) that must be unique and constant. That scalar is what you store in the database, put in JSON, and receive from forms.</p>
<p>A few facts worth knowing before you build on them:</p>
<ul>
<li><code>-&gt;name</code> is the identifier you wrote in code. <code>-&gt;value</code> is the persisted value. Renaming a case is a refactor; changing a value is a data migration.</li>
<li>Every case is a singleton object, so <code>===</code> works and is the comparison you want.</li>
<li>Backed enums implement <code>BackedEnum</code> (which extends <code>UnitEnum</code>); pure enums implement only <code>UnitEnum</code>. You can type-hint against those interfaces in generic code.</li>
<li><code>json_encode()</code> turns a backed case into its value. A pure case cannot be serialised to JSON at all: with <code>JSON_THROW_ON_ERROR</code> you get a <code>JsonException</code> saying non-backed enums have no default serialization.</li>
</ul>
<p>Prefer string backing: <code>status = 3</code> in a support query tells nobody anything.</p>
<h2>Put Behaviour on the Enum: Methods and match</h2>
<p>The real payoff comes when the rules that belong to a status live next to it. Enums can have methods (instance and static), and <code>match ($this)</code> makes them compact:</p>
<pre><code class="language-php">enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Pending =&gt; 'Waiting for payment',
            self::Paid =&gt; 'Paid',
            self::Shipped =&gt; 'On the way',
            self::Delivered =&gt; 'Delivered',
            self::Cancelled =&gt; 'Cancelled',
        };
    }

    public function isFinal(): bool
    {
        return $this === self::Delivered || $this === self::Cancelled;
    }

    /** @return list&lt;self&gt; */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Pending =&gt; [self::Paid, self::Cancelled],
            self::Paid =&gt; [self::Shipped, self::Cancelled],
            self::Shipped =&gt; [self::Delivered],
            self::Delivered, self::Cancelled =&gt; [],
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this-&gt;allowedTransitions(), true);
    }
}
</code></pre>
<p>Two details make this robust. First, there is no <code>default</code> arm in the <code>match</code> expressions. If someone adds <code>case Refunded = 'refunded'</code> and forgets to handle it, PHP throws <code>UnhandledMatchError</code> the first time that path runs, and a test that loops over <code>cases()</code> catches it before production. A <code>default</code> arm would silently give refunded orders the wrong label.</p>
<p>Second, the transition table now has exactly one home. Controllers ask <code>$order-&gt;status-&gt;canTransitionTo(OrderStatus::Shipped)</code> instead of re-implementing <code>in_array($order-&gt;status, ['paid'])</code>. When product changes the rules, you change one method and the tests that cover it.</p>
<p>What enums cannot do: hold state. An enum may not declare properties, not even static ones, and cases cannot be constructed with <code>new</code>.</p>
<h2>Constants, Static Methods and Interfaces</h2>
<p>Enums can implement interfaces, use traits (as long as the trait declares no properties), declare constants, and have static methods. That lets them plug into generic code:</p>
<pre><code class="language-php">interface HasLabel
{
    public function label(): string;
}

trait ListsValues
{
    /** @return list&lt;string&gt; */
    public static function values(): array
    {
        return array_column(self::cases(), 'value');
    }
}

enum PaymentMethod: string implements HasLabel
{
    use ListsValues;

    case Card = 'card';
    case BankTransfer = 'bank_transfer';
    case Wallet = 'wallet';

    public const DEFAULT = self::Card;
    public const DEFAULT_VALUE = self::Card-&gt;value; // allowed since PHP 8.2

    public function label(): string
    {
        return match ($this) {
            self::Card =&gt; 'Credit / debit card',
            self::BankTransfer =&gt; 'Bank transfer',
            self::Wallet =&gt; 'E-wallet',
        };
    }
}

echo implode(',', PaymentMethod::values()); // card,bank_transfer,wallet
</code></pre>
<p><code>HasLabel</code> lets a form component render any labelled enum. The <code>ListsValues</code> trait removes a line you would otherwise copy into every enum. Constants are handy for defaults: <code>PaymentMethod::DEFAULT</code> is a real case, so it is type-safe everywhere it is used. Since PHP 8.2 you can also read <code>-&gt;value</code> inside a constant expression, which is useful for attribute arguments and migration defaults.</p>
<h2>from() vs tryFrom(): Parsing Untrusted Input</h2>
<p>Backed enums come with two constructors for turning a scalar back into a case:</p>
<pre><code class="language-php">enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';
}

// from(): use when an invalid value is a bug. Throws ValueError.
$status = OrderStatus::from('paid');

// tryFrom(): use at the boundary, where bad input is expected. Returns null.
function statusFromWebhook(array $payload): OrderStatus
{
    $raw = $payload['status'] ?? null;

    if (! is_string($raw)) {
        throw new InvalidArgumentException('Webhook payload has no status.');
    }

    // Normalise known aliases first.
    $normalised = match (strtolower(trim($raw))) {
        'canceled' =&gt; 'cancelled',
        default =&gt; strtolower(trim($raw)),
    };

    return OrderStatus::tryFrom($normalised)
        ?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
}

echo statusFromWebhook(['status' =&gt; ' Canceled '])-&gt;name; // Cancelled
</code></pre>
<p>The rule of thumb:</p>
<ul>
<li><strong><code>from()</code></strong> when an invalid value means a bug in your own system: a value you wrote yourself, a config key, a column you control. It throws <code>ValueError</code>, and you want it loud.</li>
<li><strong><code>tryFrom()</code></strong> at the edges: webhooks, CSV imports, query strings, anything a third party sends. It returns <code>null</code>, and the <code>?? throw</code> idiom turns that into a domain-specific exception with a useful message.</li>
</ul>
<p>Watch the types under <code>declare(strict_types=1)</code>: an int-backed enum's <code>from('1')</code> throws a <code>TypeError</code>, not a <code>ValueError</code>, because the argument must be an <code>int</code>. Cast at the boundary (<code>(int) $request-&gt;input('level')</code>) after validating that it is numeric.</p>
<h2>Eloquent Enum Casts in Laravel 13</h2>
<p>Laravel casts a column straight to a backed enum. Reading gives you a case, writing accepts a case (or its value), and the query builder binds enum cases as their scalar value:</p>
<pre><code class="language-php">namespace App\Models;

use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Model;

final class Order extends Model
{
    protected $fillable = ['status', 'payment_method', 'allowed_methods'];

    protected function casts(): array
    {
        return [
            'status' =&gt; OrderStatus::class,
            'payment_method' =&gt; PaymentMethod::class,
            'allowed_methods' =&gt; AsEnumCollection::of(PaymentMethod::class),
        ];
    }

    public function markAsPaid(): void
    {
        if (! $this-&gt;status-&gt;canTransitionTo(OrderStatus::Paid)) {
            throw new \DomainException("Cannot pay an order that is {$this-&gt;status-&gt;value}.");
        }

        $this-&gt;status = OrderStatus::Paid;
        $this-&gt;save();
    }

    public function scopeOpen(Builder $query): void
    {
        // The query builder binds BackedEnum cases as their -&gt;value.
        $query-&gt;whereIn('status', [OrderStatus::Pending, OrderStatus::Paid, OrderStatus::Shipped]);
    }
}
</code></pre>
<p><code>AsEnumCollection::of()</code> stores a JSON array of values and hydrates a collection of cases, which fits "allowed methods per merchant" style columns. For input, <code>Rule::enum()</code> validates against the cases, <code>only()</code> / <code>except()</code> narrow it, and <code>$request-&gt;enum('status', OrderStatus::class)</code> returns the case (or <code>null</code>):</p>
<pre><code class="language-php">namespace App\Http\Requests;

use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class UpdateOrderStatusRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status' =&gt; ['required', Rule::enum(OrderStatus::class)-&gt;only([OrderStatus::Shipped, OrderStatus::Cancelled])],
        ];
    }}
</code></pre>
<p>Route binding understands backed enums too: type-hint <code>OrderStatus $status</code> and <code>/orders/status/bogus</code> returns a 404 with no code from you.</p>
<p>For the column itself, keep a plain <code>string</code> (say <code>varchar(32)</code>) with <code>-&gt;default(OrderStatus::Pending-&gt;value)</code> rather than a database <code>ENUM</code> type. Adding a case then needs no schema change, and the PHP enum stays the single source of truth.</p>
<h2>Replacing Magic Strings Without Breaking Production</h2>
<p>Migrating is a sequence, not a big bang, because the cast throws on the first value it cannot map:</p>
<div class="page-mermaid"><div class="mermaid">graph LR
A[Grep magic strings] --&gt; B[Audit DB values]
B --&gt; C[Create backed enum]
C --&gt; D[Add Eloquent cast]
D --&gt; E[Validate input with Rule enum]
E --&gt; F[Move logic into enum methods]
F --&gt; G[Delete old string constants]</div></div>
<ol>
<li><strong>Grep the strings</strong>, including views, JavaScript and SQL reports. This is your checklist.</li>
<li><strong>Audit the database.</strong> Before writing the cast, find out what is actually stored. Legacy rows always contain surprises:</li>
</ol>
<pre><code class="language-sql">-- Run this BEFORE adding the cast. Every row must map to a case.
SELECT status, COUNT(*) AS total
FROM orders
GROUP BY status
ORDER BY total DESC;
</code></pre>
<ol start="3">
<li><strong>Create the enum</strong> with the values the database really contains after cleanup.</li>
<li><strong>Add the cast.</strong> Every <code>$order-&gt;status === 'paid'</code> comparison is now silently <code>false</code>; fix them in the same pull request using the grep list.</li>
<li><strong>Validate input</strong> with <code>Rule::enum()</code> so new bad values cannot get in.</li>
<li><strong>Move logic into the enum</strong>: transitions, labels, colours, "is this final".</li>
<li><strong>Delete the old constants</strong> (<code>Order::STATUS_PAID</code> and friends) once nothing references them.</li>
</ol>
<p>Steps 4 and 6 can ship in separate deploys; the audit must happen before the cast goes live.</p>
<h2>Testing Enums with Pest</h2>
<p>Enum logic is pure, so tests are fast and need no setup. Test the transition table with a dataset, loop over <code>cases()</code> so new cases cannot slip through untested, and add one database-level guard:</p>
<pre><code class="language-php">use App\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;

it('allows only the documented transitions', function (OrderStatus $from, OrderStatus $to, bool $allowed) {
    expect($from-&gt;canTransitionTo($to))-&gt;toBe($allowed);
})-&gt;with([
    'pay a pending order' =&gt; [OrderStatus::Pending, OrderStatus::Paid, true],
    'ship a paid order' =&gt; [OrderStatus::Paid, OrderStatus::Shipped, true],
    'ship an unpaid order' =&gt; [OrderStatus::Pending, OrderStatus::Shipped, false],
    'revive a cancelled order' =&gt; [OrderStatus::Cancelled, OrderStatus::Pending, false],
]);

it('gives every case a label', function () {
    foreach (OrderStatus::cases() as $case) {
        expect($case-&gt;label())-&gt;toBeString()-&gt;not-&gt;toBeEmpty();
    }
});

it('has no status values in the database that the enum cannot read', function () {
    $unknown = DB::table('orders')
        -&gt;distinct()
        -&gt;pluck('status')
        -&gt;reject(fn (string $value): bool =&gt; OrderStatus::tryFrom($value) !== null);

    expect($unknown)-&gt;toBeEmpty();
});
</code></pre>
<p>Run the last test against a staging snapshot and it tells you whether the cast will throw on real data. The <code>cases()</code> loop catches a new case without a label.</p>
<h2>Common Pitfalls</h2>
<p><strong>Comparing a cast attribute to a string.</strong> After adding the cast, <code>$order-&gt;status === 'paid'</code> is always <code>false</code> because you are comparing an object to a string. No error, just wrong behaviour. Compare to the case: <code>$order-&gt;status === OrderStatus::Paid</code>.</p>
<p><strong>Using cases as array keys.</strong> Enums are objects, so <code>[Plan::Free =&gt; 10]</code> throws a <code>TypeError</code>. Use <code>$limits[Plan::Pro-&gt;value]</code>, or a <code>WeakMap</code> when you really need the case itself as the key.</p>
<p><strong>A <code>default</code> arm in <code>match</code>.</strong> It defeats the exhaustiveness check that makes enums safer than strings. Leave it out unless "everything else" is a real business rule.</p>
<p><strong>Invalid rows crash reads.</strong> If the database contains a value the enum does not know, reading the attribute throws <code>ValueError</code>. That happens on page loads and in queue workers, not just in the code that wrote the bad value. Clean data first, then cast.</p>
<p><strong>Changing a case value casually.</strong> The value lives in rows, cache entries, queued jobs and API clients. Treat it like a column rename: add the new value, migrate data, remove the old one.</p>
<h2>When to Use Enums and When Not To</h2>
<p>Use a backed enum when the set of values is <strong>closed and owned by your code</strong>: order states, roles with hard-coded permissions, payment methods you actually integrate with, log levels, feature tiers. Adding a case is a code change that goes through review, which is exactly what you want for values that carry logic.</p>
<p>Use a pure enum for in-process choices that are never stored, like sort directions.</p>
<p>Do <strong>not</strong> use an enum when the list is data rather than code. Product categories that admins create, per-tenant ticket statuses, countries with editable metadata: those belong in a table with a foreign key. If adding a value should not require a deploy, it is not an enum.</p>
<p>The goal is simple: every place that cares about a status should ask the enum, and the database should only ever contain values the enum can read.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Zero-Downtime Database Migrations with Expand/Contract: Rename Columns Without Breaking Deploys</title>
            <link>https://cslant.com/tips/zero-downtime-database-migrations-with-expandcontract-rename-columns-without-breaking-deploys</link>
            <guid isPermaLink="false">https://cslant.com/tips/zero-downtime-database-migrations-with-expandcontract-rename-columns-without-breaking-deploys</guid>
                        <pubDate>Wed, 28 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>Renaming a column mid-deploy breaks old servers and queue workers. Use expand/contract, batched backfills and the right deploy order to change schemas safely.</description>
                                    <content:encoded><![CDATA[<p>It is 14:05, the deploy pipeline is green, and the error tracker lights up with <code>SQLSTATE[42S22]: Column not found: 1054 Unknown column 'name'</code>. The migration that renamed <code>users.name</code> to <code>users.full_name</code> ran fine. The problem is that for about forty seconds, the old release was still serving traffic on half the servers, and the old code still selects <code>name</code>. Queue workers that had not restarted yet were even worse: they kept failing jobs until someone ran <code>php artisan queue:restart</code> by hand.</p>
<p>Nothing in that story is a bug in the migration itself. The bug is the assumption that schema and code change at the same instant. They never do. During every deploy there is a window where old code runs against the new schema, or new code runs against the old schema, and on a rolling or blue/green deploy that window can last minutes. Zero-downtime migrations are about making every schema change compatible with <strong>both</strong> versions of the code that might be running at the same time.</p>
<h2>Why "Just Run the Migration" Breaks in Production</h2>
<p>A typical deploy does three things: ship new code, run <code>php artisan migrate --force</code>, switch traffic. Whatever order you pick, there is overlap:</p>
<ul>
<li><strong>Migrate first, then switch code</strong>: old code runs against the new schema. Renames and drops break it immediately.</li>
<li><strong>Switch code first, then migrate</strong>: new code runs against the old schema. Any new column it reads does not exist yet.</li>
<li><strong>Rolling deploys</strong> (Kubernetes, multiple Forge servers, Vapor): both versions serve traffic at the same time for the whole rollout.</li>
<li><strong>Queue workers and scheduled commands</strong> keep the old code in memory until they restart, so they lag behind web servers.</li>
<li><strong>Rollback</strong>: if the new release is bad, you redeploy the old code. If the migration already dropped something, the old code is now broken too.</li>
</ul>
<p>There is also the database side. On a large table, some <code>ALTER TABLE</code> operations take a metadata lock or rewrite the table. MySQL 8 can do many changes with <code>ALGORITHM=INSTANT</code> or <code>INPLACE</code>, and PostgreSQL adds nullable columns without a rewrite, but a long-running <code>UPDATE users SET full_name = name</code> on 20 million rows will hold row locks, bloat the undo log or WAL, and stall replication. So "zero downtime" has two halves: compatible code, and small, lock-friendly database operations.</p>
<h2>The Expand/Contract Pattern</h2>
<p>Expand/contract (also called parallel change) splits one breaking change into several non-breaking ones. You <strong>expand</strong> the schema so that both old and new shapes exist, migrate the data and the code in small steps, then <strong>contract</strong> by removing the old shape once nothing uses it. Each step is its own deploy, and each deploy is safe to roll back to the previous one.</p>
<div class="page-mermaid"><div class="mermaid">graph LR
A[Expand: add nullable full_name] --&gt; B[Dual write: name and full_name]
B --&gt; C[Backfill old rows in batches]
C --&gt; D[Switch reads to full_name]
D --&gt; E[Stop writing name]
E --&gt; F[Contract: drop name]</div></div>
<p>For the <code>name</code> to <code>full_name</code> rename, the phases look like this:</p>
<ol>
<li><strong>Expand</strong>: add <code>full_name</code> as a nullable column. Old code ignores it. Nothing breaks.</li>
<li><strong>Dual write</strong>: deploy code that writes to both <code>name</code> and <code>full_name</code>, but still reads <code>name</code>. Every new or updated row now has both values.</li>
<li><strong>Backfill</strong>: copy <code>name</code> into <code>full_name</code> for old rows, in small batches, from a job or command, not inside the migration transaction.</li>
<li><strong>Switch reads</strong>: deploy code that reads <code>full_name</code> (with a fallback to <code>name</code> if you are cautious), still writing both.</li>
<li><strong>Stop writing the old column</strong>: deploy code that only touches <code>full_name</code>. Keep <code>name</code> around for one more release so a rollback still works.</li>
<li><strong>Contract</strong>: drop <code>name</code> in a later release, and only then make <code>full_name</code> NOT NULL if you need that.</li>
</ol>
<p>Yes, that is up to five deploys for one rename. That is the price of never having an outage window, and it pays for itself the first time you need to roll back.</p>
<h2>Implementation in Laravel</h2>
<h3>Phase 1: expand</h3>
<p>Keep the migration tiny and additive. No data copying here.</p>
<pre><code class="language-php">use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;string('full_name')-&gt;nullable()-&gt;after('name');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;dropColumn('full_name');
        });
    }
};
</code></pre>
<p>The column is nullable on purpose. If you add <code>NOT NULL</code> with no default, old code that inserts users without <code>full_name</code> will fail on the very next signup.</p>
<h3>Phase 2: dual write in the model</h3>
<p>The cleanest place to dual write is a model event or a mutator, so every code path (controllers, imports, Filament forms, seeders) gets it for free.</p>
<pre><code class="language-php">namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected $fillable = ['name', 'full_name', 'email', 'password'];

    protected static function booted(): void
    {
        static::saving(function (User $user): void {
            if ($user-&gt;isDirty('name') &amp;&amp; ! $user-&gt;isDirty('full_name')) {
                $user-&gt;full_name = $user-&gt;name;
            }

            if ($user-&gt;isDirty('full_name') &amp;&amp; ! $user-&gt;isDirty('name')) {
                $user-&gt;name = $user-&gt;full_name;
            }
        });
    }
}
</code></pre>
<p>Model events do not fire for query builder updates like <code>User::where(...)-&gt;update([...])</code> or raw SQL. Search the codebase for mass updates on that table and fix them by hand, or the backfill will keep finding stale rows.</p>
<h3>Phase 3: backfill in batches</h3>
<p>Run the backfill as an Artisan command or a queued job, not inside the migration. Migrations run inside the deploy and, on PostgreSQL, inside a transaction; a twenty-minute migration blocks the deploy and holds locks for twenty minutes.</p>
<pre><code class="language-php">namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class BackfillUserFullName extends Command
{
    protected $signature = 'backfill:user-full-name {--batch=1000} {--sleep=100}';

    protected $description = 'Copy users.name into users.full_name in small batches';

    public function handle(): int
    {
        $batch = (int) $this-&gt;option('batch');
        $sleepMs = (int) $this-&gt;option('sleep');
        $total = 0;

        DB::table('users')
            -&gt;whereNull('full_name')
            -&gt;select(['id'])
            -&gt;chunkById($batch, function ($rows) use (&amp;$total, $sleepMs): void {
                $ids = $rows-&gt;pluck('id')-&gt;all();

                $updated = DB::table('users')
                    -&gt;whereIn('id', $ids)
                    -&gt;whereNull('full_name')
                    -&gt;update(['full_name' =&gt; DB::raw('name')]);

                $total += $updated;
                $this-&gt;info("Updated {$total} rows, last id {$ids[array_key_last($ids)]}");

                usleep($sleepMs * 1000);
            });

        $this-&gt;info("Done. {$total} rows backfilled.");

        return self::SUCCESS;
    }
}
</code></pre>
<p>A few details matter here:</p>
<ul>
<li><code>chunkById</code> pages with <code>WHERE id &gt; ? ORDER BY id LIMIT n</code> (keyset pagination). Plain <code>chunk()</code> uses <code>OFFSET</code>, which gets slower on every page and, worse, skips rows when the <code>WHERE</code> condition changes under it. Because we are updating exactly the column we filter on, <code>chunk()</code> would silently skip half the table.</li>
<li>The update repeats <code>whereNull('full_name')</code>, so the command is idempotent. If a user edits their profile mid-backfill, the dual write already set <code>full_name</code> and we do not overwrite it. You can kill and restart the command safely.</li>
<li>Each batch is its own short statement, so locks are held for milliseconds, and replicas can keep up. The sleep gives replication and other traffic room to breathe. Watch replica lag while it runs and tune <code>--batch</code> and <code>--sleep</code> accordingly.</li>
<li><code>DB::raw('name')</code> makes the database copy the value directly, with no round trip through PHP.</li>
</ul>
<p>If the table is huge and the IDs are dense, an even simpler loop is to walk fixed ID ranges (<code>WHERE id BETWEEN 1 AND 1000</code>, then 1001 to 2000 and so on). That avoids the extra <code>SELECT</code> per batch and makes progress trivially resumable: just log the last range you finished. The challenge below asks you to build exactly that range splitter.</p>
<h3>Phase 4 and 5: switch reads, then stop writing the old column</h3>
<p>Switch reads with an accessor fallback for one release. It costs nothing and protects you from rows the backfill missed.</p>
<pre><code class="language-php">namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected function displayName(): Attribute
    {
        return Attribute::get(fn (): string =&gt; $this-&gt;full_name ?? $this-&gt;name ?? '');
    }
}
</code></pre>
<p>Before phase 5, verify that the backfill really finished: <code>SELECT COUNT(*) FROM users WHERE full_name IS NULL AND name IS NOT NULL</code> must be zero. Then remove every write to <code>name</code>. Grep for it in Blade views, API resources, exports, raw SQL and <code>$fillable</code>.</p>
<h3>Phase 6: contract</h3>
<p>Only after the release that stopped writing <code>name</code> has been live long enough that you will not roll back past it, drop the column. Tightening <code>full_name</code> to <code>NOT NULL</code> can go in the same migration.</p>
<pre><code class="language-php">use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;string('full_name')-&gt;nullable(false)-&gt;change();
            $table-&gt;dropColumn('name');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;string('name')-&gt;nullable()-&gt;after('id');
            $table-&gt;string('full_name')-&gt;nullable()-&gt;change();
        });
    }
};
</code></pre>
<p>Notice that <code>down()</code> cannot restore the data in <code>name</code>. That is normal for a contract step and the reason it ships last, after you are confident. Since Laravel 11, <code>-&gt;change()</code> requires you to repeat every attribute you want to keep (length, default, comment), because modifiers that you omit are dropped.</p>
<h2>Deploy Order Cheat Sheet</h2>
<p>The rule of thumb: <strong>additive schema changes go before the code that needs them; destructive schema changes go after the code that stopped needing them.</strong></p>
<table>
<thead>
<tr>
<th>Change</th>
<th>Migration runs</th>
<th>Code change</th>
</tr>
</thead>
<tbody>
<tr>
<td>Add nullable column or new table</td>
<td>Before the code deploy</td>
<td>Next release starts using it</td>
</tr>
<tr>
<td>Add index</td>
<td>Before, ideally online</td>
<td>None</td>
</tr>
<tr>
<td>Rename column</td>
<td>Never directly; expand/contract</td>
<td>Spread across releases</td>
</tr>
<tr>
<td>Drop column</td>
<td>After code stopped reading and writing it</td>
<td>Previous release</td>
</tr>
<tr>
<td>Add NOT NULL</td>
<td>After backfill is complete</td>
<td>Code always writes the value</td>
</tr>
<tr>
<td>Change type</td>
<td>New column plus backfill</td>
<td>Like a rename</td>
</tr>
</tbody>
</table>
<p>Also restart queue workers in every deploy (<code>php artisan queue:restart</code>, or <code>php artisan horizon:terminate</code> with Horizon); they are the slowest part of your fleet to pick up new code.</p>
<h2>Testing the Backfill</h2>
<p>You cannot unit test downtime, but you can test that each phase does what it claims: that the backfill is idempotent, does not overwrite rows the dual write already handled, and processes everything across multiple batches. A Pest feature test with <code>RefreshDatabase</code> covers it.</p>
<pre><code class="language-php">use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

it('backfills full_name in batches without overwriting newer values', function () {
    User::factory()-&gt;count(25)-&gt;create(['full_name' =&gt; null]);
    DB::table('users')-&gt;update(['full_name' =&gt; null]);

    $edited = User::factory()-&gt;create(['name' =&gt; 'Old Name']);
    DB::table('users')-&gt;where('id', $edited-&gt;id)-&gt;update(['full_name' =&gt; 'New Name']);

    $this-&gt;artisan('backfill:user-full-name', ['--batch' =&gt; 10, '--sleep' =&gt; 0])
        -&gt;assertSuccessful();

    expect(DB::table('users')-&gt;whereNull('full_name')-&gt;count())-&gt;toBe(0)
        -&gt;and(DB::table('users')-&gt;where('id', $edited-&gt;id)-&gt;value('full_name'))-&gt;toBe('New Name');

    $this-&gt;artisan('backfill:user-full-name', ['--batch' =&gt; 10, '--sleep' =&gt; 0])
        -&gt;assertSuccessful();

    expect(DB::table('users')-&gt;whereColumn('full_name', '!=', 'name')-&gt;count())-&gt;toBe(1);
});
</code></pre>
<p>The batch size of 10 against 26 rows forces at least three chunks, which is where pagination bugs hide. In CI, also run <code>php artisan migrate:rollback --step=1</code> after each expand migration to prove the <code>down()</code> method works; a rollback that has never been executed is a rollback that does not work.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Backfilling inside the migration.</strong> It runs during the deploy, blocks the pipeline and holds locks. Keep migrations to schema changes; move data to commands or jobs.</li>
<li><strong>Using <code>chunk()</code> while modifying the filter column.</strong> Offset pagination skips rows. Use <code>chunkById()</code> or <code>lazyById()</code>.</li>
<li><strong>Adding <code>NOT NULL</code> in the expand step.</strong> Old code that does not know the column will fail on insert. Add constraints only after the backfill and after every writer sets the value.</li>
<li><strong>Forgetting non-Eloquent writers.</strong> Query builder updates, raw SQL, other services sharing the database, and database triggers all bypass model events.</li>
<li><strong>Dropping too early.</strong> If you drop in the same release that stopped using the column, a rollback brings back code that needs it. Wait one release.</li>
<li><strong>Long-running <code>ALTER TABLE</code> on hot tables.</strong> Check whether your engine can do the change online. For MySQL, tools like <code>gh-ost</code> or <code>pt-online-schema-change</code> exist for changes that are not instant.</li>
<li><strong>Not restarting workers.</strong> Queue workers keep old code alive long after the web tier switched.</li>
</ul>
<h2>When to Use It and When Not To</h2>
<p>Use expand/contract when:</p>
<ul>
<li>You deploy to more than one server, use rolling or blue/green deploys, or run long-lived queue workers.</li>
<li>The table is large enough that a full rewrite or single big <code>UPDATE</code> would lock it noticeably.</li>
<li>You need the option to roll back code without restoring a database backup.</li>
<li>Several services or apps read the same table and cannot all deploy at once.</li>
</ul>
<p>Skip it, or shortcut it, when:</p>
<ul>
<li>It is a brand-new table nobody reads yet: just change it.</li>
<li>The app is small, single-server, and a two-minute <code>php artisan down</code> window is acceptable to your users.</li>
<li>The change is purely additive (new nullable column, new table, new index created online). That is already the expand step; there is nothing to contract.</li>
</ul>
<p>The mental shift is the important part: stop thinking of a migration as one event, and start thinking of it as a sequence of states where every state is compatible with the code before it and the code after it.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Offset vs Cursor Pagination: Why Page 4,000 Is Slow and How Keyset Queries Fix It</title>
            <link>https://cslant.com/tips/offset-vs-cursor-pagination-why-page-4000-is-slow-and-how-keyset-queries-fix-it</link>
            <guid isPermaLink="false">https://cslant.com/tips/offset-vs-cursor-pagination-why-page-4000-is-slow-and-how-keyset-queries-fix-it</guid>
                        <pubDate>Tue, 27 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>OFFSET makes deep pages slow and shuffles rows when data changes. Learn keyset queries, composite cursors, Laravel cursorPaginate and cursor-based API design.</description>
                                    <content:encoded><![CDATA[<p>The mobile team files a ticket: "the activity feed gets slower the more you scroll". On page 1 the API answers in 15 ms. Somebody who scrolled for a long time hits page 4,000 and waits 2.8 seconds. Meanwhile the nightly export job that walks <code>?page=1..N</code> over 3 million orders has crept from 4 minutes to 40, and the support team reports that some orders appear twice in the export while others are missing.</p>
<p>All three symptoms have the same root cause: <code>LIMIT ... OFFSET ...</code>. Offset pagination is the default in every framework because it is easy to reason about, but it does work proportional to how deep you are, and it describes a position ("row number 80,000") instead of a place in the data ("after this exact row"). Keyset pagination, also called cursor pagination, fixes both problems.</p>
<h2>Why OFFSET Gets Slow</h2>
<p>The database cannot jump to row 200,000. It has no idea where row 200,000 is until it has produced the first 199,999 rows in the requested order. Even with a perfect index on the sort columns, the engine walks the index from the start, counts and discards <code>OFFSET</code> entries, then returns <code>LIMIT</code> rows.</p>
<pre><code class="language-sql">-- Page 1: fast
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;

-- Page 10,000: the server reads 200,020 index entries and throws 200,000 away
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;
</code></pre>
<p>Without a covering index it is worse: MySQL may fetch the full row for every skipped entry or fall back to a filesort. Run <code>EXPLAIN ANALYZE</code> on a deep page and you will see the "rows examined" number grow linearly with the offset while the result stays at 20 rows. Page 1 and page 10,000 return the same amount of data, but the second one costs ten thousand times more work.</p>
<p>There is also a correctness problem. An offset is relative to the current state of the table:</p>
<ul>
<li>A new post is inserted at the top while a user is on page 3. Everything shifts down by one, and the first row of page 4 is the last row they already saw on page 3. <strong>Duplicate.</strong></li>
<li>A post on page 2 is deleted. Everything shifts up by one, and a row that would have been the first item of page 4 is now the last item of page 3, which the user already loaded. <strong>Skipped.</strong></li>
</ul>
<p>In an admin table that is an annoyance; in an export or a feed it is a bug.</p>
<h2>How Keyset Pagination Works</h2>
<p>Instead of telling the database "skip N rows", you tell it "give me rows that come after this one". The client sends back the sort values of the last row it received, and the next query turns them into a <code>WHERE</code> clause:</p>
<pre><code class="language-sql">-- First page
SELECT id, title FROM posts
ORDER BY id DESC
LIMIT 20;

-- Next page: the client sends back the last id it saw, e.g. 981234
SELECT id, title FROM posts
WHERE id &lt; 981234
ORDER BY id DESC
LIMIT 20;
</code></pre>
<p>With an index on <code>id</code>, the engine does an index seek straight to <code>981234</code> and reads 20 entries. The cost is the same on page 1 and page 1,000,000: roughly <code>O(log n + limit)</code>.</p>
<p>It is also stable. Inserting a newer post does not change which rows are older than <code>981234</code>, and deleting a row on an earlier page does not move anything. Every row is returned exactly once, in order, as long as the sort key itself does not change.</p>
<p>Here is the full round trip, including the "fetch one extra row" trick that tells you whether a next page exists without running <code>COUNT(*)</code>:</p>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
participant C as Client
participant A as API
participant D as Database
C-&gt;&gt;A: GET posts limit 20
A-&gt;&gt;D: ORDER BY created_at, id LIMIT 21
D--&gt;&gt;A: 21 rows
A--&gt;&gt;C: 20 rows plus next_cursor
C-&gt;&gt;A: GET posts with cursor
A-&gt;&gt;D: WHERE created_at, id before cursor LIMIT 21
D--&gt;&gt;A: rows found by index seek
A--&gt;&gt;C: next 20 rows plus next_cursor</div></div>
<h2>Composite Cursors for Non-Unique Sort Columns</h2>
<p>Sorting by <code>id</code> alone is rare. Feeds sort by <code>created_at</code>, shops by <code>price</code>. None of these are unique: a bulk import can create 500 rows with the same <code>created_at</code> second. If your cursor is only <code>created_at &lt; '2026-10-20 08:15:00'</code>, every row that shares that timestamp and did not fit on the previous page is silently skipped. If you use <code>&lt;=</code>, they are repeated forever.</p>
<p>The fix is a <strong>tiebreaker</strong>: add a unique column (usually the primary key) as the last sort key, and make the cursor a tuple of all sort columns.</p>
<pre><code class="language-sql">CREATE INDEX posts_created_at_id_index ON posts (created_at, id);

-- Row constructor form (MySQL 8 and PostgreSQL can use the index for this range)
SELECT id, title, created_at FROM posts
WHERE (created_at, id) &lt; ('2026-10-20 08:15:00', 981234)
ORDER BY created_at DESC, id DESC
LIMIT 21;

-- Expanded form: same meaning, works everywhere, and also handles mixed directions
SELECT id, title, created_at FROM posts
WHERE created_at &lt; '2026-10-20 08:15:00'
   OR (created_at = '2026-10-20 08:15:00' AND id &lt; 981234)
ORDER BY created_at DESC, id DESC
LIMIT 21;
</code></pre>
<p>Three rules make this work:</p>
<ol>
<li>The <code>ORDER BY</code> must be fully deterministic: the column list ends with something unique.</li>
<li>The <code>WHERE</code> comparison uses the same columns in the same order and direction as the <code>ORDER BY</code>.</li>
<li>There is an index whose columns match that order, so the database seeks rather than scans. In InnoDB, a secondary index on <code>(created_at)</code> already carries the primary key, but declaring <code>(created_at, id)</code> explicitly makes the intent obvious and keeps the plan predictable.</li>
</ol>
<p>The row constructor form <code>(a, b) &lt; (x, y)</code> is concise and both MySQL 8 and PostgreSQL optimize it as a range. The expanded <code>OR</code> form says the same thing and is the one you need when directions are mixed, such as <code>ORDER BY price ASC, id DESC</code>: flip the operator per column (<code>price &gt; ? OR (price = ? AND id &lt; ?)</code>) and back it with a matching <code>(price ASC, id DESC)</code> index.</p>
<p>MySQL 8.0 added real descending indexes, so <code>(price ASC, id DESC)</code> can be scanned in one pass. On older versions mixed directions often meant a filesort.</p>
<h2>Laravel cursorPaginate</h2>
<p>Laravel ships keyset pagination out of the box. Swap <code>paginate()</code> for <code>cursorPaginate()</code> and make sure the ordering ends with a unique column:</p>
<pre><code class="language-php">namespace App\Http\Controllers\Api;

use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final class PostFeedController
{
    public function __invoke(Request $request): AnonymousResourceCollection
    {
        $perPage = min((int) $request-&gt;integer('per_page', 20), 100);

        $posts = Post::query()
            -&gt;select(['id', 'title', 'excerpt', 'created_at'])
            -&gt;where('status', 'published')
            -&gt;orderByDesc('created_at')
            -&gt;orderByDesc('id')
            -&gt;cursorPaginate($perPage)
            -&gt;withQueryString();

        return PostResource::collection($posts);
    }
}
</code></pre>
<p>Under the hood the paginator fetches <code>perPage + 1</code> rows, reads the order columns from the query, and builds the expanded <code>where ... or (... and ...)</code> condition from the decoded cursor. The cursor itself is a base64-encoded JSON object of the sort column values plus a flag that says whether it points forward or backward, and it travels in the <code>cursor</code> query string parameter. Returned through an API resource, the <code>meta</code> block contains <code>path</code>, <code>per_page</code>, <code>next_cursor</code> and <code>prev_cursor</code>, and <code>links.next</code> is a ready-to-call URL.</p>
<p>Notice what is missing: there is no <code>total</code>, no <code>last_page</code>, no <code>current_page</code>. The paginator never runs <code>COUNT(*)</code>, which on a large filtered table is often the slowest query of the whole request. <code>prev_cursor</code> works too, so "back" navigation is supported.</p>
<p>Things the framework expects from you:</p>
<ul>
<li>Every <code>orderBy</code> column must be selected and must not contain <code>NULL</code> values. Cursor values are compared with <code>&lt;</code> and <code>&gt;</code>, and SQL comparisons with <code>NULL</code> are never true.</li>
<li>Ordering by an expression only works if it is aliased and present in the <code>select</code>. Expressions with bound parameters are not supported.</li>
<li>The cursor is encoded, not encrypted or signed. A client can decode it, edit the values and send it back. That is harmless for public feeds because the query still applies your <code>where</code> filters, but do not put anything secret in the sort columns.</li>
</ul>
<h2>Rolling Your Own Cursor</h2>
<p>Sometimes you want control over the format: a signed cursor that cannot be tampered with, a short string for a mobile client, or the same pagination logic over a raw query builder or an Elasticsearch result. The pattern is small enough to write yourself. First, a value object that encodes and signs the tuple:</p>
<pre><code class="language-php">namespace App\Support\Pagination;

use InvalidArgumentException;

final class FeedCursor
{
    public function __construct(
        public readonly string $createdAt,
        public readonly int $id,
    ) {}

    public static function fromRow(object $row): self
    {
        return new self((string) $row-&gt;created_at, (int) $row-&gt;id);
    }

    public function encode(string $key): string
    {
        $payload = json_encode([$this-&gt;createdAt, $this-&gt;id], JSON_THROW_ON_ERROR);
        $body = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
        $sig = substr(hash_hmac('sha256', $body, $key), 0, 16);

        return $body . '.' . $sig;
    }

    public static function decode(string $cursor, string $key): self
    {
        [$body, $sig] = array_pad(explode('.', $cursor, 2), 2, '');

        if (! hash_equals(substr(hash_hmac('sha256', $body, $key), 0, 16), $sig)) {
            throw new InvalidArgumentException('Invalid cursor.');
        }

        $json = base64_decode(strtr($body, '-_', '+/'), true);
        $data = $json === false ? null : json_decode($json, true);

        if (! is_array($data) || count($data) !== 2 || ! is_int($data[1])) {
            throw new InvalidArgumentException('Malformed cursor.');
        }

        return new self((string) $data[0], $data[1]);
    }
}
</code></pre>
<p>In the query, decode the incoming cursor, apply the same expanded <code>created_at &lt; ? OR (created_at = ? AND id &lt; ?)</code> condition shown earlier, fetch <code>limit + 1</code> rows, and encode <code>FeedCursor::fromRow()</code> of the last kept row only when the extra row came back.</p>
<p>Truncating the signature to 16 hex characters keeps the cursor short and still makes forgery impractical. Rotating the key invalidates every outstanding cursor, so clients should treat <code>400 Invalid cursor</code> as "restart from page one".</p>
<h2>API Design With Cursors</h2>
<p>Keyset pagination changes the contract, so design the API around it rather than bolting a cursor onto a page-number API:</p>
<ul>
<li><strong>Make the cursor opaque.</strong> Document it as "a string you send back unchanged", so adding a sort column never breaks clients.</li>
<li><strong>Return <code>next_cursor</code> and <code>has_more</code>, or <code>null</code> at the end.</strong> That is all an infinite scroll or a sync job needs.</li>
<li><strong>Bind the cursor to the query.</strong> If the client changes <code>sort</code> or a filter, the old cursor is meaningless. Either reject it (store a hash of the filters inside the cursor) or document that filters must stay identical across pages.</li>
<li><strong>Cap <code>limit</code>.</strong> <code>min($perPage, 100)</code> stops one client from asking for 50,000 rows in a single request.</li>
<li><strong>Offer total counts separately, if at all.</strong> If the UI needs "1,234 results", serve an approximate count from a cached or estimated value, not a <code>COUNT(*)</code> on every page.</li>
<li><strong>Use cursors for sync endpoints.</strong> "Everything changed since X" is keyset on <code>(updated_at, id)</code>.</li>
</ul>
<h2>Testing Keyset Pagination</h2>
<p>The bugs live at the boundaries: duplicated timestamps, the last page, and data that changes between requests. A good test walks every page by following <code>links.next</code> until it is <code>null</code>, then checks that every row was seen exactly once. Seeding a block of rows with an identical <code>created_at</code> is what catches a missing tiebreaker.</p>
<pre><code class="language-php">use App\Models\Post;
use Illuminate\Support\Carbon;

it('walks every post exactly once even when timestamps collide', function () {
    $sameMoment = Carbon::parse('2026-10-20 08:00:00');

    Post::factory()-&gt;count(30)-&gt;create(['status' =&gt; 'published', 'created_at' =&gt; $sameMoment]);
    Post::factory()-&gt;count(25)-&gt;create(['status' =&gt; 'published']);

    $seen = [];
    $url = '/api/posts?per_page=7';

    while ($url !== null) {
        $response = $this-&gt;getJson($url)-&gt;assertOk();
        $seen = [...$seen, ...array_column($response-&gt;json('data'), 'id')];
        $url = $response-&gt;json('links.next');
    }

    expect($seen)-&gt;toHaveCount(55)
        -&gt;and(array_unique($seen))-&gt;toHaveCount(55)
        -&gt;and($seen)-&gt;toEqualCanonicalizing(Post::pluck('id')-&gt;all());
});
</code></pre>
<p>A second test worth having: load page 1, insert newer posts, load page 2 and assert the two pages share no ids. A <code>per_page</code> of 7 puts page boundaries inside the identical-timestamp group, so removing <code>orderByDesc('id')</code> fails the test at once.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>No unique tiebreaker.</strong> The most common bug. Rows silently disappear at page boundaries whenever the sort column has duplicates.</li>
<li><strong>Index does not match the order.</strong> Keyset is only fast if the database can seek. Check <code>EXPLAIN</code>: you want a <code>range</code> access on your composite index, not <code>filesort</code>.</li>
<li><strong>Nullable sort columns.</strong> <code>NULL</code> breaks comparisons. Use <code>COALESCE</code> into a sentinel and index that expression, or exclude <code>NULL</code> rows, or sort by a non-null column.</li>
<li><strong>Mutable sort keys.</strong> If you sort by <code>updated_at</code> and a row is updated while someone is paging, it can jump ahead of the cursor and be seen twice, or jump behind it and be missed. For sync jobs that is usually what you want; for feeds, sort by something immutable.</li>
<li><strong>Expecting "jump to page 57".</strong> Keyset only knows next and previous. If the product needs numbered pages, keep offset for that screen.</li>
<li><strong>Filtering after fetching.</strong> If you drop rows in PHP after the query (permission checks, soft visibility rules), pages come out short. Push those conditions into SQL.</li>
</ul>
<h2>When to Use Offset and When to Use Keyset</h2>
<p>Use <strong>offset</strong> (<code>paginate()</code> or <code>simplePaginate()</code>) when:</p>
<ul>
<li>the data set is small or users never go beyond a few pages, like an admin table with filters;</li>
<li>the UI needs page numbers, a total count, or "jump to page N";</li>
<li>the sort order is arbitrary and user-chosen across many columns, and you cannot index every combination.</li>
</ul>
<p>Use <strong>keyset</strong> (<code>cursorPaginate()</code> or a hand-written cursor) when:</p>
<ul>
<li>the table is large and people or jobs go deep: infinite scroll, activity feeds, notifications, chat history;</li>
<li>you export, sync or backfill a whole table in batches. For one-off jobs inside Laravel, <code>lazyById()</code> and <code>chunkById()</code> already use keyset internally;</li>
<li>rows are inserted or deleted while clients are paging and duplicates or gaps are unacceptable;</li>
<li>the API is public and you want predictable latency regardless of how deep a client goes.</li>
</ul>
<p>A common middle ground: admin screens keep offset with numbered pages, while the public API and every background job use cursors. The query that took 2.8 seconds on page 4,000 becomes the same 15 ms seek as page 1, and the export stops losing orders.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Password Hashing Done Right: bcrypt vs Argon2id, Cost Tuning and Rehash on Login</title>
            <link>https://cslant.com/tips/password-hashing-done-right-bcrypt-vs-argon2id-cost-tuning-and-rehash-on-login</link>
            <guid isPermaLink="false">https://cslant.com/tips/password-hashing-done-right-bcrypt-vs-argon2id-cost-tuning-and-rehash-on-login</guid>
                        <pubDate>Mon, 26 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>Security</category>
                                    <description>MD5 and SHA-256 crack in hours once a dump leaks. Pick bcrypt or Argon2id, tune the cost on real hardware and upgrade old hashes on login in PHP and Laravel.</description>
                                    <content:encoded><![CDATA[<p>A client hands you a legacy app to "modernise". You open the <code>users</code> table and every password column is a 32-character hex string: unsalted MD5. Two weeks later a backup bucket turns out to have been public. Anyone holding that dump can recover most of those passwords in an afternoon with one consumer GPU and a public wordlist, and because people reuse passwords, the damage does not stop at your app. Their email and bank logins are next.</p>
<p>Password storage is one of the few places where "fast" is a bug. The fix is not complicated: a slow, salted, memory-hard password hash, a cost you actually tuned, and a login flow that silently upgrades old hashes. Here is each piece in PHP 8.4 and Laravel 13, plus migrating an existing user base.</p>
<h2>Why MD5, SHA-1 and SHA-256 Are the Wrong Tool</h2>
<p>General-purpose hashes are designed to be fast. That is exactly what you want for checksums and HMACs, and exactly what an attacker wants when guessing passwords offline. Once the hashes leak, the attacker no longer talks to your rate limiter or your lockout policy. They run guesses on their own hardware as fast as the algorithm allows: tens of billions of MD5 guesses per second on one GPU, versus thousands for bcrypt.</p>
<p>Adding a salt (<code>sha256($salt . $password)</code>) stops precomputed rainbow tables and forces per-user work, but it does nothing about raw speed. Running SHA-256 a few thousand times in a loop is better, and that is basically what PBKDF2 does, but GPUs still parallelise it beautifully because it needs almost no memory.</p>
<p>A password hash has three jobs: a unique random salt per password, a tunable work factor so you can keep up with hardware, and ideally memory hardness so GPUs and ASICs cannot run thousands of guesses in parallel cheaply. bcrypt covers the first two. Argon2id covers all three.</p>
<h2>bcrypt vs Argon2id</h2>
<p><strong>bcrypt</strong> (1999) is the boring, battle-tested default. It is <code>PASSWORD_DEFAULT</code> in PHP and the default driver in Laravel. Its work factor is a cost exponent: cost 12 means 2^12 rounds of the expensive key setup, and every +1 doubles the time. PHP 8.4 raised the default bcrypt cost from 10 to 12, and Laravel's <code>BCRYPT_ROUNDS</code> default is also 12. bcrypt uses about 4 KiB of memory, which is small enough that GPUs can still attack it, just slowly.</p>
<p>bcrypt has one sharp edge: it only uses the first <strong>72 bytes</strong> of the input. Two passphrases that share the first 72 bytes produce the same hash. For normal passwords that does not matter. It does matter if you pre-concatenate things such as a username or a long pepper in front of the password, and it matters for very long UTF-8 passphrases where 72 bytes can be fewer than 30 characters.</p>
<p><strong>Argon2id</strong> won the Password Hashing Competition in 2015 and is the current OWASP first choice. You tune three knobs: memory (<code>memory_cost</code>, in KiB), iterations (<code>time_cost</code>) and parallelism (<code>threads</code>). Because every guess needs its own block of memory, an attacker with a GPU runs out of memory bandwidth long before running out of cores. It has no 72-byte limit.</p>
<p>The practical rule: for a new project where you control the servers, Argon2id with at least 19 MiB of memory (OWASP's minimum) is the better choice, and 64 MiB is a sensible default if your login traffic allows it. bcrypt at cost 12 or higher is still perfectly acceptable, and much better than anything fast. Do not use plain PBKDF2 unless a compliance regime such as FIPS forces it, and then use a high iteration count (OWASP suggests 600,000 for PBKDF2-HMAC-SHA256).</p>
<h2>The Verify-and-Rehash Flow</h2>
<p>The key idea that makes upgrades painless: you can only re-hash a password when you have the plaintext, and the only time you have it is during a successful login. So every successful login is a chance to check whether the stored hash still matches your current policy and replace it if not.</p>
<div class="page-mermaid"><div class="mermaid">graph TD
A[User submits password] --&gt; B[Load stored hash]
B --&gt; C{Legacy MD5 hash?}
C --&gt;|yes| D[Compare with hash_equals]
C --&gt;|no| E[password_verify]
D --&gt;|match| F[Hash again with current policy]
E --&gt;|match| G{Needs rehash?}
G --&gt;|yes| F
G --&gt;|no| H[Log the user in]
F --&gt; H</div></div>
<p>The legacy branch handles hashes from before your migration. The normal branch handles cost changes and algorithm changes (bcrypt to Argon2id) with exactly the same code, because the hash string itself records the algorithm and its parameters.</p>
<h2>Doing It in Plain PHP 8.4</h2>
<p>PHP's password API does the heavy lifting. Never call <code>crypt()</code> directly, never generate your own salt, and never compare hashes with <code>===</code>.</p>
<pre><code class="language-php">// Registration: let PHP pick the algorithm, salt and encoding.
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);

// Store $hash in a VARCHAR(255) column. It looks like:
// $2y$12$&lt;22 chars of salt&gt;&lt;31 chars of hash&gt;

// Login: never compare hashes yourself.
if (password_verify($plainPassword, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
        // persist the new $hash for this user
    }
    // log the user in
}
</code></pre>
<p><code>password_needs_rehash()</code> returns true if the stored hash uses a different algorithm or different options than the ones you pass. Raise the cost, deploy, and every user is upgraded the next time they log in.</p>
<p>How high should the cost be? The honest answer is "as high as your login latency and CPU budget allow". A good target is roughly 250 to 500 ms per hash on your production hardware. Measure instead of guessing:</p>
<pre><code class="language-php">// Run this on production-like hardware, not on your laptop.
$timeTarget = 0.25; // seconds per hash you are willing to spend
$cost = 9;

do {
    $cost++;
    $start = microtime(true);
    password_hash('benchmark-password', PASSWORD_BCRYPT, ['cost' =&gt; $cost]);
    $elapsed = microtime(true) - $start;
} while ($elapsed &lt; $timeTarget &amp;&amp; $cost &lt; 16);

printf("Use cost %d (%.3f s per hash)%s", $cost, $elapsed, PHP_EOL);
</code></pre>
<p>Pair a high cost with rate limiting (Laravel's <code>RateLimiter</code> or <code>throttle</code> middleware) so password hashing does not become a cheap way to burn your CPU.</p>
<p>Argon2id goes through the same API:</p>
<pre><code class="language-php">if (! defined('PASSWORD_ARGON2ID')) {
    throw new RuntimeException('PHP was built without Argon2 support.');
}

$options = [
    'memory_cost' =&gt; 65536, // KiB, so 64 MiB per hash
    'time_cost' =&gt; 4,       // passes over that memory
    'threads' =&gt; 1,         // libsodium builds only support 1
];

$hash = password_hash($plainPassword, PASSWORD_ARGON2ID, $options);
// $argon2id$v=19$m=65536,t=4,p=1$&lt;salt&gt;$&lt;hash&gt;

$valid = password_verify($plainPassword, $hash);
$stale = password_needs_rehash($hash, PASSWORD_ARGON2ID, $options);
</code></pre>
<p><code>PASSWORD_ARGON2ID</code> only exists if PHP was compiled with libargon2 or libsodium. Official Docker images and most distro packages have it, but check in CI rather than finding out in production. Memory cost is per hash, so 64 MiB times 50 concurrent logins is 3.2 GiB. Size your PHP-FPM workers accordingly.</p>
<h2>Laravel 13: Hash Facade, Config and Rehash on Login</h2>
<p>Laravel wraps the same functions behind the <code>Hash</code> facade and reads its policy from <code>config/hashing.php</code>. Publish it with <code>php artisan config:publish hashing</code> if your app does not have it yet.</p>
<pre><code class="language-php">// config/hashing.php (trimmed)
return [
    'driver' =&gt; env('HASH_DRIVER', 'bcrypt'), // or 'argon2id'

    'bcrypt' =&gt; [
        'rounds' =&gt; env('BCRYPT_ROUNDS', 12),
        'verify' =&gt; env('HASH_VERIFY', true),
    ],

    'argon' =&gt; [
        'memory' =&gt; env('ARGON_MEMORY', 65536),
        'threads' =&gt; env('ARGON_THREADS', 1),
        'time' =&gt; env('ARGON_TIME', 4),
        'verify' =&gt; env('HASH_VERIFY', true),
    ],

    'rehash_on_login' =&gt; true,
];
</code></pre>
<p>Switching <code>HASH_DRIVER</code> to <code>argon2id</code> does not break existing users: Laravel can still verify old bcrypt hashes, and <code>rehash_on_login</code> upgrades them as people log in.</p>
<p>The default <code>User</code> model casts <code>password</code> to <code>hashed</code>, so assigning a plaintext value hashes it automatically and assigning an existing hash leaves it alone. Since Laravel 11, <code>Auth::attempt()</code> calls the user provider's <code>rehashPasswordIfRequired()</code> after a successful check, so cost and driver upgrades need zero code from you. If you wrote a custom <code>UserProvider</code>, implement that method or the upgrade silently never happens.</p>
<p>What Laravel cannot do for you is migrate hashes it does not understand, like the raw MD5 column from the intro. Do it in two steps. First, a one-off command wraps every MD5 value in bcrypt, so no fast hash stays at rest even for users who never log in again:</p>
<pre><code class="language-php">namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;

final class WrapLegacyPasswords extends Command
{
    protected $signature = 'passwords:wrap-legacy';

    protected $description = 'Replace raw MD5 hashes with bcrypt(md5) so no fast hash stays at rest';

    public function handle(): int
    {
        User::query()
            -&gt;whereNotNull('legacy_md5')
            -&gt;chunkById(500, function (Collection $users): void {
                foreach ($users as $user) {
                    $user-&gt;forceFill([
                        'password' =&gt; Hash::make($user-&gt;legacy_md5),
                        'password_is_wrapped' =&gt; true,
                        'legacy_md5' =&gt; null,
                    ])-&gt;save();
                }
            });

        $this-&gt;info('Legacy hashes wrapped.');

        return self::SUCCESS;
    }
}
</code></pre>
<p>Then, at login, users with a wrapped hash are checked against <code>bcrypt(md5(password))</code> and unwrapped into a normal hash:</p>
<pre><code class="language-php">namespace App\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

final class LoginController
{
    public function __invoke(Request $request): RedirectResponse
    {
        $credentials = $request-&gt;validate([
            'email' =&gt; ['required', 'email'],
            'password' =&gt; ['required', 'string'],
        ]);

        $this-&gt;unwrapLegacyHash($credentials['email'], $credentials['password']);

        // Auth::attempt() verifies the hash and, with rehash_on_login enabled,
        // re-hashes it when the cost or algorithm in config/hashing.php changed.
        if (! Auth::attempt($credentials, $request-&gt;boolean('remember'))) {
            throw ValidationException::withMessages([
                'email' =&gt; __('auth.failed'),
            ]);
        }

        $request-&gt;session()-&gt;regenerate();

        return redirect()-&gt;intended('/dashboard');
    }

    private function unwrapLegacyHash(string $email, string $password): void
    {
        $user = User::query()-&gt;where('email', $email)-&gt;first();

        if ($user === null || ! $user-&gt;password_is_wrapped) {
            return;
        }

        // Stored value is bcrypt(md5(password)) from the one-time migration.
        if (Hash::check(md5($password), $user-&gt;password)) {
            $user-&gt;forceFill([
                'password' =&gt; $password, // the 'hashed' cast hashes it
                'password_is_wrapped' =&gt; false,
            ])-&gt;save();
        }
    }
}
</code></pre>
<h2>Testing the Upgrade Path</h2>
<p>Test the behaviour that matters: users can still log in after a migration, the stored hash actually changes, and failed logins change nothing. Keep tests fast with a low cost: the Laravel skeleton's <code>phpunit.xml</code> already sets <code>BCRYPT_ROUNDS=4</code>.</p>
<pre><code class="language-php">use App\Models\User;
use Illuminate\Support\Facades\Hash;

it('unwraps a legacy md5 password on the first successful login', function () {
    $user = User::factory()-&gt;create([
        'password' =&gt; Hash::make(md5('old-secret-123')),
        'password_is_wrapped' =&gt; true,
    ]);

    $this-&gt;post('/login', ['email' =&gt; $user-&gt;email, 'password' =&gt; 'old-secret-123'])
        -&gt;assertRedirect('/dashboard');

    $user-&gt;refresh();

    expect($user-&gt;password_is_wrapped)-&gt;toBeFalse()
        -&gt;and(Hash::check('old-secret-123', $user-&gt;password))-&gt;toBeTrue()
        -&gt;and(Hash::check(md5('old-secret-123'), $user-&gt;password))-&gt;toBeFalse();

    $this-&gt;assertAuthenticatedAs($user);
});

it('rehashes on login after the bcrypt cost goes up', function () {
    $user = User::factory()-&gt;create([
        'password' =&gt; password_hash('secret-pass-1', PASSWORD_BCRYPT, ['cost' =&gt; 4]),
    ]);

    config(['hashing.bcrypt.rounds' =&gt; 5]);
    Hash::forgetDrivers();

    $this-&gt;post('/login', ['email' =&gt; $user-&gt;email, 'password' =&gt; 'secret-pass-1'])
        -&gt;assertRedirect('/dashboard');

    expect(Hash::info($user-&gt;refresh()-&gt;password)['options']['cost'])-&gt;toBe(5);
});
</code></pre>
<p><code>Hash::forgetDrivers()</code> clears the cached hasher so the new <code>rounds</code> value is picked up within the same test. The second test is the one that protects you from a custom provider quietly breaking rehash-on-login.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Comparing hashes with <code>==</code> or <code>===</code>.</strong> Use <code>password_verify()</code> or <code>Hash::check()</code>. For legacy MD5 comparisons, use <code>hash_equals()</code> so the comparison is constant-time, and never use <code>==</code>, which treats strings like <code>"0e123..."</code> as numbers.</li>
<li><strong>Too-short columns.</strong> bcrypt is 60 characters and Argon2id hashes are around 97 with default options and grow with the parameters. Use <code>VARCHAR(255)</code> so a future algorithm does not get truncated.</li>
<li><strong>Hashing on the client.</strong> If the browser sends <code>sha256(password)</code>, that hash <em>is</em> the password from the server's point of view. Use HTTPS and hash on the server.</li>
<li><strong>Peppers that break bcrypt.</strong> Prepending a long secret pushes the real password past 72 bytes. If you want a pepper, apply it as <code>hash_hmac('sha256', $password, $pepper)</code> and base64-encode the output before bcrypt, and plan for pepper rotation.</li>
<li><strong>Forgetting the cost in tests.</strong> Without <code>BCRYPT_ROUNDS=4</code>, a test suite that creates thousands of users spends minutes hashing.</li>
<li><strong>Leaving MD5 values at rest.</strong> A lazy "upgrade on login" migration keeps fast hashes forever for inactive users. Wrap them all immediately, as shown above.</li>
</ul>
<h2>When to Use What (and When Not To)</h2>
<ul>
<li><strong>New Laravel app:</strong> keep the default bcrypt at cost 12 or move to Argon2id if PHP has it and you have the memory. Either way, leave <code>rehash_on_login</code> on.</li>
<li><strong>High-traffic login or tight memory:</strong> bcrypt is predictable and cheap on RAM. Raise the cost over time instead of switching algorithms.</li>
<li><strong>Inheriting MD5/SHA-1 hashes:</strong> wrap them in bcrypt now, unwrap on login, and force resets for whatever is left after a few months.</li>
<li><strong>API keys and long random tokens:</strong> do not use a password hash. They already have high entropy, so a single SHA-256 is fine, which is exactly how Sanctum stores personal access tokens. Slow hashes are for low-entropy secrets humans choose.</li>
</ul>
<p>The short version: never store anything fast, let the platform pick salts and encodings, tune the cost on real hardware, and treat every successful login as a free migration step.</p>]]></content:encoded>
                                </item>
                <item>
            <title>JavaScript Event Loop: Microtasks vs Macrotasks, Predicting Output Order, and Starving the Loop</title>
            <link>https://cslant.com/tips/javascript-event-loop-microtasks-vs-macrotasks-predicting-output-order-and-starving-the-loop</link>
            <guid isPermaLink="false">https://cslant.com/tips/javascript-event-loop-microtasks-vs-macrotasks-predicting-output-order-and-starving-the-loop</guid>
                        <pubDate>Sun, 25 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>JavaScript</category>
                                    <description>Sync code, then all microtasks, then one macrotask. Use that rule to predict Promise vs setTimeout order and to stop async loops from freezing Node.</description>
                                    <content:encoded><![CDATA[<p>A teammate ships a "harmless" refactor: an export endpoint now builds a 200k-row report with a chain of <code>async</code> helpers instead of a plain loop. Nothing is awaited on the network, everything is in memory. Within an hour the health check starts timing out, p99 latency on <em>unrelated</em> endpoints jumps from 40 ms to 6 seconds, and the Kubernetes liveness probe restarts the pod. CPU looks busy but not pegged. The code has no infinite loop, no deadlock, and every function returns.</p>
<p>The culprit is the event loop. The refactor turned one long synchronous task into hundreds of thousands of chained microtasks, and in JavaScript microtasks always run before the loop is allowed to look at timers, sockets or the next HTTP request. If you understand exactly what runs when, bugs like this become obvious and output-order interview puzzles become boring. Here is the model I use.</p>
<h2>One Thread, One Call Stack</h2>
<p>JavaScript runs your code on a single thread with a single call stack. When you call a function, a frame is pushed; when it returns, the frame is popped. While that stack is non-empty, <em>nothing else</em> can run: no timer callback, no promise reaction, no click handler, no incoming request in Node. Every "concurrent" thing in JavaScript is really a queue of callbacks waiting for the stack to be empty.</p>
<p>The host (the browser or Node's libuv) does the actual waiting for timers, sockets and files on other threads or in the kernel. When something finishes, the host does not interrupt your code. It puts a callback into a queue. The event loop is just the policy that decides which queue gets served next once the stack is empty.</p>
<p>There are two families of queues that matter:</p>
<ul>
<li><strong>Macrotasks</strong> (the spec just calls them <em>tasks</em>): <code>setTimeout</code>, <code>setInterval</code>, <code>setImmediate</code> (Node), I/O callbacks, <code>MessageChannel</code> messages, UI events. The loop takes <strong>one</strong> of these per turn.</li>
<li><strong>Microtasks</strong>: promise reactions (<code>.then</code>, <code>.catch</code>, <code>.finally</code>, and the continuation after <code>await</code>), <code>queueMicrotask</code>, and <code>MutationObserver</code> callbacks in browsers. After every macrotask, and after the initial script, the loop drains <strong>all</strong> of them, including microtasks queued while draining.</li>
</ul>
<p>Node adds one more queue in front: <code>process.nextTick</code> callbacks, which are drained before promise microtasks.</p>
<h2>How the Loop Picks the Next Callback</h2>
<p>This is the whole algorithm, simplified enough to predict output and accurate enough to debug production:</p>
<div class="page-mermaid"><div class="mermaid">flowchart TD
    A[Run synchronous code until the call stack is empty] --&gt; B[Drain nextTick queue - Node only]
    B --&gt; C[Drain microtask queue: promises, queueMicrotask]
    C --&gt; D{More microtasks queued?}
    D --&gt;|yes| C
    D --&gt;|no| E[Browser may render a frame]
    E --&gt; F[Take ONE macrotask: timer, I/O, message]
    F --&gt; A</div></div>
<p>The key asymmetry is in steps C and D versus step F. Microtasks are drained <em>until the queue is empty</em>. Macrotasks are taken <em>one at a time</em>, with a full microtask drain after each. Browsers get a chance to render only between macrotasks, never in the middle of a microtask drain.</p>
<p>In Node, step F is more detailed because libuv cycles through phases (timers, pending callbacks, poll for I/O, check for <code>setImmediate</code>, close callbacks), but since Node 11 the rule that matters matches browsers: after <strong>each</strong> individual timer or immediate callback, Node drains <code>nextTick</code> and then the microtask queue.</p>
<h2>Predicting Output Order</h2>
<p>With that model, the classic puzzles are mechanical. Walk the script top to bottom, write sync output immediately, and put every callback in its queue.</p>
<pre><code class="language-js">console.log('A');

setTimeout(() =&gt; console.log('B'), 0);

Promise.resolve().then(() =&gt; console.log('C'));

queueMicrotask(() =&gt; console.log('D'));

console.log('E');

// Output: A E C D B
</code></pre>
<p><code>A</code> and <code>E</code> are synchronous. <code>C</code> and <code>D</code> are microtasks, queued in that order, so they run in that order as soon as the script finishes. <code>B</code> is a macrotask and has to wait for the microtask queue to be empty, even with a delay of <code>0</code> (Node clamps it to 1 ms anyway, and browsers clamp deeply nested timers to at least 4 ms).</p>
<p><code>async</code>/<code>await</code> is just promises with nicer syntax. The body of an <code>async</code> function runs synchronously until the first <code>await</code>. Everything after the <code>await</code> is scheduled as a microtask once the awaited value settles:</p>
<pre><code class="language-js">async function load() {
  console.log(1);
  await null;            // everything below becomes a microtask
  console.log(2);
}

console.log(0);
load();
Promise.resolve().then(() =&gt; console.log(3));
console.log(4);

// Output: 0 1 4 2 3
</code></pre>
<p><code>1</code> prints synchronously inside <code>load()</code>. <code>await null</code> wraps <code>null</code> in a resolved promise and queues the continuation. Then <code>.then</code> queues <code>3</code>. <code>4</code> prints, the stack empties, and the microtasks run in FIFO order: <code>2</code>, then <code>3</code>. Before V8 7.2 an <code>await</code> on a native promise took three microtask ticks instead of one, so very old Node versions could print <code>3</code> before <code>2</code>. If code correctness depends on that kind of tick counting, the code is wrong, not the engine.</p>
<p>The third rule people miss: microtasks queued <em>inside</em> a macrotask run before the <em>next</em> macrotask.</p>
<pre><code class="language-js">setTimeout(() =&gt; {
  console.log('t1');
  Promise.resolve().then(() =&gt; console.log('p1'));
}, 0);

setTimeout(() =&gt; console.log('t2'), 0);

// Output (Node 11+ and browsers): t1 p1 t2
</code></pre>
<p>Node versions before 11 ran all expired timers first and drained microtasks afterwards, printing <code>t1 t2 p1</code>. That changed to match browsers, and it is one more reason to never write logic that depends on how many timers fire per phase.</p>
<p>Finally, Node's own queues:</p>
<pre><code class="language-js">// main.cjs (CommonJS)
Promise.resolve().then(() =&gt; console.log('promise'));
process.nextTick(() =&gt; console.log('nextTick'));
setImmediate(() =&gt; console.log('immediate'));
setTimeout(() =&gt; console.log('timeout'), 0);

// Typical output: nextTick promise timeout immediate
// (timeout vs immediate order is NOT guaranteed in the main module)
</code></pre>
<p><code>nextTick</code> beats promises because Node drains the tick queue first. <code>setTimeout(fn, 0)</code> versus <code>setImmediate</code> from the main module depends on how long process startup took relative to the 1 ms timer threshold, so it can flip between runs. Inside an I/O callback, <code>setImmediate</code> always fires first because the check phase comes right after poll. Also note: in an ES module, the top-level code itself runs as part of a promise job, so the <code>promise</code> vs <code>nextTick</code> order you observe there can differ from CommonJS. Another reason not to rely on it.</p>
<h2>Starving the Loop</h2>
<p>Because the microtask queue is drained until empty, a microtask that keeps scheduling microtasks blocks the loop forever, even though each individual callback is tiny:</p>
<pre><code class="language-js">// This never lets the timer fire: the microtask queue is never empty.
setTimeout(() =&gt; console.log('you will never see me'), 0);

function spin() {
  Promise.resolve().then(spin);
}
spin();
</code></pre>
<p>The call stack never grows, so you do not get a <code>RangeError: Maximum call stack size exceeded</code>. You just get a frozen tab or a Node process that stops answering requests while CPU sits at 100% on one core. Recursive <code>process.nextTick</code> does the same thing, even more aggressively, because it starves promises too.</p>
<p>The production incident from the intro is the softer version of this. Each <code>await</code> on an already-resolved value costs one microtask. A loop of 200k awaited helpers is 200k+ microtasks drained back to back, and from the event loop's point of view that is one enormous uninterruptible block. The fix is not "add more <code>await</code>". This does nothing for fairness:</p>
<pre><code class="language-js">// Looks like yielding, but is NOT: await on a resolved promise is a microtask,
// so the loop never reaches the timers or poll phase.
for (const row of hugeArray) {
  transform(row);
  await Promise.resolve();
}
</code></pre>
<p>To let the rest of the world run, you must yield a <strong>macrotask</strong>: <code>setImmediate</code> in Node, <code>setTimeout(0)</code> or <code>MessageChannel</code> in browsers, or <code>scheduler.yield()</code> where it is supported (Chromium-based browsers today).</p>
<h2>Implementation: Time-Sliced Processing</h2>
<p>The pattern I reach for is time slicing: do work until a budget is spent, then yield a macrotask so the loop can serve timers, I/O and requests, then continue. The clock is injectable so the behaviour is testable without real timing.</p>
<pre><code class="language-js">const { setImmediate: yieldToLoop } = require('node:timers/promises');

/**
 * Process a large array without blocking the event loop.
 * Runs `fn` on items until `budgetMs` is spent, then yields a macrotask
 * so timers, I/O callbacks and incoming requests get a turn.
 */
async function processInChunks(items, fn, { budgetMs = 10, now = () =&gt; performance.now() } = {}) {
  const results = [];
  let sliceStart = now();

  for (let i = 0; i &lt; items.length; i++) {
    results.push(fn(items[i], i));

    if (now() - sliceStart &gt;= budgetMs) {
      await yieldToLoop();   // a real macrotask, not a microtask
      sliceStart = now();
    }
  }

  return results;
}

module.exports = { processInChunks };
</code></pre>
<p>A budget of 5 to 15 ms per slice is a good starting point for a Node API server; it keeps per-request latency bounded while still doing real work per slice. In browsers aim for well under 16 ms so you do not drop frames, and consider moving truly heavy work to a Worker instead, where it cannot block the UI at all.</p>
<p>For CPU-bound jobs that take seconds, slicing is a band-aid. Use <code>worker_threads</code> in Node or a Web Worker in the browser, or push the job to a queue. Slicing shines for the middle ground: work that takes 50 to 500 ms and must stay on the main thread because it touches main-thread state.</p>
<h2>Testing Event Loop Behaviour</h2>
<p>You can test ordering with the built-in <code>node:test</code> runner. Record events into an array and assert on the sequence after giving the loop a real macrotask to finish. The second test proves that <code>processInChunks</code> really yields: a timer scheduled before the work must fire <em>in the middle</em> of the work, not after it. A fake clock makes the slice boundaries deterministic.</p>
<pre><code class="language-js">// event-loop.test.cjs  -&gt;  node --test
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { processInChunks } = require('./process-in-chunks.cjs');

const tick = () =&gt; new Promise((resolve) =&gt; setTimeout(resolve, 5));

test('microtasks drain before the next timer', async () =&gt; {
  const log = [];
  setTimeout(() =&gt; log.push('timeout'), 0);
  Promise.resolve().then(() =&gt; log.push('promise'));
  queueMicrotask(() =&gt; log.push('microtask'));
  log.push('sync');

  await tick();
  assert.deepEqual(log, ['sync', 'promise', 'microtask', 'timeout']);
});

test('processInChunks lets a timer run in the middle of the work', async () =&gt; {
  const log = [];
  setTimeout(() =&gt; log.push('timer'), 0);

  let fakeTime = 0;
  const now = () =&gt; fakeTime;
  const items = Array.from({ length: 6 }, (_, i) =&gt; i);

  const out = await processInChunks(items, (x) =&gt; {
    fakeTime += 5;            // pretend each item costs 5 ms
    log.push(`item${x}`);
    return x * 2;
  }, { budgetMs: 10, now });

  assert.deepEqual(out, [0, 2, 4, 6, 8, 10]);
  const timerAt = log.indexOf('timer');
  assert.ok(timerAt &gt; 0 &amp;&amp; timerAt &lt; log.length - 1, `timer ran at ${timerAt}`);
});
</code></pre>
<p>If someone replaces <code>yieldToLoop()</code> with <code>await Promise.resolve()</code>, the second test fails, because the timer would only run after all items are processed. That is exactly the regression you want caught in CI rather than in production.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Assuming <code>setTimeout(fn, 0)</code> means "now".</strong> It means "after the current task, after all microtasks, and after at least 1 ms (4 ms when nested in browsers)". In an inactive browser tab it can be throttled to once per second or worse.</li>
<li><strong>Using <code>await</code> as a yield point.</strong> Awaiting a resolved promise, a sync function, or <code>null</code> is a microtask. It never lets timers, I/O or rendering run.</li>
<li><strong>Recursive <code>process.nextTick</code> or promise chains</strong> for "async recursion". They starve I/O. Use <code>setImmediate</code> for recursion that should be fair.</li>
<li><strong>Expecting <code>async</code> functions to run in the background.</strong> The body runs synchronously until the first <code>await</code>. An <code>async</code> function with a CPU-heavy loop and no <code>await</code> blocks just as hard as a normal function.</li>
<li><strong>Relying on cross-queue order</strong> like <code>setTimeout</code> vs <code>setImmediate</code>, or <code>nextTick</code> vs promises across module types. Express the dependency explicitly with <code>await</code> or callbacks instead.</li>
<li><strong>Unhandled rejections in microtasks.</strong> A rejected promise without a handler surfaces as <code>unhandledRejection</code>, which crashes Node by default since v15. Microtask scheduling does not make errors disappear.</li>
<li><strong>Measuring the wrong thing.</strong> Event loop lag is visible with <code>perf_hooks.monitorEventLoopDelay()</code>. If p99 delay is in the hundreds of milliseconds, look for long synchronous work or long microtask chains before blaming the database.</li>
</ul>
<h2>When to Care and When Not To</h2>
<p>Care about the details when:</p>
<ul>
<li>a Node service shows latency spikes on cheap endpoints while one expensive endpoint is running;</li>
<li>you write libraries that schedule callbacks (emitters, caches, stream adapters) and need a consistent "always async" contract (<code>queueMicrotask</code> is the right tool for that);</li>
<li>you process large in-memory datasets on the main thread, in Node or in the browser;</li>
<li>you debug flaky tests that pass or fail depending on timer timing.</li>
</ul>
<p>Do not over-engineer when:</p>
<ul>
<li>the work is I/O-bound and naturally awaits the network or disk; the loop is already free while waiting;</li>
<li>the job is truly CPU-heavy; move it to a worker or a queue instead of tuning slice sizes;</li>
<li>you are tempted to depend on exact tick counts. Write code whose correctness holds for any interleaving and use the event-loop model only to reason about latency and fairness.</li>
</ul>
<p>The short version to keep in your head: sync code first, then <strong>all</strong> microtasks, then <strong>one</strong> macrotask, repeat. If something can schedule work faster than it finishes, it will starve everything behind it.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Laravel Job Batching vs Chains: Progress, Failures, allowFailures and then/catch/finally</title>
            <link>https://cslant.com/tips/laravel-job-batching-vs-chains-progress-failures-allowfailures-and-thencatchfinally</link>
            <guid isPermaLink="false">https://cslant.com/tips/laravel-job-batching-vs-chains-progress-failures-allowfailures-and-thencatchfinally</guid>
                        <pubDate>Sat, 24 Oct 2026 09:00:00 +0700</pubDate>
                                                <category>Laravel</category>
                                    <description>Bus::chain runs jobs in order and stops at the first failure; Bus::batch runs them in parallel with progress and callbacks. Learn exactly when then, catch and finally fire.</description>
                                    <content:encoded><![CDATA[<p>A customer uploads a 250,000-row CSV. The first version of the import was one queued job that looped over every row: it ran for 40 minutes, hit the worker timeout at row 180,000, was retried from row zero, and nobody could tell the customer how far along it was. The second version split the file into 250 jobs, which fixed the timeout but created new questions. How do you know when <em>all</em> of them are done? What happens when chunk 117 fails three times? And how do you send exactly one "your import is ready" email?</p>
<p>Laravel has two tools for this, and they answer different questions. <code>Bus::chain</code> runs jobs <strong>one after another</strong> and stops at the first failure. <code>Bus::batch</code> runs jobs <strong>in parallel</strong> and tracks them as a group, with progress and lifecycle callbacks. Picking the wrong one is how you end up with half-charged orders or imports that never report completion.</p>
<h2>Chain vs Batch: The Core Difference</h2>
<p>A chain is a sequence. Job B is only pushed to the queue after job A succeeds. If A fails permanently (after its retries), B and C are discarded and the chain's <code>catch</code> callback runs. Use it when each step depends on the previous one: reserve stock, then charge the card, then send the receipt.</p>
<p>A batch is a group. All jobs are pushed at once and any number of workers can pick them up. Laravel stores a row in the <code>job_batches</code> table with counters (<code>total_jobs</code>, <code>pending_jobs</code>, <code>failed_jobs</code>) and updates them atomically as each job finishes. Use it when the jobs are independent and you care about the group outcome: import chunks, thumbnail generation, sending a newsletter in slices.</p>
<p>The lifecycle of a batch is where most of the confusion lives:</p>
<div class="page-mermaid"><div class="mermaid">graph TD
A[Bus batch dispatched] --&gt; B[Jobs run in parallel]
B --&gt;|job succeeds| C[progress callback]
B --&gt;|job fails for good| D[catch on first failure]
D --&gt;|allowFailures off| E[Batch cancelled]
D --&gt;|allowFailures on| B
C --&gt;|all jobs succeeded| F[then callback]
F --&gt; G[finally callback]
E --&gt; G
C --&gt;|some failed, all ran| G</div></div>
<p>Three rules to memorize:</p>
<ul>
<li><code>then</code> runs <strong>only if every job succeeded</strong>. One permanent failure and <code>then</code> never fires, even with <code>allowFailures()</code>.</li>
<li><code>catch</code> runs <strong>once, on the first job that fails for good</strong> (after it has used up its <code>$tries</code>). Later failures do not trigger it again.</li>
<li><code>finally</code> runs <strong>when every job has run</strong>, whatever the outcome. This is where cleanup and "import finished" notifications belong.</li>
</ul>
<h2>Setting Up Batches</h2>
<p>Batches need the <code>job_batches</code> table. Laravel ships a generator for its migration:</p>
<pre><code class="language-bash">php artisan make:queue-batches-table
php artisan migrate
</code></pre>
<p>Every job you put in a batch must use the <code>Batchable</code> trait. It gives the job <code>$this-&gt;batch()</code>, which returns the <code>Batch</code> instance so a job can check whether the batch was cancelled or add more jobs to it. The cleanest way to stop work after cancellation is the <code>SkipIfBatchCancelled</code> job middleware:</p>
<pre><code class="language-php">namespace App\Jobs;

use App\Services\CustomerCsv;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;

final class ImportCustomerChunk implements ShouldQueue
{
    use Batchable, Queueable;

    public int $tries = 3;

    public function __construct(
        public readonly int $importId,
        public readonly int $offset,
        public readonly int $limit,
    ) {}

    /** Skip the work entirely once the batch has been cancelled. */
    public function middleware(): array
    {
        return [new SkipIfBatchCancelled()];
    }

    public function handle(CustomerCsv $csv): void
    {
        foreach ($csv-&gt;rows($this-&gt;importId, $this-&gt;offset, $this-&gt;limit) as $row) {
            $csv-&gt;upsertCustomer($row);
        }
    }

    /** @return list&lt;int&gt; */
    public function backoff(): array
    {
        return [10, 60];
    }
}
</code></pre>
<p>Note that a job skipped by the middleware still counts as processed. That matters for progress bars: a cancelled batch can reach 100% "progress" while doing almost no work.</p>
<h2>Chains for Dependent Steps</h2>
<p>Checkout is the classic chain. Charging the card before stock is reserved is a bug, and sending a receipt for a failed charge is worse:</p>
<pre><code class="language-php">use App\Jobs\ChargePayment;
use App\Jobs\ReleaseStock;
use App\Jobs\ReserveStock;
use App\Jobs\SendReceipt;
use Illuminate\Support\Facades\Bus;

$orderId = $order-&gt;id;

Bus::chain([
    new ReserveStock($orderId),
    new ChargePayment($orderId),
    new SendReceipt($orderId),
])
    -&gt;onQueue('orders')
    -&gt;catch(function (Throwable $e) use ($orderId): void {
        // Runs once, when a job in the chain fails for good.
        // The jobs after the failed one are never executed.
        ReleaseStock::dispatch($orderId);
        logger()-&gt;error('Checkout chain failed', ['order_id' =&gt; $orderId, 'error' =&gt; $e-&gt;getMessage()]);
    })
    -&gt;dispatch();
</code></pre>
<p>The <code>catch</code> closure is serialized and stored with the chain, so capture plain values like <code>$orderId</code>, never <code>$this</code> or a big model graph. If <code>ChargePayment</code> throws on its last attempt, <code>SendReceipt</code> is never queued and the compensation job releases the stock.</p>
<h2>Batches for Parallel Work With Progress</h2>
<p>Here is the import, split into chunks, with all four callbacks wired to a <code>CustomerImport</code> record that the UI polls:</p>
<pre><code class="language-php">namespace App\Services;

use App\Jobs\ImportCustomerChunk;
use App\Models\CustomerImport;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;

final class CustomerImporter
{
    public function start(int $importId, int $rows, int $chunkSize = 1000): Batch
    {
        $jobs = [];

        for ($offset = 0; $offset &lt; $rows; $offset += $chunkSize) {
            $jobs[] = new ImportCustomerChunk($importId, $offset, $chunkSize);
        }

        return Bus::batch($jobs)
            -&gt;name("customer-import-{$importId}")
            -&gt;onQueue('imports')
            -&gt;allowFailures()
            -&gt;progress(function (Batch $batch) use ($importId): void {
                CustomerImport::whereKey($importId)-&gt;update(['progress' =&gt; $batch-&gt;progress()]);
            })
            -&gt;then(function (Batch $batch) use ($importId): void {
                // Only when EVERY job succeeded.
                CustomerImport::whereKey($importId)-&gt;update(['status' =&gt; 'completed']);
            })
            -&gt;catch(function (Batch $batch, Throwable $e) use ($importId): void {
                // Only for the FIRST job that failed for good.
                CustomerImport::whereKey($importId)-&gt;update(['status' =&gt; 'has_errors']);
            })
            -&gt;finally(function (Batch $batch) use ($importId): void {
                // When every job has run, success or not.
                CustomerImport::whereKey($importId)-&gt;update([
                    'finished_at' =&gt; now(),
                    'failed_chunks' =&gt; $batch-&gt;failedJobs,
                ]);
            })
            -&gt;dispatch();
    }
}
</code></pre>
<p>Why <code>allowFailures()</code> here? Without it, the first permanently failed chunk cancels the batch, and every chunk still in the queue is skipped by the middleware. For a checkout-style "all or nothing" job that is what you want. For an import, losing 1 chunk out of 250 should not throw away the other 249; you would rather finish, report <code>failed_chunks</code>, and let support retry the failures with <code>php artisan queue:retry-batch {id}</code>.</p>
<p>The progress callback fires after each successful job. It does a small write, which is fine at a few hundred jobs. At tens of thousands of jobs, throttle it (only write when the integer percentage changes) or drop it and let the UI read the batch directly:</p>
<pre><code class="language-php">use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Route;

Route::get('/imports/batches/{batchId}', function (string $batchId) {
    $batch = Bus::findBatch($batchId);

    abort_if($batch === null, 404);

    return [
        'name' =&gt; $batch-&gt;name,
        'progress' =&gt; $batch-&gt;progress(),
        'total' =&gt; $batch-&gt;totalJobs,
        'pending' =&gt; $batch-&gt;pendingJobs,
        'failed' =&gt; $batch-&gt;failedJobs,
        'finished' =&gt; $batch-&gt;finished(),
        'cancelled' =&gt; $batch-&gt;cancelled(),
    ];
})-&gt;middleware('auth');
</code></pre>
<p><code>Bus::findBatch()</code> reads the <code>job_batches</code> row, so this endpoint is cheap and always consistent with what the workers have recorded. Keep it behind auth: a batch ID is not a secret token.</p>
<h2>Mixing Chains and Batches</h2>
<p>Real workflows are rarely pure. Laravel lets you nest them both ways. An array inside a batch is a chain; a batch inside a chain is a step that waits for the whole batch:</p>
<pre><code class="language-php">use App\Jobs\BuildSearchIndex;
use App\Jobs\ExportInvoices;
use App\Jobs\ExportPayments;
use App\Jobs\NotifyFinanceTeam;
use App\Jobs\ZipExports;
use Illuminate\Support\Facades\Bus;

// A batch whose items are chains: each region runs its two steps in order,
// while the regions run in parallel with each other.
Bus::batch([
    [new ExportInvoices('eu'), new ExportPayments('eu')],
    [new ExportInvoices('us'), new ExportPayments('us')],
    [new ExportInvoices('apac'), new ExportPayments('apac')],
])-&gt;name('month-end-exports')-&gt;dispatch();

// A chain that contains a batch: the zip step waits for the whole batch.
Bus::chain([
    Bus::batch([
        new ExportInvoices('eu'),
        new ExportInvoices('us'),
    ]),
    new ZipExports(),
    new NotifyFinanceTeam(),
])-&gt;dispatch();

// A running job can also grow its own batch.
// Inside a Batchable job's handle():
// $this-&gt;batch()-&gt;add([new BuildSearchIndex($this-&gt;importId)]);
</code></pre>
<p>The second form replaces a common hack where the batch's <code>then</code> callback dispatches the next job. With the batch as a chain step, the dependency is visible at the dispatch site instead of being hidden in a callback.</p>
<h2>Testing Batches and Chains</h2>
<p>You rarely want real workers in a feature test. <code>Bus::fake()</code> records what was dispatched and gives you assertions for both shapes:</p>
<pre><code class="language-php">use App\Jobs\ChargePayment;
use App\Jobs\ImportCustomerChunk;
use App\Jobs\ReserveStock;
use App\Jobs\SendReceipt;
use App\Models\Order;
use App\Services\CheckoutService;
use App\Services\CustomerImporter;
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;

it('dispatches one batch job per chunk and tolerates failures', function () {
    Bus::fake();

    app(CustomerImporter::class)-&gt;start(importId: 42, rows: 2500, chunkSize: 1000);

    Bus::assertBatched(function (PendingBatch $batch): bool {
        return $batch-&gt;name === 'customer-import-42'
            &amp;&amp; $batch-&gt;jobs-&gt;count() === 3
            &amp;&amp; $batch-&gt;jobs-&gt;every(fn ($job) =&gt; $job instanceof ImportCustomerChunk)
            &amp;&amp; $batch-&gt;allowsFailures();
    });
});

it('runs checkout as an ordered chain', function () {
    Bus::fake();

    $order = Order::factory()-&gt;create();
    app(CheckoutService::class)-&gt;checkout($order);

    Bus::assertChained([
        ReserveStock::class,
        ChargePayment::class,
        SendReceipt::class,
    ]);
});
</code></pre>
<p>These tests check the wiring: the right number of chunks, the right order, <code>allowFailures</code> switched on. The jobs themselves deserve separate unit tests that call <code>handle()</code> directly with fake dependencies. For the callbacks, extract their bodies into small invokable classes or service methods so you can test them without a queue.</p>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Expecting <code>then</code> after partial failure.</strong> With <code>allowFailures()</code>, the batch keeps going, but <code>then</code> still only fires when everything succeeded. Put "import done" logic in <code>finally</code> and read <code>$batch-&gt;failedJobs</code> there.</li>
<li><strong>Capturing <code>$this</code> or models in callbacks.</strong> Callbacks are serialized into the <code>job_batches</code> row. Capture IDs and re-query inside the closure.</li>
<li><strong>Forgetting the batch was cancelled.</strong> Without <code>SkipIfBatchCancelled</code> or a manual <code>if ($this-&gt;batch()?-&gt;cancelled()) return;</code>, queued jobs keep doing real work after a failure cancelled the batch.</li>
<li><strong>Retries vs failures.</strong> A job that throws but has tries left is not a batch failure yet. <code>catch</code> only fires after the final attempt. Set <code>$tries</code> and <code>backoff()</code> deliberately.</li>
<li><strong>Unbounded <code>job_batches</code> table.</strong> Every batch leaves a row. Schedule pruning:</li>
</ul>
<pre><code class="language-php">use Illuminate\Support\Facades\Schedule;

// routes/console.php
Schedule::command('queue:prune-batches --hours=48 --unfinished=72 --cancelled=72')-&gt;daily();
</code></pre>
<ul>
<li><strong>Dispatching inside a DB transaction.</strong> If you create the <code>CustomerImport</code> row and dispatch the batch in the same transaction, workers can start before the commit and not find the row. Use <code>afterCommit()</code> on the jobs or dispatch after the transaction.</li>
</ul>
<h2>When to Use Which, and When Not To</h2>
<p>Use a <strong>chain</strong> when steps must run in order and later steps make no sense if an earlier one failed. Keep chains short; a 15-step chain is a workflow engine in disguise and is hard to resume halfway.</p>
<p>Use a <strong>batch</strong> when the work is a set of independent units and you need group-level answers: how far along, did anything fail, run something exactly once at the end.</p>
<p>Do not use either when one job is enough. If a task finishes in seconds, splitting it adds a database round trip per job for bookkeeping. And do not use batches as a long-lived state machine for business processes that span days; model that state in your own tables and let jobs be the small, retryable steps that move it forward.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Circuit Breaker Pattern: Stop Hammering a Service That Is Already Down</title>
            <link>https://cslant.com/tips/circuit-breaker-pattern-stop-hammering-a-service-that-is-already-down</link>
            <guid isPermaLink="false">https://cslant.com/tips/circuit-breaker-pattern-stop-hammering-a-service-that-is-already-down</guid>
                        <pubDate>Fri, 23 Oct 2026 15:09:52 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>Retries alone can bury a struggling dependency. Learn how a circuit breaker fails fast, lets the service recover and probes it safely, with PHP, Python and Laravel code.</description>
                                    <content:encoded><![CDATA[<p>Your payment provider starts timing out. Every request in your app waits 10 seconds, retries three times, then fails anyway. PHP-FPM workers pile up waiting on a service that is already down, and now <em>your</em> app is down too. Retries with backoff help with short blips, but when a dependency is truly broken, the kindest thing you can do is <strong>stop calling it for a while</strong>. That is what a circuit breaker does.</p>
<h2>The Three States</h2>
<p>A circuit breaker wraps calls to a dependency and tracks how they go. It lives in one of three states:</p>
<div class="page-mermaid"><div class="mermaid">graph LR
A[Closed: calls pass through] --&gt;|failures reach threshold| B[Open: fail fast]
B --&gt;|cooldown elapsed| C[Half-Open: one trial call]
C --&gt;|trial succeeds| A
C --&gt;|trial fails| B</div></div>
<ul>
<li><strong>Closed</strong>: normal operation. Calls go through, failures are counted. A success resets the counter.</li>
<li><strong>Open</strong>: the threshold was hit. Calls fail immediately with no network round trip, so your workers are freed in microseconds instead of seconds.</li>
<li><strong>Half-Open</strong>: the cooldown has passed. One trial call is allowed through. If it succeeds, the circuit closes; if it fails, it opens again for another cooldown.</li>
</ul>
<h2>Implementation</h2>
<p>The clock is injected so the breaker can be tested without sleeping.</p>
<pre><code class="language-php">final class CircuitOpenException extends RuntimeException {}

final class CircuitBreaker
{
    private string $state = 'closed';
    private int $failures = 0;
    private ?float $openedAt = null;

    public function __construct(
        private int $failureThreshold = 5,
        private float $cooldownSeconds = 30.0,
        private ?Closure $clock = null,
    ) {
        $this-&gt;clock ??= fn (): float =&gt; microtime(true);
    }

    public function call(callable $operation): mixed
    {
        if ($this-&gt;state === 'open') {
            if (($this-&gt;clock)() - $this-&gt;openedAt &lt; $this-&gt;cooldownSeconds) {
                throw new CircuitOpenException('Circuit is open, failing fast.');
            }

            $this-&gt;state = 'half_open';
        }

        try {
            $result = $operation();
        } catch (Throwable $e) {
            $this-&gt;recordFailure();

            throw $e;
        }

        $this-&gt;reset();

        return $result;
    }

    public function state(): string
    {
        return $this-&gt;state;
    }

    private function recordFailure(): void
    {
        $this-&gt;failures++;

        if ($this-&gt;state === 'half_open' || $this-&gt;failures &gt;= $this-&gt;failureThreshold) {
            $this-&gt;state = 'open';
            $this-&gt;openedAt = ($this-&gt;clock)();
        }
    }

    private function reset(): void
    {
        $this-&gt;state = 'closed';
        $this-&gt;failures = 0;
        $this-&gt;openedAt = null;
    }
}
</code></pre>
<pre><code class="language-python">import time
from typing import Callable


class CircuitOpenError(Exception):
    pass


class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        cooldown_seconds: float = 30.0,
        clock: Callable[[], float] = time.monotonic,
    ) -&gt; None:
        self.failure_threshold = failure_threshold
        self.cooldown_seconds = cooldown_seconds
        self.clock = clock
        self.state = "closed"
        self.failures = 0
        self.opened_at: float | None = None

    def call[T](self, operation: Callable[[], T]) -&gt; T:
        if self.state == "open":
            if self.clock() - self.opened_at &lt; self.cooldown_seconds:
                raise CircuitOpenError("Circuit is open, failing fast.")
            self.state = "half_open"

        try:
            result = operation()
        except Exception:
            self._record_failure()
            raise

        self._reset()
        return result

    def _record_failure(self) -&gt; None:
        self.failures += 1
        if self.state == "half_open" or self.failures &gt;= self.failure_threshold:
            self.state = "open"
            self.opened_at = self.clock()

    def _reset(self) -&gt; None:
        self.state = "closed"
        self.failures = 0
        self.opened_at = None
</code></pre>
<h2>Sharing State in Laravel</h2>
<p>The class above keeps its state in memory. In a long-running Python worker that is fine, but under PHP-FPM every request starts with a fresh object, so the breaker never remembers a single failure. In Laravel, keep the state in a shared cache (Redis) instead:</p>
<pre><code class="language-php">use Illuminate\Support\Facades\Cache;

final class CachedCircuitBreaker
{
    public function __construct(
        private string $name,
        private int $failureThreshold = 5,
        private int $cooldownSeconds = 30,
        private int $failureWindowSeconds = 60,
    ) {}

    public function call(callable $operation): mixed
    {
        $openKey = "circuit:{$this-&gt;name}:open";
        $failKey = "circuit:{$this-&gt;name}:failures";

        if (Cache::has($openKey)) {
            throw new CircuitOpenException("Circuit {$this-&gt;name} is open.");
        }

        try {
            $result = $operation();
        } catch (Throwable $e) {
            Cache::add($failKey, 0, $this-&gt;failureWindowSeconds);

            if (Cache::increment($failKey) &gt;= $this-&gt;failureThreshold) {
                Cache::put($openKey, true, $this-&gt;cooldownSeconds);
                Cache::forget($failKey);
            }

            throw $e;
        }

        Cache::forget($failKey);

        return $result;
    }
}
</code></pre>
<pre><code class="language-php">$breaker = new CachedCircuitBreaker('payment-gateway', failureThreshold: 5, cooldownSeconds: 30);

try {
    $response = $breaker-&gt;call(
        fn () =&gt; Http::timeout(3)-&gt;post($url, $payload)-&gt;throw()
    );
} catch (CircuitOpenException) {
    return response()-&gt;json([
        'message' =&gt; 'The payment provider is unavailable, please try again shortly.',
    ], 503);
}
</code></pre>
<p>This shared version is simpler than the in-memory one: when the open key expires, all traffic flows again instead of a single trial call. For most apps that is good enough. If the dependency is fragile, add a short <code>Cache::lock()</code> so only one request makes the trial call.</p>
<h2>Testing the Breaker</h2>
<p>Because the clock is injected, you can move time forward instead of sleeping:</p>
<pre><code class="language-php">it('opens after the threshold and recovers after the cooldown', function () {
    $now = 1000.0;
    $breaker = new CircuitBreaker(3, 30, function () use (&amp;$now) { return $now; });
    $fail = fn () =&gt; throw new RuntimeException('down');

    foreach (range(1, 3) as $_) {
        try { $breaker-&gt;call($fail); } catch (RuntimeException) {}
    }

    expect($breaker-&gt;state())-&gt;toBe('open')
        -&gt;and(fn () =&gt; $breaker-&gt;call(fn () =&gt; 'ok'))-&gt;toThrow(CircuitOpenException::class);

    $now += 31;

    expect($breaker-&gt;call(fn () =&gt; 'ok'))-&gt;toBe('ok')
        -&gt;and($breaker-&gt;state())-&gt;toBe('closed');
});
</code></pre>
<h2>Choosing the Thresholds</h2>
<ul>
<li><strong>Failure threshold</strong>: 5 failures is a common start. Too low and one hiccup trips the breaker; too high and you keep hammering a dead service.</li>
<li><strong>Cooldown</strong>: 15 to 60 seconds. Long enough for the dependency to restart or scale, short enough that users do not wait minutes after it recovers.</li>
<li><strong>Timeouts</strong>: a breaker only helps if calls fail fast. Always set a client timeout (<code>Http::timeout(3)</code>); a 30-second default timeout means 30 seconds of blocked workers per call.</li>
</ul>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Counting the wrong errors.</strong> A <code>422 Validation failed</code> is the caller's fault, not a sign the service is down. Only count timeouts, connection errors and <code>5xx</code> responses.</li>
<li><strong>One breaker for everything.</strong> Use one breaker per dependency (<code>payment-gateway</code>, <code>sms-provider</code>), so an SMS outage does not block payments.</li>
<li><strong>No fallback.</strong> Decide what the user sees when the circuit is open: a cached value, a queued job to retry later, or a clear <code>503</code> message.</li>
<li><strong>Retrying inside the breaker without limits.</strong> Put backoff retries <em>inside</em> the operation and keep them small, or the breaker sees one failure for what was actually five slow calls.</li>
</ul>
<h2>When to Reach for This</h2>
<p>Use a circuit breaker around any remote dependency that can be slow or down and that sits on a hot path: payment gateways, SMS and email providers, third-party APIs, even an internal microservice. Pair it with exponential backoff: backoff handles short blips, the breaker handles real outages.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Exponential Backoff with Jitter: Retrying Failed Requests the Right Way</title>
            <link>https://cslant.com/tips/exponential-backoff-with-jitter-retrying-failed-requests-the-right-way</link>
            <guid isPermaLink="false">https://cslant.com/tips/exponential-backoff-with-jitter-retrying-failed-requests-the-right-way</guid>
                        <pubDate>Thu, 03 Sep 2026 11:49:05 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>Why naive exponential backoff causes retry storms, and how adding random jitter smooths out the load, with a full-jitter retry implementation in PHP and Python.</description>
                                    <content:encoded><![CDATA[<p>Retrying a failed request seems simple: wait a bit, try again. But retry the same fixed delay across thousands of clients and you get a thundering herd — every client wakes up and hammers the server at the exact same moment, often taking down whatever was just starting to recover.</p>
<p>Exponential backoff spaces retries further apart each time (<code>200ms, 400ms, 800ms...</code>), but on its own it still leaves every client synchronized to the same schedule. Jitter fixes that by randomizing the delay, so retries spread out instead of arriving in lockstep.</p>
<h2>The Retry Timeline</h2>
<div class="page-mermaid"><div class="mermaid">graph LR
A[Attempt 1] --&gt;|delay ~200ms| B[Attempt 2]
B --&gt;|delay ~400ms plus jitter| C[Attempt 3]
C --&gt;|delay ~800ms plus jitter| D[Attempt 4]
D --&gt;|delay ~1600ms plus jitter, capped| E[Give up or succeed]</div></div>
<p>Each hop roughly doubles the base delay, capped at some maximum, with a random component layered on top so no two clients retry at exactly the same instant.</p>
<h2>Fixed vs Exponential vs Jittered</h2>
<p>A fixed delay (always wait 1s) is simple but synchronizes every client that failed at the same time. Plain exponential backoff (<code>200ms, 400ms, 800ms, 1600ms</code>) spreads attempts further apart over time, but clients that failed together are still retrying together at each step. Adding jitter — picking a random delay instead of the fixed exponential value — breaks that synchronization entirely.</p>
<h2>Full Jitter</h2>
<p>The <code>full jitter</code> strategy (used internally by AWS's own SDKs) computes the exponential delay as a ceiling, then picks a uniformly random value between zero and that ceiling:</p>
<pre><code>delay = random_between(0, min(cap, base * 2^attempt))
</code></pre>
<p>This is simpler than "equal jitter" or "decorrelated jitter" variants and performs well in practice: it spreads retries across the full window instead of just adding a small amount of noise around the exponential value.</p>
<h2>Implementation</h2>
<div class="page-codeblock" x-data="{ active: 0 }"><div class="page-codeblock__bar"><div class="flex items-center gap-1 overflow-x-auto"><button type="button" @click="active = 0" class="tip-tab" :class="active === 0 ? 'tip-tab--active' : ''">PHP</button><button type="button" @click="active = 1" class="tip-tab" :class="active === 1 ? 'tip-tab--active' : ''">PYTHON</button></div></div><div x-show="active === 0" x-cloak=""><pre><code class="language-php">function calculateBackoff(int $attempt, int $baseDelayMs = 200, int $maxDelayMs = 30000): int
{
    $exponential = $baseDelayMs * (2 ** $attempt);
    $capped = min($exponential, $maxDelayMs);

    return random_int(0, $capped);
}

function retryWithBackoff(callable $operation, int $maxAttempts = 5, int $baseDelayMs = 200, int $maxDelayMs = 30000)
{
    $attempt = 0;

    while (true) {
        try {
            return $operation();
        } catch (\Throwable $e) {
            $attempt++;

            if ($attempt &gt;= $maxAttempts) {
                throw $e;
            }

            $delayMs = calculateBackoff($attempt, $baseDelayMs, $maxDelayMs);
            usleep($delayMs * 1000);
        }
    }
}</code></pre></div><div x-show="active === 1" x-cloak=""><pre><code class="language-python">import random
import time
from typing import Callable, TypeVar

T = TypeVar("T")


def calculate_backoff(attempt: int, base_delay_ms: int = 200, max_delay_ms: int = 30000) -&gt; int:
    exponential = base_delay_ms * (2 ** attempt)
    capped = min(exponential, max_delay_ms)

    return random.randint(0, capped)


def retry_with_backoff(
    operation: Callable[[], T],
    max_attempts: int = 5,
    base_delay_ms: int = 200,
    max_delay_ms: int = 30000,
) -&gt; T:
    attempt = 0

    while True:
        try:
            return operation()
        except Exception:
            attempt += 1

            if attempt &gt;= max_attempts:
                raise

            delay_ms = calculate_backoff(attempt, base_delay_ms, max_delay_ms)
            time.sleep(delay_ms / 1000)</code></pre></div></div>
<h2>Choosing the Parameters</h2>
<p><code>baseDelayMs</code> should roughly match how fast the downstream service actually recovers from a blip — too low and you're retrying into a still-failing service, too high and users wait needlessly on transient errors. <code>maxDelayMs</code> caps the worst case so a client doesn't end up waiting minutes between attempts. <code>maxAttempts</code> should be low enough that a genuinely broken dependency fails fast instead of hanging the caller for a long chain of doubling delays.</p>
<h2>Common Pitfalls</h2>
<p>Retrying without any cap on the delay means a client can end up waiting minutes for an attempt that will just fail again. Retrying without jitter defeats the entire purpose — synchronized clients stay synchronized no matter how far apart the delays get. Retrying on every kind of error, including 4xx client errors that will never succeed, wastes the retry budget on failures that a delay can't fix. And retrying indefinitely with no <code>maxAttempts</code> turns a transient blip into a request that never gives up and never surfaces an error to the caller.</p>
<h2>When to Reach for This</h2>
<p>Any call to a dependency that can fail transiently and recover on its own: HTTP calls to another service, database connections during a failover, queue consumers hitting a rate-limited API. If failures are rarely transient — a malformed request, a missing resource — backoff and retries just delay an error that was never going to succeed.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Consistent Hashing: Distributing Load Without a Full Reshuffle</title>
            <link>https://cslant.com/tips/consistent-hashing-distributing-load-without-a-full-reshuffle</link>
            <guid isPermaLink="false">https://cslant.com/tips/consistent-hashing-distributing-load-without-a-full-reshuffle</guid>
                        <pubDate>Fri, 28 Aug 2026 23:33:56 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>How consistent hashing distributes keys across nodes so adding or removing a node only reshuffles a small fraction of the keyspace, with a virtual-node implementation in PHP and Python.</description>
                                    <content:encoded><![CDATA[<p>Naive hashing (<code>hash(key) % N</code>) works fine until you add or remove a node — then N changes and almost every key maps to a different server. For a distributed cache or shard, that means a near-total cache miss storm or a mass data migration, right when you can least afford it.</p>
<p>Consistent hashing fixes this by mapping both nodes and keys onto the same ring, so a topology change only remaps the keys between the changed node and its neighbor.</p>
<h2>The Ring</h2>
<div class="page-mermaid"><div class="mermaid">graph LR
    subgraph Ring["Hash Ring (0 to 2^32-1)"]
        NA((Node A))
        NB((Node B))
        NC((Node C))
        K1[key1] -.-&gt; NB
        K2[key2] -.-&gt; NC
        K3[key3] -.-&gt; NA
    end</div></div>
<p>Each node is hashed onto the ring (often multiple times — see "Virtual Nodes" below). To find which node owns a key, hash the key and walk clockwise until you hit the first node.</p>
<h2>Adding or Removing a Node</h2>
<p>When node B leaves, only the keys that were mapped to B move — to B's clockwise neighbor. Every other key stays exactly where it was. That's the whole point: roughly <code>1/N</code> of the keyspace moves per topology change, not the whole keyspace.</p>
<h2>Virtual Nodes (Replicas)</h2>
<p>Hashing raw node names onto the ring directly creates hot spots — some nodes end up owning much bigger arcs than others. The fix: hash each node multiple times, e.g. <code>node-A#0</code>, <code>node-A#1</code>, ..., <code>node-A#149</code> (150 virtual nodes is a common default). More virtual nodes means smoother distribution, at the cost of a bigger in-memory ring.</p>
<h2>Implementation</h2>
<div class="page-codeblock" x-data="{ active: 0 }"><div class="page-codeblock__bar"><div class="flex items-center gap-1 overflow-x-auto"><button type="button" @click="active = 0" class="tip-tab" :class="active === 0 ? 'tip-tab--active' : ''">PHP</button><button type="button" @click="active = 1" class="tip-tab" :class="active === 1 ? 'tip-tab--active' : ''">PYTHON</button></div></div><div x-show="active === 0" x-cloak=""><pre><code class="language-php">class ConsistentHash
{
    private array $ring = [];
    private array $sortedKeys = [];
    private int $replicas;

    public function __construct(int $replicas = 150)
    {
        $this-&gt;replicas = $replicas;
    }

    public function addNode(string $node): void
    {
        for ($i = 0; $i &lt; $this-&gt;replicas; $i++) {
            $this-&gt;ring[$this-&gt;hash("$node#$i")] = $node;
        }
        $this-&gt;sortedKeys = $this-&gt;sortedRingKeys();
    }

    public function getNode(string $key): ?string
    {
        if (empty($this-&gt;sortedKeys)) {
            return null;
        }

        $hash = $this-&gt;hash($key);
        foreach ($this-&gt;sortedKeys as $ringKey) {
            if ($ringKey &gt;= $hash) {
                return $this-&gt;ring[$ringKey];
            }
        }

        return $this-&gt;ring[$this-&gt;sortedKeys[0]];
    }

    private function sortedRingKeys(): array
    {
        $keys = array_keys($this-&gt;ring);
        sort($keys);
        return $keys;
    }

    private function hash(string $value): int
    {
        return crc32($value);
    }
}</code></pre></div><div x-show="active === 1" x-cloak=""><pre><code class="language-python">import bisect
import zlib


class ConsistentHash:
    def __init__(self, replicas: int = 150):
        self.replicas = replicas
        self.ring: dict[int, str] = {}
        self.sorted_keys: list[int] = []

    def add_node(self, node: str) -&gt; None:
        for i in range(self.replicas):
            self.ring[self._hash(f"{node}#{i}")] = node
        self.sorted_keys = sorted(self.ring.keys())

    def get_node(self, key: str) -&gt; str | None:
        if not self.sorted_keys:
            return None

        h = self._hash(key)
        index = bisect.bisect_left(self.sorted_keys, h)
        if index == len(self.sorted_keys):
            index = 0

        return self.ring[self.sorted_keys[index]]

    @staticmethod
    def _hash(value: str) -&gt; int:
        return zlib.crc32(value.encode())</code></pre></div></div>
<h2>Common Pitfalls</h2>
<p>Too few virtual nodes leads to uneven load, with hot spots on a handful of physical nodes. Using a weak or poorly-distributed hash function makes the same problem worse — prefer something like CRC32 or xxHash over a naive sum-of-bytes hash. Forgetting to remove all of a node's virtual nodes on removal leaves ghost entries that route traffic to a dead server. And rehashing the entire keyspace on every topology change defeats the whole purpose — only the ring should change, not every key's mapping.</p>
<h2>When to Reach for This</h2>
<p>Distributed caches doing client-side sharding, CDN edge selection, distributed hash tables, sharded databases, and load balancers that need session affinity without a central coordinator. If your cluster size is fixed and rarely changes, plain modulo hashing is simpler and perfectly fine.</p>]]></content:encoded>
                                </item>
                <item>
            <title>LRU Cache: Design an O(1) Cache with a Doubly Linked List and Hash Map</title>
            <link>https://cslant.com/tips/lru-cache-design-an-o1-cache-with-a-doubly-linked-list-and-hash-map</link>
            <guid isPermaLink="false">https://cslant.com/tips/lru-cache-design-an-o1-cache-with-a-doubly-linked-list-and-hash-map</guid>
                        <pubDate>Fri, 28 Aug 2026 10:00:00 +0700</pubDate>
                                                <category>PHP</category>
                                    <description>How to design an LRU cache that supports get and put in O(1) time using a hash map paired with a doubly linked list, with a working PHP implementation and a hands-on coding challenge.</description>
                                    <content:encoded><![CDATA[<p>An LRU (Least Recently Used) cache evicts the item that hasn't been touched in the longest time once it's full. It's a classic interview question, but it's also exactly what backs real systems: database query caches, CDN edge caches, browser tab memory management, connection pools.</p>
<p>The naive approach — an array you scan for the oldest entry — is O(n) per operation. The trick to O(1) is combining two structures: a hash map for O(1) lookup, and a doubly linked list for O(1) reordering.</p>
<h2>The Structure</h2>
<div class="page-mermaid"><div class="mermaid">graph LR
    subgraph "Hash Map (key -&gt; node pointer)"
        M1["'a' -&gt; Node A"]
        M2["'b' -&gt; Node B"]
        M3["'c' -&gt; Node C"]
    end

    subgraph "Doubly Linked List (most to least recent)"
        HEAD((head)) &lt;--&gt; A["Node A key=a"]
        A &lt;--&gt; B["Node B key=b"]
        B &lt;--&gt; C["Node C key=c"]
        C &lt;--&gt; TAIL((tail))
    end

    M1 -.-&gt; A
    M2 -.-&gt; B
    M3 -.-&gt; C</div></div>
<p>Every node lives in the linked list AND has a pointer stored in the hash map. <code>head</code> is a sentinel pointing to the most recently used node; <code>tail</code> is a sentinel pointing to the least recently used one, which is the eviction candidate.</p>
<h2>The Two Operations</h2>
<p><strong>get(key):</strong> hash map lookup finds the node in O(1). If found, unlink it from its current position and relink it right after <code>head</code> (it's now the most recently used), then return its value. If not found, return null/miss.</p>
<p><strong>put(key, value):</strong> if the key already exists, update its value and move it to the front, same as get. If it's new and capacity is full, remove the node just before <code>tail</code> (the least recently used) from both the list and the hash map, then insert the new node at the front.</p>
<p>Both operations only ever touch a constant number of pointers — no scanning required.</p>
<h2>PHP Implementation</h2>
<pre><code class="language-php">class Node
{
    public function __construct(
        public string $key,
        public mixed $value,
        public ?Node $prev = null,
        public ?Node $next = null,
    ) {}
}

class LRUCache
{
    private array $map = [];
    private Node $head;
    private Node $tail;
    private int $capacity;

    public function __construct(int $capacity)
    {
        $this-&gt;capacity = $capacity;
        $this-&gt;head = new Node('', null);
        $this-&gt;tail = new Node('', null);
        $this-&gt;head-&gt;next = $this-&gt;tail;
        $this-&gt;tail-&gt;prev = $this-&gt;head;
    }

    public function get(string $key): mixed
    {
        if (!isset($this-&gt;map[$key])) {
            return null;
        }

        $node = $this-&gt;map[$key];
        $this-&gt;detach($node);
        $this-&gt;attachToFront($node);

        return $node-&gt;value;
    }

    public function put(string $key, mixed $value): void
    {
        if (isset($this-&gt;map[$key])) {
            $node = $this-&gt;map[$key];
            $node-&gt;value = $value;
            $this-&gt;detach($node);
            $this-&gt;attachToFront($node);
            return;
        }

        if (count($this-&gt;map) &gt;= $this-&gt;capacity) {
            $lru = $this-&gt;tail-&gt;prev;
            $this-&gt;detach($lru);
            unset($this-&gt;map[$lru-&gt;key]);
        }

        $node = new Node($key, $value);
        $this-&gt;map[$key] = $node;
        $this-&gt;attachToFront($node);
    }

    private function detach(Node $node): void
    {
        $node-&gt;prev-&gt;next = $node-&gt;next;
        $node-&gt;next-&gt;prev = $node-&gt;prev;
    }

    private function attachToFront(Node $node): void
    {
        $node-&gt;next = $this-&gt;head-&gt;next;
        $node-&gt;prev = $this-&gt;head;
        $this-&gt;head-&gt;next-&gt;prev = $node;
        $this-&gt;head-&gt;next = $node;
    }
}
</code></pre>
<p>The two sentinel nodes (<code>head</code> and <code>tail</code>) exist purely to avoid null checks at the boundaries — every real node always has a valid <code>prev</code> and <code>next</code> to work with.</p>
<h2>Common Pitfalls</h2>
<p><strong>Forgetting to update the hash map on eviction.</strong> If you remove a node from the linked list but leave its entry in <code>$map</code>, the next <code>get()</code> for that key returns a dangling node instead of a miss.</p>
<p><strong>Off-by-one on capacity checks.</strong> Check <code>count($this-&gt;map) &gt;= $this-&gt;capacity</code> <em>before</em> inserting the new node, not after — otherwise the cache temporarily holds capacity + 1 items.</p>
<p><strong>Treating get() as read-only.</strong> A cache hit still mutates state — it moves the node to the front. Skipping that step turns your LRU into a plain FIFO cache.</p>
<p><strong>Using an array and array_shift() as the "queue."</strong> <code>array_shift()</code> is O(n) because PHP reindexes the array. This defeats the entire point of the exercise — the doubly linked list is what makes eviction O(1).</p>
<h2>When to Reach for a Real Implementation</h2>
<p>Don't hand-roll this for production unless you have a specific reason (embedding the cache inside a single PHP process with no external dependency, for instance). For anything shared across requests or processes, Redis's own eviction policies (<code>allkeys-lru</code>, <code>volatile-lru</code>) or a library like <code>symfony/cache</code> already solve this correctly and durably. The value of building it yourself is understanding <em>why</em> O(1) requires both structures together — neither one alone gets you there.</p>]]></content:encoded>
                                </item>
                <item>
            <title>Idempotency Keys: Preventing Duplicate Payments in Distributed Systems</title>
            <link>https://cslant.com/tips/idempotency-keys-preventing-duplicate-payments-in-distributed-systems</link>
            <guid isPermaLink="false">https://cslant.com/tips/idempotency-keys-preventing-duplicate-payments-in-distributed-systems</guid>
                        <pubDate>Tue, 18 Aug 2026 09:00:00 +0700</pubDate>
                                                <category>Laravel</category>
                                    <description>Why idempotency keys matter in payment APIs, how to implement them safely with a database-backed store, and common pitfalls like race conditions and key collisions.</description>
                                    <content:encoded><![CDATA[<p>Idempotency keys are the difference between a retried payment request and an accidental double-charge. In any distributed system where a client might retry a request after a timeout — mobile app on flaky wifi, a load balancer failing over, a queue redelivering a message — the server needs a way to recognize "I've already done this" and return the original result instead of repeating the side effect.</p>
<h2>The Problem</h2>
<pre><code>Client -&gt; POST /charge {amount: 5000} -&gt; Server processes charge, DB write succeeds
Client -&gt; (times out waiting for response, retries)
Client -&gt; POST /charge {amount: 5000} -&gt; Server processes AGAIN -&gt; customer charged twice
</code></pre>
<p>The client never knows if the first request failed before or after the side effect happened. Without idempotency, a naive retry strategy silently causes double charges, duplicate emails, duplicate orders.</p>
<h2>The Fix: Idempotency Keys</h2>
<p>The client generates a unique key (usually a UUID) per logical operation and sends it in a header:</p>
<pre><code>POST /api/v1/charges
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{ "amount": 5000, "currency": "USD" }
</code></pre>
<p>The server stores the key alongside the result of the first successful execution. On retry with the same key, it returns the stored result without re-executing the side effect.</p>
<h2>Implementation with a Database-Backed Store</h2>
<pre><code class="language-php">function handleCharge(Request $request) {
    $key = $request-&gt;header('Idempotency-Key');
    if (!$key) {
        abort(400, 'Idempotency-Key header required');
    }

    return DB::transaction(function () use ($key, $request) {
        $existing = IdempotencyKey::where('key', $key)-&gt;lockForUpdate()-&gt;first();

        if ($existing) {
            if ($existing-&gt;status === 'processing') {
                abort(409, 'Request with this key is already being processed');
            }
            return response($existing-&gt;response_body, $existing-&gt;response_status);
        }

        IdempotencyKey::create([
            'key' =&gt; $key,
            'status' =&gt; 'processing',
            'request_hash' =&gt; hash('sha256', $request-&gt;getContent()),
        ]);

        $result = processCharge($request-&gt;all());

        IdempotencyKey::where('key', $key)-&gt;update([
            'status' =&gt; 'completed',
            'response_status' =&gt; 200,
            'response_body' =&gt; json_encode($result),
        ]);

        return response()-&gt;json($result);
    });
}
</code></pre>
<p>The row-level lock (<code>lockForUpdate</code>) inside the transaction is what prevents two concurrent requests with the same key from both slipping past the "does it exist" check — this is the race condition most naive implementations miss.</p>
<h2>Sequence of a Safe Retry</h2>
<div class="page-mermaid"><div class="mermaid">sequenceDiagram
    participant C as Client
    participant S as Server
    participant D as DB

    C-&gt;&gt;S: POST /charge (key=abc123)
    S-&gt;&gt;D: BEGIN, SELECT ... FOR UPDATE (key=abc123)
    D--&gt;&gt;S: not found
    S-&gt;&gt;D: INSERT key=abc123 status=processing
    S-&gt;&gt;S: process charge
    S-&gt;&gt;D: UPDATE key=abc123 status=completed, store response
    S-&gt;&gt;D: COMMIT
    S--&gt;&gt;C: 200 OK

    Note over C,S: network drop, client never saw the 200

    C-&gt;&gt;S: POST /charge (key=abc123) [retry]
    S-&gt;&gt;D: SELECT ... FOR UPDATE (key=abc123)
    D--&gt;&gt;S: found, status=completed
    S--&gt;&gt;C: 200 OK (stored response, no new charge)</div></div>
<h2>Common Pitfalls</h2>
<p><strong>Not hashing the request body.</strong> If a client reuses a key with a <em>different</em> payload (different amount, say), returning the cached response silently applies the wrong charge to the wrong intent. Store a hash of the request body with the key and reject mismatches with a 422.</p>
<p><strong>No expiration.</strong> Idempotency keys should expire (24-48 hours is typical for payment APIs). Keeping them forever bloats the table and risks legitimate reuse of a UUID being blocked incorrectly, however unlikely.</p>
<p><strong>Treating "processing" as "not found."</strong> If a second request arrives while the first is still mid-flight (not a retry after failure, but genuine concurrency), returning 409 (Conflict) or making the client poll is safer than letting both proceed.</p>
<p><strong>Scoping keys per-endpoint, not globally.</strong> The same key sent to two different endpoints should not collide. Compose the lookup on <code>(key, endpoint)</code> or <code>(key, account_id)</code>, not just <code>key</code> alone.</p>
<p><strong>Only applying this to POST.</strong> PUT and PATCH can double-apply too if not naturally idempotent (e.g., "increment balance by 100" instead of "set balance to 100"). Prefer absolute updates over relative ones where money is involved, and use idempotency keys for both.</p>
<h2>When You Don't Need This</h2>
<p>Truly idempotent operations by construction (a PUT that sets absolute state, a GET, a DELETE by ID) don't need a key — retrying them is already safe. Idempotency keys matter specifically when an operation has a side effect that isn't naturally safe to repeat: charging money, sending an email, decrementing inventory, creating a resource with server-generated side effects.</p>
<blockquote>
<p>Rule of thumb: any endpoint that moves money, sends a notification, or creates a resource that shouldn't be duplicated needs an idempotency key. Everything else, question whether you're overengineering.</p>
</blockquote>]]></content:encoded>
                                </item>
                <item>
            <title>Database Deadlocks: Detection, Diagnosis, and Prevention</title>
            <link>https://cslant.com/tips/database-deadlocks-detection-diagnosis-and-prevention</link>
            <guid isPermaLink="false">https://cslant.com/tips/database-deadlocks-detection-diagnosis-and-prevention</guid>
                        <pubDate>Mon, 10 Aug 2026 09:00:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>How relational databases detect deadlocks with wait-for graphs, why they happen, and concrete techniques to prevent them in high-concurrency systems.</description>
                                    <content:encoded><![CDATA[<p>A deadlock happens when two or more transactions each hold a lock the other needs, and neither can proceed. Databases don't let this hang forever — they detect it and kill one of the transactions. Understanding how that detection works, and how to avoid triggering it in the first place, is a core skill for anyone writing high-concurrency database code.</p>
<h2>The Classic Deadlock</h2>
<p>Two transactions, two rows, opposite order:</p>
<pre><code class="language-sql">-- Transaction A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ... pauses ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Transaction B (running concurrently)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- ... pauses ...
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
</code></pre>
<p>If A locks row 1 and B locks row 2 at nearly the same time, A's second statement waits for B to release row 2, while B's second statement waits for A to release row 1. Neither can ever proceed.</p>
<h2>Wait-For Graphs: How Databases Detect This</h2>
<p>Most databases (PostgreSQL, MySQL/InnoDB, SQL Server) model lock waits as a directed graph: each transaction is a node, and an edge from T1 to T2 means "T1 is waiting on a lock held by T2." A deadlock exists exactly when this graph contains a cycle.</p>
<div class="page-mermaid"><div class="mermaid">graph TD
    A[Transaction A] --&gt;|waits for row 2| B[Transaction B]
    B[Transaction B] --&gt;|waits for row 1| A[Transaction A]
    A -.cycle detected.-&gt; C[Deadlock!]
    C --&gt; D[Database picks a victim]
    D --&gt; E[Victim transaction rolled back]
    E --&gt; F[Survivor proceeds]</div></div>
<p>Periodically (or on every new lock wait, depending on the engine), the database walks this graph looking for cycles. When it finds one, it picks a <strong>victim</strong> — usually the transaction that has done the least work, or holds the fewest locks — and rolls it back, releasing its locks so the other transaction can continue.</p>
<h2>What the Error Actually Looks Like</h2>
<pre><code class="language-text">ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5678.
Process 5678 waits for ShareLock on transaction 1234; blocked by process 1234.
HINT: See server log for query details.
</code></pre>
<p>The application's job is to catch this specific error and retry the transaction — a deadlock victim is not a bug, it's the database correctly resolving an unavoidable conflict.</p>
<pre><code class="language-python">import time

def run_with_retry(fn, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            return fn()
        except DeadlockDetected:
            if attempt == max_attempts - 1:
                raise
            time.sleep(0.05 * (2 ** attempt))  # exponential backoff
</code></pre>
<h2>Deadlocks vs. Ordinary Lock Waits</h2>
<p>An ordinary lock wait resolves itself once the holding transaction commits or rolls back — no cycle, no error, just a delay. A deadlock is fundamentally different: without intervention, it never resolves on its own, because every party in the cycle is waiting on another party in the same cycle. This is why detection has to be active (scanning for cycles), not passive (just waiting things out).</p>
<h2>Prevention: Consistent Lock Ordering</h2>
<p>The single most effective prevention technique is to always acquire locks in the same order across every transaction that touches the same set of resources. If both transactions above had updated row 1 before row 2, the second one to arrive would simply wait for the first to finish — no cycle possible.</p>
<pre><code class="language-sql">-- Both transactions now lock in ascending id order — no deadlock possible
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(1, 2);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(1, 2);
</code></pre>
<p>In practice this often means sorting rows by primary key before issuing a batch of updates, or centralizing the lock-acquisition order in a single code path rather than leaving it to each caller.</p>
<h2>Prevention: Keep Transactions Short</h2>
<p>Long-running transactions hold locks longer, which widens the window during which a conflicting transaction can show up and create a cycle. Moving non-essential work (sending emails, calling external APIs, heavy computation) outside the transaction boundary shrinks that window significantly.</p>
<h2>Prevention: Lower Isolation Where Safe</h2>
<p>Higher isolation levels take more locks for longer. <code>SERIALIZABLE</code> is the most deadlock-prone; <code>READ COMMITTED</code> takes fewer, shorter-held locks. Not every operation needs the strongest guarantee — evaluate whether a lower isolation level is safe for a given transaction before defaulting to the strictest one everywhere.</p>
<h2>Prevention: Smaller Lock Footprint</h2>
<p>Locking an entire table when you only need a few rows multiplies the chance of collision. Use indexed WHERE clauses so the database can take row-level (not table-level) locks, and avoid <code>SELECT ... FOR UPDATE</code> over more rows than the transaction actually intends to modify.</p>
<h2>Diagnosing Recurring Deadlocks</h2>
<p>When deadlocks show up repeatedly in production, look at the database's deadlock log (PostgreSQL logs full lock and query detail when <code>log_lock_waits</code> is on; MySQL exposes <code>SHOW ENGINE INNODB STATUS</code>). The recurring pattern is almost always the same two code paths acquiring the same two resources in opposite order — find those two call sites and fix the ordering, rather than just adding retry logic and hoping.</p>
<h2>Common Pitfalls</h2>
<p>Treating deadlocks purely as something to retry around, without ever fixing the underlying lock-order conflict, just moves the cost to increased latency and wasted work under load. Assuming an ORM's default query patterns are deadlock-safe is risky — many ORMs issue related updates in whatever order model associations happen to be traversed, which can vary between requests. And forgetting to add retry logic entirely means a deadlock becomes a user-facing error instead of an invisible, automatically-recovered hiccup.</p>
<blockquote>
<p>A deadlock isn't a failure of the database — it's the database correctly refusing to let two transactions wait on each other forever. The fix lives in your application's lock ordering, not in disabling detection.</p>
</blockquote>]]></content:encoded>
                                </item>
                <item>
            <title>RedGo – Ride-Hailing, Delivery &amp; Food App</title>
            <link>https://cslant.com/works/redgo-ride-hailing-delivery-food-super-app</link>
            <guid isPermaLink="false">https://cslant.com/works/redgo-ride-hailing-delivery-food-super-app</guid>
                        <pubDate>Mon, 27 Jul 2026 09:00:00 +0700</pubDate>
                                                <category>Mobile App</category>
                                    <description>All-in-one super app landing page for ride-hailing, food &amp; drink delivery, and package delivery in Vietnam — built for RedGo by CSlant × Wztek.</description>
                                    <content:encoded><![CDATA[RedGo is a Vietnam-based app combining ride-hailing, food delivery, package delivery, and drink ordering into a single platform. CSlant partnered with Wztek to design and build RedGo's marketing website simultaneously with its iOS and Android app launch.

The site highlights RedGo's four core services — ride-hailing, food delivery, package delivery, and drinks — through interactive product mockups showing live order tracking, driver matching, and checkout flows. Dedicated sections speak to riders and shoppers as well as prospective drivers and restaurant partners, with clear calls to action to download the app.

Built with a modern, mobile stack and optimized for performance and SEO, the site serves to showcase the RedGo ecosystem, letting people know about our services and guiding them to the mobile app where the core experience takes place.]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/portfolio/images/redgo-homepage-image.png" medium="image" />
                    </item>
                <item>
            <title>Distributed Locking with Redis: Understanding the Redlock Algorithm</title>
            <link>https://cslant.com/tips/distributed-locking-with-redis-understanding-the-redlock-algorithm</link>
            <guid isPermaLink="false">https://cslant.com/tips/distributed-locking-with-redis-understanding-the-redlock-algorithm</guid>
                        <pubDate>Fri, 24 Jul 2026 19:00:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>A deep dive into the Redlock algorithm for safe distributed locking across multiple Redis nodes, including failure modes, clock drift, and the fencing token fix.</description>
                                    <content:encoded><![CDATA[<p>A single Redis instance makes a fine lock for a single-process app, but the moment you have multiple services racing to acquire the same lock — and that Redis instance can fail — a naive <code>SETNX</code> isn't safe anymore. <strong>Redlock</strong>, proposed by Redis's creator, is an algorithm for acquiring a distributed lock across multiple independent Redis nodes so that no single node's failure or slowness can silently break mutual exclusion.</p>
<blockquote>
<p>This is genuinely advanced material — understanding it requires comfort with distributed systems failure modes, not just Redis commands.</p>
</blockquote>
<h2>Why a Single Redis Lock Isn't Enough</h2>
<p>A basic lock looks simple:</p>
<pre><code class="language-bash">SET resource:order-42 my-random-value NX PX 30000
</code></pre>
<p>This sets the key only if it doesn't exist (<code>NX</code>), with a 30-second expiry (<code>PX</code>) so a crashed client doesn't hold the lock forever. The problem is that this single Redis node is a single point of failure. If it goes down before replicating the write to a replica, and the replica gets promoted to master, another client can acquire the "same" lock — because the new master never saw the key.</p>
<h2>The Redlock Algorithm</h2>
<p>Redlock solves this by using <strong>N independent Redis masters</strong> (the reference implementation uses 5), with no replication or coordination between them. A client wanting the lock does the following:</p>
<ol>
<li>Get the current time in milliseconds.</li>
<li>Try to acquire the lock on all N instances sequentially, using the same key and a random value, with a small timeout per instance so a down node doesn't stall the whole process.</li>
<li>Compute elapsed time. The lock is considered acquired only if the client got the lock on a <strong>majority</strong> (N/2 + 1) of instances, and the total elapsed time is less than the lock's validity time.</li>
<li>If acquired, the effective validity time is the original TTL minus the elapsed time and clock drift.</li>
<li>If the lock wasn't acquired, release it on every instance immediately, whether or not that instance thought it succeeded.</li>
</ol>
<div class="page-mermaid"><div class="mermaid">graph TD
    A[Client wants lock] --&gt; B[Record start time T1]
    B --&gt; C[Try SET NX PX on Redis Node 1]
    B --&gt; D[Try SET NX PX on Redis Node 2]
    B --&gt; E[Try SET NX PX on Redis Node 3]
    B --&gt; F[Try SET NX PX on Redis Node 4]
    B --&gt; G[Try SET NX PX on Redis Node 5]
    C --&gt; H{Count successes}
    D --&gt; H
    E --&gt; H
    F --&gt; H
    G --&gt; H
    H --&gt;|Majority acquired AND time OK| I[Lock acquired]
    H --&gt;|Majority failed OR time expired| J[Release lock on all nodes]
    J --&gt; K[Retry after random backoff]</div></div>
<h2>Why Majority Matters</h2>
<p>Requiring a majority (not all N) means Redlock tolerates the failure of a minority of nodes — with 5 nodes, up to 2 can be down or unreachable and the lock still works correctly, the same fault-tolerance principle behind Raft and Paxos-based systems. It also prevents split-brain: two clients can't both get a majority of 5 nodes simultaneously, because any two majorities of 5 must overlap by at least one node.</p>
<h2>The Random Value and Safe Release</h2>
<p>Each client generates a unique random value for its lock attempt (not just any placeholder). Releasing the lock must check that the stored value still matches before deleting it, done atomically via a Lua script:</p>
<pre><code class="language-lua">if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
</code></pre>
<p>Without this check, a client could accidentally delete a lock it no longer owns — for example, if its own lock expired and a different client acquired it in the meantime.</p>
<h2>Clock Drift: The Real Weakness</h2>
<p>Redlock's correctness assumes clocks across the N nodes don't drift too far apart relative to the lock's TTL. If a node's clock jumps forward unexpectedly (a bad NTP sync, a VM pause-and-resume, manual clock changes), that node might expire a lock much earlier than the others believe, opening a window where two clients think they hold the lock simultaneously. This is the core of Martin Kleppmann's well-known critique of Redlock: it depends on real-time clock behavior in ways that are hard to fully guarantee.</p>
<h2>Fencing Tokens: The Practical Fix</h2>
<p>Even with Redlock done correctly, a paused client (GC pause, VM suspend, network partition) can wake up after its lock has expired and still act as if it holds it — writing to a shared resource after another client has already acquired the lock and moved on. The standard mitigation is a <strong>fencing token</strong>: a monotonically increasing number returned every time a lock is granted.</p>
<pre><code class="language-text">Client A acquires lock, gets token 33
Client A pauses (GC, network delay...)
Client A's lock expires
Client B acquires lock, gets token 34
Client B writes to storage, tagging the write with token 34
Client A wakes up, tries to write with token 33
Storage rejects it: 33 &lt; 34 (already saw a higher token)
</code></pre>
<p>The protected resource itself — a database, a file store, an API — must be the one enforcing the token check. Redlock alone cannot make this guarantee; it can only reduce the <em>likelihood</em> of two clients believing they hold the lock at once.</p>
<h2>When to Use Redlock (and When Not To)</h2>
<p>Redlock is a reasonable choice for <strong>efficiency locks</strong> — cases where a duplicate execution is wasteful but not catastrophic, like preventing the same cron-triggered job from running twice, or coalescing duplicate cache-rebuild requests. It is a risky choice for <strong>correctness locks</strong> — cases where a duplicate execution corrupts data, like coordinating writes to a financial ledger. For those, use a system with strong consistency guarantees (a consensus-based lock service like ZooKeeper or etcd) combined with fencing tokens enforced by the resource itself, not just Redis.</p>
<h2>Common Pitfalls</h2>
<p>Using an even number of nodes defeats the majority-quorum logic that makes Redlock resilient — always use an odd number so a majority is unambiguous. Setting per-node acquisition timeouts too high means a single slow node can blow past your lock's TTL before you've even finished the acquisition phase. And treating Redlock as a strict correctness guarantee, rather than a best-effort mutual exclusion mechanism, is the mistake that leads to production incidents — pair it with fencing tokens whenever the operation it protects actually matters.</p>
<blockquote>
<p>Redlock reduces the probability of a broken lock; it does not eliminate it. Design your protected operations to survive the case where the lock briefly fails.</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY4R72TK8Z1BS5X9DP6R75Q4.png" medium="image" />
                    </item>
                <item>
            <title>Git Interactive Rebase: Clean Up Your Commit History Before a PR</title>
            <link>https://cslant.com/tips/git-interactive-rebase-clean-up-your-commit-history-before-a-pr</link>
            <guid isPermaLink="false">https://cslant.com/tips/git-interactive-rebase-clean-up-your-commit-history-before-a-pr</guid>
                        <pubDate>Thu, 23 Jul 2026 18:15:00 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>Learn how to use git rebase -i to squash, reorder, and edit commits so your pull requests tell a clean, reviewable story.</description>
                                    <content:encoded><![CDATA[<p>Every developer who's shipped a "WIP", "fix typo", "actually fix it", "oops" string of commits knows the pain of opening a pull request and seeing a commit history that tells no story at all. <strong>Interactive rebase</strong> is the tool that turns that mess into a clean, linear narrative your reviewers can actually follow.</p>
<h2>Why Clean History Matters</h2>
<p>A pull request's commit history isn't just a log — it's documentation. Future developers (including you, in six months) will use <code>git blame</code> and <code>git log</code> to understand <em>why</em> a change was made. A history full of noise makes that archaeology painful.</p>
<blockquote>
<p>A clean commit history is a gift to your future self and your reviewers.</p>
</blockquote>
<h2>The Basic Command</h2>
<pre><code class="language-bash">git rebase -i HEAD~5
</code></pre>
<p>This opens your last 5 commits in an editor, each prefixed with <code>pick</code>. From here you can reorder lines, or swap <code>pick</code> for one of several rebase commands.</p>
<h2>The Rebase Commands</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>Shorthand</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td>pick</td>
<td>p</td>
<td>Keep the commit as-is</td>
</tr>
<tr>
<td>reword</td>
<td>r</td>
<td>Keep changes, edit the commit message</td>
</tr>
<tr>
<td>edit</td>
<td>e</td>
<td>Pause here to amend the commit</td>
</tr>
<tr>
<td>squash</td>
<td>s</td>
<td>Merge into the previous commit, combine messages</td>
</tr>
<tr>
<td>fixup</td>
<td>f</td>
<td>Merge into the previous commit, discard this message</td>
</tr>
<tr>
<td>drop</td>
<td>d</td>
<td>Remove the commit entirely</td>
</tr>
</tbody>
</table>
<h2>How It Works Under the Hood</h2>
<p>Rebase doesn't move commits — it replays them. Git walks your branch's commits one at a time, applying each as a patch on top of the new base, and builds brand-new commit objects (with new SHAs) along the way.</p>
<div class="page-mermaid"><div class="mermaid">graph TD
    A[Start: git rebase -i HEAD~5] --&gt; B[Git opens commit list in editor]
    B --&gt; C{You edit each line}
    C --&gt;|pick| D[Commit kept as-is]
    C --&gt;|reword| E[Edit message only]
    C --&gt;|squash/fixup| F[Merge into previous commit]
    C --&gt;|drop| G[Commit removed]
    D --&gt; H[Git replays commits onto new base]
    E --&gt; H
    F --&gt; H
    G --&gt; H
    H --&gt; I[New commit history with new SHAs]
    I --&gt; J[Force-push to update remote branch]</div></div>
<h2>A Real Example</h2>
<p>Say your branch has this history before opening a PR:</p>
<pre><code class="language-text">a1b2c3d WIP
e4f5g6h fix typo
h7i8j9k actually add the feature
k0l1m2n oops forgot a file
</code></pre>
<p>Run <code>git rebase -i HEAD~4</code>, then change the plan to:</p>
<pre><code class="language-text">pick h7i8j9k actually add the feature
fixup k0l1m2n oops forgot a file
fixup a1b2c3d WIP
fixup e4f5g6h fix typo
</code></pre>
<p>Save and exit — Git squashes all four into a single, well-described commit.</p>
<h2>Editing a Commit Message Mid-Rebase</h2>
<p>Mark a commit <code>reword</code>, and after saving the plan, Git will stop and open your editor just for that commit's message:</p>
<pre><code class="language-bash">git rebase -i HEAD~3
# change 'pick' to 'reword' on the line you want to rename
# Git opens your editor — update the message, save, close
# rebase continues automatically
</code></pre>
<h2>Splitting or Amending a Commit</h2>
<p>Mark a commit <code>edit</code> to pause the rebase right after it's applied:</p>
<pre><code class="language-bash">git rebase -i HEAD~3
# mark the target commit as 'edit'

# rebase pauses here — make your changes
git add &lt;file&gt;
git commit --amend

# then continue
git rebase --continue
</code></pre>
<h2>Handling Conflicts</h2>
<p>Since rebase replays commits one by one, conflicts can surface at any step:</p>
<pre><code class="language-bash"># fix the conflicting files
git add &lt;resolved-files&gt;
git rebase --continue

# or bail out entirely and go back to where you started
git rebase --abort
</code></pre>
<h2>Pushing Rebased History</h2>
<p>Because rebase rewrites commit SHAs, a normal <code>git push</code> will be rejected. You need a force push — but use the safer variant:</p>
<pre><code class="language-bash">git push --force-with-lease
</code></pre>
<p><code>--force-with-lease</code> refuses to overwrite the remote branch if someone else has pushed to it since your last fetch, protecting teammates from losing work — unlike a plain <code>--force</code>.</p>
<h2>When to Use Which</h2>
<p>Use <strong>squash</strong> when a commit's changes belong with the previous one but the message itself still adds context worth keeping. Use <strong>fixup</strong> for pure "oops" commits where the message adds nothing. Use <strong>reword</strong> when the code is fine but the message is misleading or too terse. Use <strong>drop</strong> for commits that turned out to be dead ends, like an experiment you abandoned.</p>
<h2>Common Pitfalls</h2>
<p>Never rebase commits that other people have already pulled and built on top of — you'll rewrite history out from under them and create painful merge conflicts on their end. Rebase is for <strong>local or PR-branch cleanup before merge</strong>, not for branches already shared and built upon. Also double-check your rebase plan before saving; a misplaced <code>drop</code> silently deletes a commit's changes, though <code>git reflog</code> can usually recover it if you catch the mistake quickly.</p>
<blockquote>
<p>Interactive rebase rewrites history — that's exactly why it's powerful, and exactly why it should stay confined to branches only you (or your PR) are working on.</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY4QA8WY3TSDNK0FGP7X3DXT.png" medium="image" />
                    </item>
                <item>
            <title>Redis Caching Strategies: Cache-Aside vs Write-Through</title>
            <link>https://cslant.com/tips/redis-caching-strategies-cache-aside-vs-write-through</link>
            <guid isPermaLink="false">https://cslant.com/tips/redis-caching-strategies-cache-aside-vs-write-through</guid>
                        <pubDate>Wed, 22 Jul 2026 18:30:00 +0700</pubDate>
                                                <category>Database</category>
                                    <description>Compare cache-aside and write-through caching with Redis, including consistency trade-offs, TTL strategy, and when to use each pattern.</description>
                                    <content:encoded><![CDATA[<p>Redis makes caching easy to bolt on, but the pattern you choose determines how consistent, resilient, and performant your system actually is. The two most common patterns — <strong>cache-aside</strong> and <strong>write-through</strong> — solve the same problem in fundamentally different ways.</p>
<h2>Cache-Aside (Lazy Loading)</h2>
<p>In cache-aside, the application talks to the cache directly, and the cache stays passive. On a read, the app checks Redis first; on a miss, it loads from the database and populates the cache. Writes go straight to the database, and the cache entry is invalidated (or updated) afterward.</p>
<pre><code class="language-python">def get_user(user_id):
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)

    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    return user

def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.delete(f"user:{user_id}")  # invalidate, don't update
</code></pre>
<h2>Write-Through</h2>
<p>In write-through, the cache sits in front of the database and owns the write path. Every write goes to the cache first, which synchronously writes through to the database before returning. Reads always come from the cache since it's guaranteed to be up to date.</p>
<pre><code class="language-python">def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))  # update, not invalidate
    return data
</code></pre>
<h2>How the Two Patterns Differ</h2>
<div class="page-mermaid"><div class="mermaid">graph TD
    A[Application] --&gt;|Read| B{Cache-Aside}
    B --&gt;|Hit| C[Return from Redis]
    B --&gt;|Miss| D[Query Database]
    D --&gt; E[Populate Redis]
    E --&gt; C

    F[Application] --&gt;|Write| G{Write-Through}
    G --&gt; H[Write to Redis]
    H --&gt; I[Write to Database]
    I --&gt; J[Confirm to App]</div></div>
<h2>Consistency Trade-offs</h2>
<p>Cache-aside has a small window where the cache can serve stale data: if two requests race between the database write and the cache invalidation, a reader might get an old value. Write-through avoids this because the cache and database are updated together in the same operation — but that comes at the cost of every write now waiting on two systems instead of one.</p>
<blockquote>
<p>Cache-aside optimizes for read throughput and simplicity. Write-through optimizes for consistency at the cost of write latency.</p>
</blockquote>
<h2>TTL Strategy</h2>
<p>Even with write-through, set a TTL as a safety net — bugs, race conditions, and out-of-band database writes (migrations, admin scripts) can all leave the cache holding stale data indefinitely without one.</p>
<pre><code class="language-python">CACHE_TTL_SECONDS = 3600  # 1 hour

redis.setex(key, CACHE_TTL_SECONDS, value)
</code></pre>
<p>A short TTL (minutes) suits volatile data like inventory counts or session state. A longer TTL (hours) suits data that changes rarely, like user profiles or product catalogs.</p>
<h2>Handling Cache Stampedes</h2>
<p>When a hot key expires, dozens of concurrent requests can all miss the cache at once and hammer the database simultaneously. A simple mitigation is a short-lived lock so only one request repopulates the cache while the rest wait:</p>
<pre><code class="language-python">def get_user(user_id):
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)

    lock = redis.set(f"lock:user:{user_id}", "1", nx=True, ex=5)
    if lock:
        user = db.query("SELECT * FROM users WHERE id = %s", user_id)
        redis.setex(f"user:{user_id}", 3600, json.dumps(user))
        redis.delete(f"lock:user:{user_id}")
        return user
    else:
        time.sleep(0.05)
        return get_user(user_id)  # retry, likely a hit now
</code></pre>
<h2>Write-Behind: A Third Option</h2>
<p>Worth knowing, though outside this tip's main comparison: <strong>write-behind</strong> (write-back) caching writes to Redis immediately and asynchronously flushes to the database later. It gives the lowest write latency but risks data loss if the cache crashes before flushing — generally reserved for high-throughput analytics or logging pipelines, not systems of record.</p>
<h2>When to Use Which</h2>
<p>Reach for <strong>cache-aside</strong> when reads vastly outnumber writes and brief staleness is acceptable — product listings, user profiles, configuration data. Reach for <strong>write-through</strong> when correctness matters more than write speed and you can't tolerate cache/database drift — pricing data, inventory levels, anything downstream systems depend on being accurate.</p>
<h2>Common Pitfalls</h2>
<p>Forgetting to invalidate the cache on delete, not just update, leaves ghost entries that outlive the data they represent. Using a single global TTL for wildly different data types wastes memory on rarely-changing data and creates stampedes on frequently-changing data with the same expiry window. And treating Redis as a source of truth instead of a cache is a design smell — if losing the cache would lose data, it isn't a cache anymore.</p>
<blockquote>
<p>The right caching strategy is the one whose failure mode you can live with: cache-aside fails toward staleness, write-through fails toward latency. Pick based on what your system can actually tolerate.</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY4QKNH32X4257D3J855504K.png" medium="image" />
                    </item>
                <item>
            <title>TypeScript Utility Types: Pick, Omit, and Partial Explained</title>
            <link>https://cslant.com/tips/typescript-utility-types-pick-omit-and-partial-explained</link>
            <guid isPermaLink="false">https://cslant.com/tips/typescript-utility-types-pick-omit-and-partial-explained</guid>
                        <pubDate>Wed, 22 Jul 2026 17:42:00 +0700</pubDate>
                                                <category>JavaScript</category>
                                    <description>Stop duplicating interfaces for every slightly-different shape. Pick, Omit, and Partial let you derive new types from one source of truth — here&#039;s when to reach for each.</description>
                                    <content:encoded><![CDATA[<h1>TypeScript Utility Types: Pick, Omit, and Partial Explained</h1>
<p>Every API needs a slightly different shape of the same data: the full <code>User</code>, a public-safe <code>User</code> without the password hash, a form that only updates two fields. Copy-pasting the interface for each case means they drift apart the moment one gets updated. TypeScript's built-in utility types solve this by deriving new types from one source of truth.</p>
<h2>The Problem</h2>
<pre><code class="language-ts">interface User {
  id: string
  name: string
  email: string
  passwordHash: string
  createdAt: Date
}

// Duplicated, drifts out of sync with User over time
interface PublicUser {
  id: string
  name: string
  email: string
  createdAt: Date
}
</code></pre>
<p>Every time <code>User</code> changes, someone has to remember to update <code>PublicUser</code> too. They won't.</p>
<h2>Partial&lt;T&gt;: Make Every Field Optional</h2>
<p>Perfect for update payloads, where the caller only sends the fields that changed.</p>
<pre><code class="language-ts">function updateUser(id: string, changes: Partial&lt;User&gt;) {
  // changes might be { name: 'New Name' } or { email: '...' }
  return db.users.update(id, changes)
}
</code></pre>
<h2>Pick&lt;T, K&gt;: Select a Subset of Fields</h2>
<p>Use it when you only need a few fields from a larger type — a preview card, a dropdown option.</p>
<pre><code class="language-ts">type UserPreview = Pick&lt;User, 'id' | 'name'&gt;
// { id: string; name: string }
</code></pre>
<h2>Omit&lt;T, K&gt;: Exclude Specific Fields</h2>
<p>The mirror image of <code>Pick</code> — keep everything except what you list. Ideal for stripping sensitive fields before sending a response.</p>
<pre><code class="language-ts">type PublicUser = Omit&lt;User, 'passwordHash'&gt;
// { id: string; name: string; email: string; createdAt: Date }
</code></pre>
<p>Now <code>PublicUser</code> always tracks <code>User</code> automatically. Add a field to <code>User</code>, and it shows up in <code>PublicUser</code> without touching this line.</p>
<h2>Combining Them</h2>
<p>Utility types compose. A "rename form" that only lets you edit the name:</p>
<pre><code class="language-ts">type RenameForm = Partial&lt;Pick&lt;User, 'name'&gt;&gt;
// { name?: string }
</code></pre>
<h2>How the Types Derive From One Source</h2>
<div class="page-mermaid"><div class="mermaid">graph TD
    A["User (source of truth)"] --&gt; B["Partial&amp;lt;User&amp;gt;&lt;br/&gt;all fields optional"]
    A --&gt; C["Pick&amp;lt;User, 'id' | 'name'&amp;gt;&lt;br/&gt;only listed fields"]
    A --&gt; D["Omit&amp;lt;User, 'passwordHash'&amp;gt;&lt;br/&gt;everything except listed"]
    C --&gt; E["Partial&amp;lt;Pick&amp;lt;User,...&amp;gt;&amp;gt;&lt;br/&gt;subset, all optional"]</div></div>
<h2>When to Use Which</h2>
<ul>
<li><strong>Partial&lt;T&gt;</strong>: PATCH/update payloads, optional config objects, form state before submission.</li>
<li><strong>Pick&lt;T, K&gt;</strong>: previews, dropdown options, anywhere you need a deliberately small, explicit subset.</li>
<li><strong>Omit&lt;T, K&gt;</strong>: stripping sensitive or internal fields (passwords, internal IDs) while keeping everything else in sync automatically.</li>
</ul>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Omit with a renamed/removed key silently succeeds</strong> — TypeScript won't error if you omit a key that was already removed from the base type, so typos in the key list can go unnoticed. Double-check the literal keys against the source type.</li>
<li><strong>Partial hides genuinely required fields</strong> — a <code>Partial&lt;User&gt;</code> update function can't tell "the caller forgot <code>id</code>" from "the caller doesn't want to change <code>id</code>." Keep required identifiers (like <code>id</code>) outside the <code>Partial</code> wrapper: <code>{ id: string } &amp; Partial&lt;Omit&lt;User, 'id'&gt;&gt;</code>.</li>
<li><strong>Reaching for <code>Record&lt;string, unknown&gt;</code></strong> instead of <code>Pick</code>/<code>Omit</code> throws away type safety entirely — utility types keep the connection to the original shape, a loose <code>Record</code> doesn't.</li>
</ul>
<blockquote>
<p>Derive, don't duplicate. If two types describe the same entity, one of them should be built from the other with <code>Pick</code>, <code>Omit</code>, or <code>Partial</code> — not maintained by hand.</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY4PZN00MNK28M74AHNVN9SB.png" medium="image" />
                    </item>
                <item>
            <title>Rate Limiting APIs: Token Bucket vs Sliding Window</title>
            <link>https://cslant.com/tips/rate-limiting-apis-token-bucket-vs-sliding-window</link>
            <guid isPermaLink="false">https://cslant.com/tips/rate-limiting-apis-token-bucket-vs-sliding-window</guid>
                        <pubDate>Tue, 21 Jul 2026 23:09:00 +0700</pubDate>
                                                <category>Security</category>
                                    <description>Fixed windows let clients burst 2x at the boundary. See how token bucket and sliding window rate limiting actually work, with Redis-backed implementations and the pitfalls that break them in production.</description>
                                    <content:encoded><![CDATA[<h1>Rate Limiting APIs: Token Bucket vs Sliding Window</h1>
<p>Without rate limiting, one noisy client can degrade an API for everyone else. But the naive approach to rate limiting has a nasty boundary bug that most teams don't discover until production.</p>
<h2>The Problem: Fixed Window Counter</h2>
<p>The simplest approach counts requests in a fixed time box and resets the counter every period:</p>
<pre><code class="language-js">// Naive fixed window with Redis
async function isAllowed(userId) {
  const key = `rl:${userId}:${Math.floor(Date.now() / 60000)}` // per-minute bucket
  const count = await redis.incr(key)
  await redis.expire(key, 60)
  return count &lt;= 100 // 100 req/min
}
</code></pre>
<p>This looks fine until a client sends 100 requests at 0:59 and another 100 at 1:00 — 200 requests in two seconds, twice the intended limit, simply because they straddle the window boundary.</p>
<h2>Token Bucket</h2>
<p>A bucket holds up to <code>N</code> tokens. Tokens refill at a fixed rate. Every request consumes one token; if the bucket is empty, the request is rejected. This allows short bursts (up to the bucket size) while still enforcing a strict average rate over time — it's what Stripe and AWS API Gateway use under the hood.</p>
<pre><code class="language-js">class TokenBucket {
  constructor(capacity, refillPerSecond) {
    this.capacity = capacity
    this.tokens = capacity
    this.refillPerSecond = refillPerSecond
    this.lastRefill = Date.now()
  }

  tryConsume() {
    this.refill()
    if (this.tokens &lt; 1) return false
    this.tokens -= 1
    return true
  }

  refill() {
    const now = Date.now()
    const elapsedSeconds = (now - this.lastRefill) / 1000
    this.tokens = Math.min(this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond)
    this.lastRefill = now
  }
}
</code></pre>
<h2>Sliding Window Counter</h2>
<p>A cheaper alternative to a full sliding log: weight the previous window's count by how much of it still "overlaps" the current moment.</p>
<pre><code class="language-js">// weighted average of previous + current fixed windows
const weight = 1 - (elapsedInCurrentWindow / windowSize)
const estimatedCount = previousWindowCount * weight + currentWindowCount
</code></pre>
<p>This smooths out the boundary burst problem without the memory cost of storing a timestamp per request.</p>
<h2>How a Rate-Limited Request Flows</h2>
<div class="page-mermaid"><div class="mermaid">graph TD
    A[Incoming Request] --&gt; B{Rate Limiter Check}
    B --&gt; C[Load Bucket State from Redis]
    C --&gt; D{Tokens Available?}
    D --&gt;|Yes| E[Consume Token]
    E --&gt; F[Forward Request to API]
    F --&gt; G[Return 200 + Rate Limit Headers]
    D --&gt;|No| H[Reject Request]
    H --&gt; I[Return 429 Too Many Requests]
    I --&gt; J[Set Retry-After Header]
    K[Background Refill Timer] --&gt; C</div></div>
<h2>When to Use Which</h2>
<ul>
<li><strong>Fixed window</strong>: simplest to implement, fine for coarse per-day/per-hour limits where bursts don't matter.</li>
<li><strong>Token bucket</strong>: the default choice for public APIs — allows reasonable bursts while holding a strict average rate.</li>
<li><strong>Sliding window</strong>: most accurate under sustained load, worth the extra complexity for strict SLAs or billing-sensitive limits.</li>
</ul>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Limiting by IP alone</strong> breaks for users behind shared NAT or corporate proxies — key on API key or user ID as well as IP.</li>
<li><strong>Skipping rate-limit headers</strong> (<code>X-RateLimit-Remaining</code>, <code>Retry-After</code>) forces clients to guess when they can retry, causing thundering-herd retries.</li>
<li><strong>Per-instance in-memory counters</strong> in a multi-server deployment mean each instance enforces the limit independently — a client can get N times the intended limit by hitting N different instances. Always back the counter with a shared store like Redis.</li>
<li><strong>Not rate-limiting authentication endpoints</strong> separately — login/signup routes need tighter limits than general API traffic to slow down credential stuffing.</li>
</ul>
<blockquote>
<p>Start with a token bucket backed by Redis. It's simple to reason about, allows reasonable bursts, and the same primitive scales from a single endpoint to a full API gateway.</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2RDQPD8ZN5EDJ680720T99.png" medium="image" />
                    </item>
                <item>
            <title>Next.js App Router: Server Components vs Client Components</title>
            <link>https://cslant.com/tips/nextjs-app-router-server-components-vs-client-components</link>
            <guid isPermaLink="false">https://cslant.com/tips/nextjs-app-router-server-components-vs-client-components</guid>
                        <pubDate>Tue, 14 Jul 2026 12:42:00 +0700</pubDate>
                                                <category>Next.js</category>
                                    <description>Server Components run only on the server; Client Components hydrate in the browser. See how Next.js App Router renders both, and when to actually reach for &#039;use client&#039;.</description>
                                    <content:encoded><![CDATA[<h1>Next.js App Router: Server Components vs Client Components</h1>
<p>In the Next.js App Router (13+), every component is a <strong>Server Component</strong> by default. This is a big shift from the old Pages Router, and it's the single most misunderstood part of modern Next.js.</p>
<h2>The Problem</h2>
<p>Most developers coming from Create React App or the Pages Router slap <code>'use client'</code> at the top of every file "just in case." That defeats the purpose of Server Components: you end up shipping the same amount of JavaScript to the browser as before, just with extra steps.</p>
<h2>Server Components (the default)</h2>
<p>Server Components render <strong>only on the server</strong>. No JS for them is sent to the browser. They can <code>await</code> data directly, touch a database, or use secrets safely, because that code never leaves the server.</p>
<pre><code class="language-tsx">// app/products/page.tsx
export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  })
  const products = await res.json()

  return (
    &lt;ul&gt;
      {products.map((p: { id: string; name: string }) =&gt; (
        &lt;li key={p.id}&gt;{p.name}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  )
}
</code></pre>
<p>No <code>useState</code>, no <code>useEffect</code>, no event handlers here — and no client-side fetch waterfall either.</p>
<h2>Client Components (<code>'use client'</code>)</h2>
<p>Client Components are needed for interactivity: hooks, event listeners, browser-only APIs (<code>window</code>, <code>localStorage</code>), or third-party libraries that rely on the DOM.</p>
<pre><code class="language-tsx">'use client'

import { useState } from 'react'

export default function LikeButton() {
  const [liked, setLiked] = useState(false)

  return (
    &lt;button onClick={() =&gt; setLiked(!liked)}&gt;
      {liked ? 'Liked' : 'Like'}
    &lt;/button&gt;
  )
}
</code></pre>
<p>The <code>'use client'</code> directive marks the <strong>boundary</strong> — everything imported below it also ships to the browser, so keep these components small and push them to the leaves of the tree.</p>
<h2>How Rendering Actually Flows</h2>
<p>On every request, the flow looks like this:</p>
<div class="page-mermaid"><div class="mermaid">graph TD
    A[Browser Request] --&gt; B[Next.js Server]
    B --&gt; C[Render Server Components]
    C --&gt; D[Fetch Data / DB / Secrets]
    D --&gt; E[Generate HTML + RSC Payload]
    E --&gt; F[Send Response to Browser]
    F --&gt; G[Paint HTML Immediately]
    F --&gt; H[Download Client JS Bundle]
    H --&gt; I[Hydrate Client Components Only]
    G --&gt; J[Fully Interactive Page]
    I --&gt; J</div></div>
<p>Two things happen in parallel once the response lands: the static HTML paints right away (fast first contentful paint), and only the Client Component "islands" get hydrated with JS. The Server Component output is never re-executed or hydrated in the browser.</p>
<h2>When to Use Which</h2>
<ul>
<li><strong>Server Component (default)</strong>: fetching data, reading from a database, using API keys/secrets, rendering static or mostly-static UI, reducing bundle size.</li>
<li><strong>Client Component (<code>'use client'</code>)</strong>: <code>useState</code>/<code>useReducer</code>, <code>useEffect</code>, event handlers (<code>onClick</code>, <code>onChange</code>), browser APIs, context providers, third-party UI libraries that touch the DOM.</li>
</ul>
<h2>Common Pitfalls</h2>
<ul>
<li><strong>Marking a whole page <code>'use client'</code></strong> because of one interactive button — push <code>'use client'</code> down to that button only, not the entire page.</li>
<li><strong>Importing a Server Component into a Client Component</strong> doesn't work the way you'd expect — pass the Server Component in as <code>children</code> or a prop instead of importing it directly.</li>
<li><strong>Fetching secrets inside a Client Component</strong> leaks them into the browser bundle. Keep API keys and DB calls in Server Components only.</li>
<li><strong>Forgetting <code>next: { revalidate }</code></strong> on <code>fetch</code> calls, which silently opts routes into fully static rendering when you actually wanted fresh data.</li>
</ul>
<blockquote>
<p>Default to Server Components everywhere. Only add <code>'use client'</code> at the smallest leaf component that truly needs interactivity — your bundle size (and your users) will thank you.</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2RBKPJ6Q3HFMY1D84RFK56.png" medium="image" />
                    </item>
                <item>
            <title>Debounce vs Throttle in JavaScript: Control How Often Code Runs</title>
            <link>https://cslant.com/tips/debounce-vs-throttle-in-javascript-control-how-often-code-runs</link>
            <guid isPermaLink="false">https://cslant.com/tips/debounce-vs-throttle-in-javascript-control-how-often-code-runs</guid>
                        <pubDate>Thu, 09 Jul 2026 07:25:56 +0700</pubDate>
                                                <category>JavaScript</category>
                                    <description>Scroll, resize, and keystroke events fire dozens of times a second. Learn how debounce and throttle rate-limit them — and which one to reach for.</description>
                                    <content:encoded><![CDATA[<p>Some events fire dozens of times a second — <code>scroll</code>, <code>resize</code>, <code>mousemove</code>, and every keystroke in a search box. If you run expensive work (an API call, a layout recalculation) on each one, you flood the main thread and the network. <strong>Debounce</strong> and <strong>throttle</strong> are two rate-limiting techniques that fix this — and they solve <em>different</em> problems.</p>
<h2>Debounce: wait until the storm passes</h2>
<p>A debounced function delays running until events <em>stop</em> coming for a set period. Every new event resets the timer, so it fires only once, after things go quiet.</p>
<p>Use it when you only care about the <strong>final</strong> state:</p>
<ul>
<li>Search-as-you-type (call the API after the user stops typing)</li>
<li>Validating a field after the user finishes editing</li>
<li>Autosaving a draft once edits pause</li>
</ul>
<pre><code class="language-js">function debounce(fn, delay = 300) {
  let timer;
  return (...args) =&gt; {
    clearTimeout(timer);
    timer = setTimeout(() =&gt; fn.apply(this, args), delay);
  };
}

const search = debounce((q) =&gt; fetch(`/api/search?q=${q}`), 300);
input.addEventListener('input', (e) =&gt; search(e.target.value));
</code></pre>
<p>Type "laravel" quickly and only <strong>one</strong> request goes out — 300 ms after the last keystroke.</p>
<h2>Throttle: run at a steady rate</h2>
<p>A throttled function runs at most once per interval, no matter how many events fire. It doesn't wait for quiet — it guarantees a regular cadence.</p>
<p>Use it when you want <strong>periodic</strong> updates during a continuous stream:</p>
<ul>
<li>Updating a scroll-progress bar</li>
<li>Recomputing layout on <code>resize</code></li>
<li>Rate-limiting rapid clicks</li>
</ul>
<pre><code class="language-js">function throttle(fn, interval = 200) {
  let last = 0;
  return (...args) =&gt; {
    const now = Date.now();
    if (now - last &gt;= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

const onScroll = throttle(() =&gt; updateProgressBar(), 200);
window.addEventListener('scroll', onScroll);
</code></pre>
<p>Scroll for 3 seconds and the bar updates ~15 times (every 200 ms), not on all 100+ scroll events.</p>
<h2>Which one do you need?</h2>
<p>Ask yourself: do you want the result <strong>after</strong> activity settles, or <strong>during</strong> it?</p>
<ul>
<li>"After it settles" → <strong>debounce</strong> (search, autosave, validation).</li>
<li>"At a steady rate while it happens" → <strong>throttle</strong> (scroll, resize, drag).</li>
</ul>
<p>In production, reach for a battle-tested implementation like <code>lodash.debounce</code> / <code>lodash.throttle</code> — they handle leading/trailing edges, cancellation, and <code>maxWait</code> for you.</p>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2R9QSPMMHW9TNGVMN8BFKD.png" medium="image" />
                    </item>
                <item>
            <title>Database Indexes: Turn a Full Scan Into an Instant Lookup</title>
            <link>https://cslant.com/tips/database-indexes-turn-a-full-scan-into-an-instant-lookup</link>
            <guid isPermaLink="false">https://cslant.com/tips/database-indexes-turn-a-full-scan-into-an-instant-lookup</guid>
                        <pubDate>Wed, 08 Jul 2026 11:21:45 +0700</pubDate>
                                                <category>Database</category>
                                    <description>See why a query without an index scans every row, how an index turns it into a fast lookup, and how to read EXPLAIN to tell the difference.</description>
                                    <content:encoded><![CDATA[<h1>Database Indexes: Turn a Full Scan Into an Instant Lookup</h1>
<p>A query that feels instant on 1,000 rows can crawl on 1,000,000. The usual reason: the database is doing a <strong>full table scan</strong> — reading every row to find the few you asked for. An <strong>index</strong> fixes that.</p>
<h2>Without an index: a full table scan</h2>
<pre><code class="language-sql">SELECT * FROM users WHERE email = 'ann@example.com';
</code></pre>
<p>With no index on <code>email</code>, the engine reads <strong>every</strong> row and compares <code>email</code>. On a large table that is O(n) work.</p>
<h2>With an index: a lookup</h2>
<pre><code class="language-sql">CREATE INDEX idx_users_email ON users (email);
</code></pre>
<p>An index is a sorted structure (usually a B-tree). The engine now navigates straight to the matching rows in about O(log n) — a handful of reads instead of millions.</p>
<h2>Read the plan with EXPLAIN</h2>
<pre><code class="language-sql">EXPLAIN SELECT * FROM users WHERE email = 'ann@example.com';
</code></pre>
<ul>
<li><code>type = ALL</code> (and "Using where") means a full scan — slow.</li>
<li><code>type = ref</code> or <code>const</code> with a <code>key</code> listed means the index is used — good.</li>
</ul>
<h2>Composite indexes follow a left-to-right rule</h2>
<pre><code class="language-sql">CREATE INDEX idx_orders_user_status ON orders (user_id, status);
</code></pre>
<p>This helps <code>WHERE user_id = ?</code> and <code>WHERE user_id = ? AND status = ?</code>, but <strong>not</strong> <code>WHERE status = ?</code> on its own — a query must use a <strong>left prefix</strong> of the indexed columns.</p>
<h2>When an index does NOT help</h2>
<ul>
<li><code>WHERE YEAR(created_at) = 2026</code> — wrapping the column in a function defeats the index. Use a range instead: <code>created_at &gt;= '2026-01-01' AND created_at &lt; '2027-01-01'</code>.</li>
<li><code>LIKE '%term'</code> with a leading wildcard cannot use a normal index.</li>
<li>Very low-selectivity columns (like a boolean) — a scan is sometimes cheaper than the index.</li>
</ul>
<h2>Key takeaways</h2>
<ul>
<li>No index means a full scan (O(n)); the right index means a lookup (O(log n)).</li>
<li>Index the columns you filter, join and sort on — not every column (indexes cost writes and storage).</li>
<li>Use <code>EXPLAIN</code> to confirm the index is actually used.</li>
<li>Keep filters sargable: never wrap an indexed column in a function.</li>
</ul>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2R81PB4K538EWF0RCJ9ZP3.png" medium="image" />
                    </item>
                <item>
            <title>Preventing SQL Injection in PHP</title>
            <link>https://cslant.com/tips/preventing-sql-injection-php</link>
            <guid isPermaLink="false">https://cslant.com/tips/preventing-sql-injection-php</guid>
                        <pubDate>Tue, 23 Jun 2026 14:41:52 +0700</pubDate>
                                                <category>PHP</category>
                                    <description>Learn essential techniques to protect your PHP applications from SQL injection attacks.</description>
                                    <content:encoded><![CDATA[<h1>Preventing SQL Injection in PHP</h1>
<p>SQL injection is one of the most dangerous security vulnerabilities. Here's how to prevent it.</p>
<h2>❌ Never Do This</h2>
<pre><code class="language-php">// VULNERABLE CODE - DO NOT USE!
$userId = $_GET['id'];
$query = "SELECT * FROM users WHERE id = $userId";
$result = mysqli_query($conn, $query);
</code></pre>
<p>An attacker can inject: <code>?id=1 OR 1=1</code> to access all records!</p>
<h2>✅ Use Prepared Statements (MySQLi)</h2>
<pre><code class="language-php">$userId = $_GET['id'];
$stmt = $conn-&gt;prepare("SELECT * FROM users WHERE id = ?");
$stmt-&gt;bind_param("i", $userId);
$stmt-&gt;execute();
$result = $stmt-&gt;get_result();
</code></pre>
<h2>✅ Use PDO with Prepared Statements</h2>
<pre><code class="language-php">$userId = $_GET['id'];
$stmt = $pdo-&gt;prepare("SELECT * FROM users WHERE id = :id");
$stmt-&gt;execute(['id' =&gt; $userId]);
$user = $stmt-&gt;fetch();
</code></pre>
<h2>✅ Laravel Query Builder (Automatic Protection)</h2>
<pre><code class="language-php">$userId = request('id');
$user = DB::table('users')-&gt;where('id', $userId)-&gt;first();
// or
$user = User::find($userId);
</code></pre>
<h2>Warning: Raw Queries</h2>
<p>Even in Laravel, be careful with raw queries:</p>
<pre><code class="language-php">// ❌ Still vulnerable
DB::select("SELECT * FROM users WHERE id = " . $userId);

// ✅ Use bindings
DB::select("SELECT * FROM users WHERE id = ?", [$userId]);
</code></pre>
<h2>Best Practices</h2>
<ol>
<li><strong>Always use parameterized queries</strong></li>
<li><strong>Never concatenate user input into SQL</strong></li>
<li><strong>Use ORM/Query builders when possible</strong></li>
<li><strong>Validate and sanitize input</strong></li>
<li><strong>Apply principle of least privilege to database users</strong></li>
</ol>
<h2>Real-World Impact</h2>
<blockquote>
<p>In 2023, SQL injection attacks accounted for 30% of all web application breaches.</p>
</blockquote>
<p>Don't be a statistic - protect your applications!</p>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2R61SPK7R0CG61RHG8Q0QW.png" medium="image" />
                    </item>
                <item>
            <title>Docker Multi-Stage Builds for Smaller Images</title>
            <link>https://cslant.com/tips/docker-multi-stage-builds</link>
            <guid isPermaLink="false">https://cslant.com/tips/docker-multi-stage-builds</guid>
                        <pubDate>Mon, 22 Jun 2026 14:41:52 +0700</pubDate>
                                                <category>DevOps</category>
                                    <description>Reduce your Docker image size dramatically using multi-stage builds.</description>
                                    <content:encoded><![CDATA[<h1>Docker Multi-Stage Builds</h1>
<p>Multi-stage builds allow you to create smaller, more secure Docker images by separating build and runtime environments.</p>
<h2>The Problem</h2>
<p>Traditional Dockerfile includes build tools in the final image:</p>
<pre><code class="language-dockerfile">FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
</code></pre>
<p>Result: ~1GB image with unnecessary build dependencies!</p>
<h2>The Solution: Multi-Stage Build</h2>
<pre><code class="language-dockerfile"># Build stage
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
</code></pre>
<p>Result: ~200MB image with only runtime dependencies!</p>
<h2>Benefits</h2>
<ul>
<li><strong>Smaller images</strong>: 5-10x size reduction</li>
<li><strong>Faster deployments</strong>: Less data to transfer</li>
<li><strong>Better security</strong>: Fewer attack surfaces</li>
<li><strong>Cleaner production</strong>: No build tools in production</li>
</ul>
<h2>For Laravel Applications</h2>
<pre><code class="language-dockerfile"># Composer dependencies
FROM composer:2 AS composer
WORKDIR /app
COPY composer.* ./
RUN composer install --no-dev --optimize-autoloader

# Node assets
FROM node:18 AS node
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production
FROM php:8.3-fpm-alpine
WORKDIR /var/www
COPY --from=composer /app/vendor ./vendor
COPY --from=node /app/public/build ./public/build
COPY . .
</code></pre>
<h2>Flow Diagram</h2>
<div class="page-mermaid"><div class="mermaid">graph LR
    A[Build Stage] --&gt; B[Install Dependencies]
    B --&gt; C[Build Application]
    C --&gt; D[Production Stage]
    D --&gt; E[Copy Only Required Files]
    E --&gt; F[Small Final Image]</div></div>
<blockquote>
<p>Multi-stage builds are a Docker best practice that every developer should use!</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2R4B577T14FR287KZRY3N1.png" medium="image" />
                    </item>
                <item>
            <title>PHP 8.4 Property Hooks Explained</title>
            <link>https://cslant.com/tips/php-8-4-property-hooks</link>
            <guid isPermaLink="false">https://cslant.com/tips/php-8-4-property-hooks</guid>
                        <pubDate>Sun, 21 Jun 2026 14:41:52 +0700</pubDate>
                                                <category>PHP</category>
                                    <description>Discover the new property hooks feature in PHP 8.4 that provides a cleaner way to add logic to property access.</description>
                                    <content:encoded><![CDATA[<h1>PHP 8.4 Property Hooks</h1>
<p>PHP 8.4 introduces <strong>property hooks</strong>, a powerful feature that allows you to add custom logic when getting or setting properties.</p>
<h2>Basic Example</h2>
<pre><code class="language-php">class User
{
    public string $name {
        get =&gt; strtoupper($this-&gt;name);
        set =&gt; ucfirst($value);
    }
}

$user = new User();
$user-&gt;name = 'john doe';
echo $user-&gt;name; // Output: JOHN DOE
</code></pre>
<h2>Computed Properties</h2>
<p>Create virtual properties without backing storage:</p>
<pre><code class="language-php">class Rectangle
{
    public function __construct(
        public float $width,
        public float $height
    ) {}

    public float $area {
        get =&gt; $this-&gt;width * $this-&gt;height;
    }
}
</code></pre>
<h2>Validation in Setters</h2>
<pre><code class="language-php">class Product
{
    private float $_price;

    public float $price {
        get =&gt; $this-&gt;_price;
        set {
            if ($value &lt; 0) {
                throw new InvalidArgumentException('Price must be positive');
            }
            $this-&gt;_price = $value;
        }
    }
}
</code></pre>
<h2>Benefits</h2>
<ul>
<li>✅ Cleaner than <code>__get()</code> and <code>__set()</code> magic methods</li>
<li>✅ Better IDE support and static analysis</li>
<li>✅ More explicit and readable code</li>
<li>✅ Type-safe property access</li>
</ul>
<blockquote>
<p>Property hooks make PHP objects more powerful while maintaining backward compatibility!</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2R08RSDF2ANMAAHZACAA4G.png" medium="image" />
                    </item>
                <item>
            <title>Laravel Query Optimization with Eager Loading</title>
            <link>https://cslant.com/tips/laravel-query-optimization-eager-loading</link>
            <guid isPermaLink="false">https://cslant.com/tips/laravel-query-optimization-eager-loading</guid>
                        <pubDate>Fri, 19 Jun 2026 14:41:52 +0700</pubDate>
                                                <category>Laravel</category>
                                    <description>Learn how to optimize database queries in Laravel by using eager loading to prevent N+1 query problems.</description>
                                    <content:encoded><![CDATA[<h1>Laravel Query Optimization with Eager Loading</h1>
<p>When working with Laravel's Eloquent ORM, one of the most common performance killers is the <strong>N+1 query problem</strong>. It appears the moment you load a collection of models and then touch a relationship inside a loop.</p>
<h2>The N+1 problem</h2>
<pre><code class="language-php">$posts = Post::all();            // 1 query

foreach ($posts as $post) {
    echo $post-&gt;author-&gt;name;    // +1 query PER post
}
</code></pre>
<p>Load 50 posts and you fire <strong>51 queries</strong>: one for the posts, plus one for every author. It is invisible in code review but deadly under load.</p>
<div class="page-mermaid"><div class="mermaid">flowchart TD
    A[Post all - 1 query] --&gt; B{Loop each post}
    B --&gt; C[post.author - query 2]
    B --&gt; D[post.author - query 3]
    B --&gt; E[post.author - query 51]
    style C fill:#fee2e2,stroke:#ef4444
    style D fill:#fee2e2,stroke:#ef4444
    style E fill:#fee2e2,stroke:#ef4444</div></div>
<h2>The fix: eager loading with with()</h2>
<pre><code class="language-php">$posts = Post::with('author')-&gt;get();   // 2 queries total

foreach ($posts as $post) {
    echo $post-&gt;author-&gt;name;            // no extra queries
}
</code></pre>
<p>Laravel runs one query for posts and a <strong>single</strong> <code>WHERE author_id IN (...)</code> for all authors - 2 queries no matter how many posts.</p>
<h2>Nested &amp; multiple relationships</h2>
<pre><code class="language-php">// Multiple relations
$posts = Post::with(['author', 'comments'])-&gt;get();

// Nested relations (dot notation)
$posts = Post::with('comments.user')-&gt;get();

// Constrain a relation with a closure
$posts = Post::with(['comments' =&gt; fn ($q) =&gt; $q-&gt;latest()-&gt;limit(5)])-&gt;get();
</code></pre>
<h2>Load only the columns you need</h2>
<pre><code class="language-php">$posts = Post::with('author:id,name')-&gt;get();
</code></pre>
<p>Selecting <code>id,name</code> keeps the payload small - just always include the foreign key (<code>id</code>) or the relation will not match.</p>
<h2>Eager load after the fact</h2>
<p>Already have the collection? Load relationships afterwards with <code>load()</code>:</p>
<pre><code class="language-php">$posts = Post::all();
$posts-&gt;load('author');
</code></pre>
<h2>Catch N+1 automatically</h2>
<p>Enable strict mode in a service provider so lazy loading throws during development:</p>
<pre><code class="language-php">use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! app()-&gt;isProduction());
</code></pre>
<h2>Key takeaways</h2>
<ul>
<li><code>with()</code> eager-loads upfront (2 queries); <code>load()</code> eager-loads an existing collection.</li>
<li>Always eager-load any relationship you touch inside a loop.</li>
<li>Use column selection and closures to load only what you need.</li>
<li>Turn on <code>preventLazyLoading</code> in dev to catch N+1 before it ships.</li>
</ul>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2QX92RK1XBZJ6VDNH5EWQT.png" medium="image" />
                    </item>
                <item>
            <title>JavaScript Async/Await Best Practices</title>
            <link>https://cslant.com/tips/javascript-async-await-best-practices</link>
            <guid isPermaLink="false">https://cslant.com/tips/javascript-async-await-best-practices</guid>
                        <pubDate>Wed, 17 Jun 2026 14:41:52 +0700</pubDate>
                                                <category>JavaScript</category>
                                    <description>Master async/await in JavaScript with these best practices and common pitfalls to avoid.</description>
                                    <content:encoded><![CDATA[<h1>JavaScript Async/Await Best Practices</h1>
<p>Modern JavaScript uses <code>async/await</code> for handling asynchronous operations. Here are the best practices:</p>
<h2>1. Always Handle Errors</h2>
<pre><code class="language-javascript">async function fetchUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('Failed to fetch user:', error);
        throw error;
    }
}
</code></pre>
<h2>2. Parallel Execution</h2>
<p>Don't await in sequence when you can run in parallel:</p>
<pre><code class="language-javascript">// ❌ Slow - Sequential
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();

// ✅ Fast - Parallel
const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
]);
</code></pre>
<h2>3. Promise.allSettled for Independent Operations</h2>
<pre><code class="language-javascript">const results = await Promise.allSettled([
    fetchUser(),
    fetchPosts(),
    fetchComments()
]);

results.forEach(result =&gt; {
    if (result.status === 'fulfilled') {
        console.log('Success:', result.value);
    } else {
        console.log('Error:', result.reason);
    }
});
</code></pre>
<h2>4. Avoid Async in Loops</h2>
<pre><code class="language-javascript">// ❌ Bad
for (const id of userIds) {
    await processUser(id);
}

// ✅ Good
await Promise.all(userIds.map(id =&gt; processUser(id)));
</code></pre>
<h2>Key Takeaways</h2>
<ul>
<li>Always use try/catch for error handling</li>
<li>Use Promise.all() for parallel operations</li>
<li>Consider Promise.allSettled() for independent tasks</li>
<li>Avoid awaiting inside loops</li>
</ul>
<blockquote>
<p>Async/await makes asynchronous code look synchronous, but understanding Promises is still crucial!</p>
</blockquote>]]></content:encoded>
                                    <media:content url="https://s3.cslant.solutions/cslant/tip/banner/01KY2R2SSZNX41775DTPEABG9N.png" medium="image" />
                    </item>
            </channel>
</rss>
