<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dinesh Wijethunga</title>
    <description>The latest articles on DEV Community by Dinesh Wijethunga (@dineshstack).</description>
    <link>https://dev.to/dineshstack</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3550573%2F24b18188-f648-4ede-864e-3b977482ced0.jpg</url>
      <title>DEV Community: Dinesh Wijethunga</title>
      <link>https://dev.to/dineshstack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dineshstack"/>
    <language>en</language>
    <item>
      <title>24 workers configured, 6 ever used, and the number that actually mattered was MySQL's max_connections</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Wed, 19 Aug 2026 12:10:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/24-workers-configured-6-ever-used-and-the-number-that-actually-mattered-was-mysqls-5206</link>
      <guid>https://dev.to/dineshstack/24-workers-configured-6-ever-used-and-the-number-that-actually-mattered-was-mysqls-5206</guid>
      <description>&lt;p&gt;&lt;strong&gt;PHP-FPM worker count is not a performance dial you turn up. It is the smallest of three separate limits — memory, database connections, and CPU — and on most Laravel deployments the database decides it long before memory does.&lt;/strong&gt; Raising &lt;code&gt;pm.max_children&lt;/code&gt; past that point does not make the application faster; it converts a slow application into a broken one.&lt;/p&gt;
&lt;p&gt;This is what we learned taking a ride-hailing API off PHP's development server, and the number we ended up with was smaller than our first instinct by an order of magnitude.&lt;/p&gt;
&lt;h2&gt;The failure that started it&lt;/h2&gt;
&lt;p&gt;The API was running under &lt;code&gt;php artisan serve&lt;/code&gt;. Not by decision — it had been that way since the project was scaffolded, and nothing had ever pushed hard enough to expose it.&lt;/p&gt;
&lt;p&gt;A load test did. At roughly 15 requests per second of mixed traffic the service stopped responding, and here is the part that mattered: &lt;strong&gt;a ten-minute traffic spike produced a thirty-five-minute outage.&lt;/strong&gt; Arrivals stopped and the service stayed down. It recovered only when we restarted the container.&lt;/p&gt;
&lt;p&gt;That asymmetry is the whole lesson. A system that degrades gracefully returns when load returns to normal. A system that queues without bound does not — it keeps working through a backlog that no longer has anyone waiting on it.&lt;/p&gt;
&lt;h2&gt;Why one process is a hard ceiling&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;artisan serve&lt;/code&gt; wraps PHP's built-in development server. It is single-process and handles &lt;strong&gt;one request at a time&lt;/strong&gt;. Request two waits for request one, whatever it is doing.&lt;/p&gt;
&lt;p&gt;PHP-FPM's model is different in the way that matters: it maintains a pool of worker processes, and each concurrent request occupies exactly one worker for its entire lifetime. Not its CPU time — its lifetime. A worker blocked for 300ms waiting on a database query is unavailable for those 300ms even though it is consuming almost no CPU.&lt;/p&gt;
&lt;p&gt;So your concurrency ceiling is the worker count, and the obvious move is to make the worker count large. That is where people get hurt.&lt;/p&gt;
&lt;h2&gt;The three limits that decide max_children&lt;/h2&gt;
&lt;h3&gt;Limit 1: memory&lt;/h3&gt;
&lt;p&gt;Every worker is a real OS process with its own memory. The arithmetic is unforgiving:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;max_children ≤ (RAM available to PHP) / (average worker RSS)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Measure the average rather than guessing it. A Laravel worker serving a JSON API commonly sits between 40 MB and 120 MB depending on how much of the framework each route touches. Take the figure under real traffic, not at boot — a freshly forked worker is always smaller than one that has served a hundred requests.&lt;/p&gt;
&lt;p&gt;The trap here is that exceeding this limit does not produce a clean error. It produces the OOM killer choosing a victim, and the victim is chosen by memory footprint, not by fault. On a shared box the process that dies is frequently your database, not the PHP pool that caused it.&lt;/p&gt;
&lt;h3&gt;Limit 2: database connections — the one people miss&lt;/h3&gt;
&lt;p&gt;This is the limit that actually bound us, and it is invisible until it isn't.&lt;/p&gt;
&lt;p&gt;Each worker handling a request generally holds its own database connection. MySQL's default &lt;code&gt;max_connections&lt;/code&gt; is &lt;strong&gt;151&lt;/strong&gt;. If you set &lt;code&gt;pm.max_children = 200&lt;/code&gt; because the box has the RAM for it, then at the exact moment your traffic justifies 200 workers, roughly fifty of them receive a connection error instead of a database handle.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;max_children ≤ max_connections − headroom&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Headroom is not optional and it is larger than it looks. Reserve connections for the queue worker, the scheduler, any Kafka or event consumer, migrations during a deploy, your monitoring exporter, and a human being with a database client open during an incident. That last one has ended more incidents badly than it should have.&lt;/p&gt;
&lt;p&gt;The failure mode is worth dwelling on. Under-sizing workers gives you a slow site. Over-sizing them past the connection cap gives you a site that returns 500s specifically when it is busiest, which is both the worst time and the hardest to reproduce afterwards.&lt;/p&gt;
&lt;h3&gt;Limit 3: CPU, weighted by what your requests actually do&lt;/h3&gt;
&lt;p&gt;For CPU-bound work, more workers than cores buys nothing — it adds context switching to the same finite compute. For I/O-bound work, where workers spend most of their lifetime waiting on a database or an upstream HTTP call, worker count can exceed core count substantially, because the waiting overlaps.&lt;/p&gt;
&lt;p&gt;Most Laravel API requests are I/O-bound, which is why a modest core count still supports a healthy pool. But the ratio is a property of your routes, not a constant. Measure it before borrowing anyone's rule of thumb, including this one.&lt;/p&gt;
&lt;h2&gt;What we set, and what we measured&lt;/h2&gt;
&lt;p&gt;The pool, on an eight-core host shared with the database, cache, message broker and several Node services:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pm = dynamic
pm.max_children = 24
pm.start_servers = 8
pm.min_spare_servers = 6
pm.max_spare_servers = 12
pm.max_requests = 1000
pm.status_path = /fpm-status&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Twenty-four, on a box that could hold far more by the memory arithmetic alone. The database cap and the shared-tenancy reality set it, not RAM.&lt;/p&gt;
&lt;p&gt;Then we measured. Under a full booking workload — WebSocket connections, dispatch, offer handling, ride completion — the pool peaked at &lt;strong&gt;six of twenty-four workers&lt;/strong&gt;, with a listen queue of zero and &lt;code&gt;max children reached&lt;/code&gt; at zero.&lt;/p&gt;
&lt;p&gt;Six of twenty-four is not a sign the setting is wrong. It means the ceiling is currently generous, which is exactly what you want a ceiling to be. &lt;strong&gt;The number that would tell us to raise it is &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;max children reached&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt;, and it has never left zero.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pm.max_requests = 1000&lt;/code&gt; deserves a note: each worker retires after a thousand requests and is replaced. That bounds the damage from any slow leak in your code or an extension, at the cost of an occasional process fork. On a long-lived pool it is close to free insurance.&lt;/p&gt;
&lt;h2&gt;Three traps that cost us time&lt;/h2&gt;
&lt;h3&gt;opcache looks disabled when you check it from the command line&lt;/h3&gt;
&lt;p&gt;We ran a quick command-line check and got zeros back:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php -r 'var_dump(opcache_get_status(false));'
# Warning: Trying to access array offset on false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That output is correct and means nothing. The CLI SAPI has &lt;code&gt;opcache.enable_cli&lt;/code&gt; off by default, so a command-line probe reports on a completely different configuration from the one serving your web traffic. Check through the FPM binary instead:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php-fpm -i | grep -E 'opcache.enable|opcache.memory'
# opcache.enable =&amp;gt; On =&amp;gt; On
# opcache.memory_consumption =&amp;gt; 256 =&amp;gt; 256&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ten minutes disappeared into diagnosing a problem that did not exist. Verify through the same SAPI that serves the traffic, always.&lt;/p&gt;
&lt;h3&gt;A Docker memory cap can grant more memory than you think&lt;/h3&gt;
&lt;p&gt;Setting &lt;code&gt;--memory=5g&lt;/code&gt; without &lt;code&gt;--memory-swap&lt;/code&gt; does not confine a container to 5 GB. Docker grants that much RAM plus the same again in swap. A cap set above the box's available RAM therefore licenses the container to exhaust memory and then thrash, which is slower and harder to diagnose than a clean kill.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Bounded: the container is OOM-killed alone, the host survives
docker run --memory=3g --memory-swap=3g ...&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;An uncached route table taxes every request equally&lt;/h3&gt;
&lt;p&gt;Worth checking before you touch the pool at all. Our route file compiles to roughly 1.5 MB. Without &lt;code&gt;route:cache&lt;/code&gt;, that table is rebuilt on every single request — a flat cost of several hundred milliseconds on every endpoint, which no amount of worker tuning removes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php artisan route:cache&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A worker held for 300ms of avoidable work is a worker you do not have. Fixing this is often worth more than doubling the pool, and it is one command.&lt;/p&gt;
&lt;h2&gt;How to tell whether workers are your problem at all&lt;/h2&gt;
&lt;p&gt;Enable the status endpoint and read two fields:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s localhost/fpm-status | grep -E 'active processes|listen queue|max children reached'&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&lt;strong&gt;max children reached&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; climbing&lt;/strong&gt; — the pool is genuinely the ceiling. Raise it, within the three limits above.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&lt;strong&gt;listen queue&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; above zero while &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;max children reached&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; stays at zero&lt;/strong&gt; — requests are waiting, but not for workers. Look downstream: slow queries, an uncached route table, a blocking upstream call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both at zero under load&lt;/strong&gt; — the web tier is not your bottleneck. Measure elsewhere before changing anything here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That second case is the common one, and it is where worker tuning becomes cargo cult. If your workers are idle-but-occupied, you do not have a concurrency problem; you have a latency problem wearing a concurrency costume.&lt;/p&gt;
&lt;h2&gt;The principle&lt;/h2&gt;
&lt;p&gt;Every capacity fix relocates the bottleneck rather than removing it. Moving off the development server did not make the system fast — it made the next constraint visible, which turned out to be the database sitting behind those workers.&lt;/p&gt;
&lt;p&gt;So size the pool from the limits you can measure, set it to something defensible, and then &lt;strong&gt;watch the counter that tells you it was wrong&lt;/strong&gt;. A number you can justify and monitor beats a larger number you picked because the box looked like it could take it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/php-fpm-max-children-laravel-sizing?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>mysql</category>
      <category>performance</category>
      <category>php</category>
    </item>
    <item>
      <title>The Exposed API Claude AI Found in Its First Hour</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Tue, 18 Aug 2026 17:07:05 +0000</pubDate>
      <link>https://dev.to/dineshstack/the-exposed-api-claude-ai-found-in-its-first-hour-4lh6</link>
      <guid>https://dev.to/dineshstack/the-exposed-api-claude-ai-found-in-its-first-hour-4lh6</guid>
      <description>&lt;p&gt;Part 2 of a 5-part series on using Claude AI to run, secure, and ship a real production server. In Part 1 we connected Claude safely and ran a read-only audit. It found something alarming. Now we fix it.&lt;/p&gt;
&lt;h2&gt;The Exposed API Claude Found in Its First Hour (Part 2)&lt;/h2&gt;
&lt;p&gt;The audit from Part 1 ranked its top risks, and number three stopped me cold: a Python FastAPI service — the backend for a trading bot — listening on &lt;code&gt;0.0.0.0:8100&lt;/code&gt;, directly on the public internet, with no TLS and no nginx in front of it. Everything else on my server sat safely behind a reverse proxy. This one was naked.&lt;/p&gt;
&lt;p&gt;This post is the fix, and more importantly, &lt;strong&gt;the pattern&lt;/strong&gt; Claude and I used to make a production change safely: the AI investigates and prepares, I execute, the AI verifies. It's the workflow I now trust for anything that matters.&lt;/p&gt;
&lt;h3&gt;Quick Lesson: 0.0.0.0 vs 127.0.0.1&lt;/h3&gt;
&lt;p&gt;If you remember one thing from this series, make it this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&lt;strong&gt;127.0.0.1&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; (loopback)&lt;/strong&gt; — only reachable from the server itself. The internet can't touch it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&lt;strong&gt;0.0.0.0&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; (all interfaces)&lt;/strong&gt; — accepts connections from everywhere, including the public internet, unless a firewall stops it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Tutorials default to &lt;code&gt;0.0.0.0&lt;/code&gt; because it "just works." That convenience is exactly how services end up accidentally public. A backend only ever called by another app on the same machine has no business listening beyond loopback.&lt;/p&gt;
&lt;h3&gt;Claude's Move: Investigate Before Touching&lt;/h3&gt;
&lt;p&gt;Here's what impressed me. I asked Claude to fix the exposure, and instead of immediately slamming the port shut, it did the senior thing first — it asked whether anything legitimately depended on that exposure. Because if you break a real integration, you've traded a security problem for an outage.&lt;/p&gt;
&lt;p&gt;Claude ran a structured, read-only investigation and reported back:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What Claude found&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nginx configs referencing :8100&lt;/td&gt;
&lt;td&gt;None — nothing proxies to it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron jobs / scheduled tasks&lt;/td&gt;
&lt;td&gt;Nothing related&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The only consumer's source code&lt;/td&gt;
&lt;td&gt;A dashboard that expects the API at &lt;code&gt;127.0.0.1:8100&lt;/code&gt; — loopback, server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How the service starts&lt;/td&gt;
&lt;td&gt;A systemd unit with the bind address hardcoded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live reachability test&lt;/td&gt;
&lt;td&gt;A curl to the public IP returned a live response — confirming it really was open&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The conclusion wrote itself: &lt;strong&gt;nothing needed the public binding.&lt;/strong&gt; The only consumer already expected loopback. The &lt;code&gt;0.0.0.0&lt;/code&gt; was an oversight in one line of a systemd file — not a design decision. Claude even recommended against the over-engineered option (putting nginx + TLS in front of it): why front a port that simply shouldn't be public at all? Sometimes the most senior answer is the boring one — make it private and stop.&lt;/p&gt;
&lt;h3&gt;The Human-in-the-Loop Wall (And Why It's a Good Thing)&lt;/h3&gt;
&lt;p&gt;When it came time to actually apply the fix, Claude hit a wall — and this is the best part of the story. Its shell has no interactive terminal, so &lt;code&gt;sudo&lt;/code&gt; can never prompt it for a password. It reported this honestly and &lt;strong&gt;explicitly refused to work around it&lt;/strong&gt; (no touching the sudoers file, no clever hacks). That refusal is exactly what earned my trust.&lt;/p&gt;
&lt;p&gt;So we turned the wall into the workflow. This is the pattern I now use for every AI-assisted production change:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Claude writes a complete fix script&lt;/strong&gt; — with a timestamped backup, a diff checkpoint that aborts if the change looks wrong, the fix itself, and full verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I review it line-by-line&lt;/strong&gt;, then run it in a second tmux window (&lt;code&gt;Ctrl+B c&lt;/code&gt;) where sudo works normally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output goes back to Claude&lt;/strong&gt;, which verifies every result and writes the remediation report.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here's the shape of what it produced — study the safety pattern, not just the commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UNIT=/etc/systemd/system/crypto-bot-api.service
BACKUP="${UNIT}.bak.$(date +%Y%m%d%H%M%S)"

# 1. Backup first — every change must be reversible
sudo cp -v "$UNIT" "$BACKUP"

# 2. Surgical edit: ONLY the bind flag changes
sudo sed -i 's/--host 0\.0\.0\.0/--host 127.0.0.1/' "$UNIT"

# 3. Abort checkpoint: if nothing changed, STOP
diff -u "$BACKUP" "$UNIT"

# 4. Apply and verify from every angle
sudo systemctl daemon-reload &amp;amp;&amp;amp; sudo systemctl restart crypto-bot-api
sudo ss -tlnp | grep 8100          # expect 127.0.0.1 only now
curl http://127.0.0.1:8100/        # local consumer still works

# 5. Defense in depth — firewall the port in case the bind ever regresses
sudo ufw deny 8100/tcp&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Verify Like You Mean It — From Outside&lt;/h3&gt;
&lt;p&gt;A fix isn't done when a command exits cleanly. It's done when you've proven the new behaviour from every angle. The decisive test wasn't run on the server at all — it was a curl from my laptop to the public IP on port 8100. Before: a live response. After: &lt;strong&gt;connection timed out.&lt;/strong&gt; That external timeout is the ground truth that the hole is closed. Checking only from inside the server can fool you.&lt;/p&gt;
&lt;p&gt;Claude also caught a subtlety most guides miss: after adding the firewall rule, check &lt;code&gt;ufw status numbered&lt;/code&gt; for an older "allow" rule that would shadow the new "deny" — UFW is first-match. There was none, so the deny stands clean on both IPv4 and IPv6.&lt;/p&gt;
&lt;h3&gt;The Human-Only Cleanup&lt;/h3&gt;
&lt;p&gt;One thing I did not delegate: rotating the API key. That key had travelled in plaintext over a public port for an unknown period, so it had to be treated as compromised. Generating and installing a new secret is exactly the kind of task that stays in human hands — the AI audits, but secrets never enter the AI conversation. That line stays bright.&lt;/p&gt;
&lt;h3&gt;Key Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sudo ss -tlnp&lt;/code&gt; takes ten seconds and shows exactly what your server offers the world.&lt;/li&gt;
&lt;li&gt;Investigate dependencies before closing a port — the AI checking first prevented an outage.&lt;/li&gt;
&lt;li&gt;Fix at the source (rebind) and add a second layer (firewall). Layers, not either/or.&lt;/li&gt;
&lt;li&gt;The "AI writes the script, human runs it, AI verifies" loop gives you AI speed with human accountability — plus a paper trail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Risk #3 from the audit: closed, verified, documented. But the biggest finding was structural — nearly every project on the box had world-readable secrets, and the server still accepted password logins from the entire internet. That's &lt;strong&gt;Part 3&lt;/strong&gt;, where Claude and I do a permissions sweep across 20 projects and lock SSH down to keys only — and hit a trap that silently undoes the whole thing.&lt;/p&gt;
&lt;p&gt;👉 Coming up in Part 3: "Locking Down Secrets and SSH — and the Cloud-Init Trap That Almost Fooled Us." Would you let an AI agent close a port on your production server? What guardrail would you insist on?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/exposed-api-claude-ai-found-first-hour?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Let Claude AI Manage Your Production Server Safely</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Sat, 15 Aug 2026 16:57:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/how-to-let-claude-ai-manage-your-production-server-safely-b8a</link>
      <guid>https://dev.to/dineshstack/how-to-let-claude-ai-manage-your-production-server-safely-b8a</guid>
      <description>&lt;p&gt;Part 1 of a 5-part series on using Claude AI to run, secure, and ship a real production server. This is the honest, screenshot-by-screenshot account — what I typed, what the AI found, where I got stuck, and how each problem got solved.&lt;/p&gt;
&lt;h2&gt;I Let Claude AI Manage My Production Server — Here's How I Did It Safely (Part 1)&lt;/h2&gt;
&lt;p&gt;I run a VPS hosting around 20 live projects — client sites, e-commerce, a medicare system, a couple of crypto bots. One afternoon I decided to try something most people are still nervous about: &lt;strong&gt;connecting Claude AI directly to that production server&lt;/strong&gt; and letting it help me audit, secure, and fix things.&lt;/p&gt;
&lt;p&gt;The result genuinely surprised me. Within its first hour, Claude found a security hole I'd walked past for months. But the reason it was safe to do this at all is the setup — the guardrails I put in place before the AI touched anything. This first post is that foundation. If you follow along, by the end you'll have an AI agent working on your server without the power to break it behind your back.&lt;/p&gt;
&lt;h3&gt;The One Rule That Makes This Safe&lt;/h3&gt;
&lt;p&gt;Before any command, understand the model that makes AI-on-production sane: &lt;strong&gt;the AI gets the brains, you keep the keys.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Claude reads your system, finds problems, plans fixes, writes scripts, and verifies results. But every command that actually changes something goes through you. Think of it like a brilliant new engineer on day one — incredibly capable, but they don't get root access and a blank cheque on their first afternoon. Every guardrail below enforces that split.&lt;/p&gt;
&lt;h3&gt;Step 1: Harden the Server First (Never Install an AI on a Soft Target)&lt;/h3&gt;
&lt;p&gt;The AI inherits the security of the account it runs as. So I shaped that account before installing anything — a dedicated non-root user, SSH keys, a firewall:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adduser deploy_user
usermod -aG sudo deploy_user

# In /etc/ssh/sshd_config: PermitRootLogin no, PasswordAuthentication no
sudo systemctl restart ssh

sudo ufw allow OpenSSH &amp;amp;&amp;amp; sudo ufw enable&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The single most important decision here: &lt;strong&gt;the AI runs as a normal user, never as root.&lt;/strong&gt; Later, that boundary turned out to be a feature, not a limitation — you'll see why in Part 2.&lt;/p&gt;
&lt;h3&gt;Step 2: Install Claude Code and Live Inside tmux&lt;/h3&gt;
&lt;p&gt;Claude Code is Anthropic's terminal-based AI agent. It installs on the server itself:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -fsSL https://claude.ai/install.sh | bash
echo 'export PATH="$HOME/.local/bin:$PATH"' &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; source ~/.bashrc

tmux new -s setup
claude&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why tmux? An AI session is long-running. If your Wi-Fi drops mid-task, tmux keeps it alive on the server — you reconnect with &lt;code&gt;tmux attach -t setup&lt;/code&gt; and pick up exactly where you left off.&lt;/p&gt;
&lt;h3&gt;A tmux Survival Guide (Because I Got Trapped Too)&lt;/h3&gt;
&lt;p&gt;If you've never used tmux, one concept unlocks it: &lt;strong&gt;every command starts with a "prefix" — &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;Ctrl+B&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; — which you press and release before the next key.&lt;/strong&gt; Beginners fail at tmux for exactly one reason: they mash all the keys at once. Knock first, then speak.&lt;/p&gt;
&lt;p&gt;The commands this whole series uses:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux new -s setup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start a named session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ctrl+B&lt;/code&gt; then &lt;code&gt;d&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Detach (leave it running in the background)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux attach -t setup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reconnect after a dropped connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ctrl+B&lt;/code&gt; then &lt;code&gt;c&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;New window (a second shell — you'll need this for sudo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ctrl+B&lt;/code&gt; then &lt;code&gt;n&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Switch to the next window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Ctrl+B&lt;/code&gt; then &lt;code&gt;[&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Scroll mode (read output that scrolled away)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;⚠ The trap that catches everyone:&lt;/strong&gt; the moment you press &lt;code&gt;Ctrl+B [&lt;/code&gt;, your keyboard stops typing into the shell — the arrows scroll history instead. It feels like the terminal froze. It happened to me and for a full minute I thought I'd broken everything. The escape is one key: press &lt;code&gt;&lt;strong&gt;q&lt;/strong&gt;&lt;/code&gt; and you're back. Nothing was frozen; you were just in a different mode.&lt;/p&gt;
&lt;h3&gt;Step 3: The Three Guardrails&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Guardrail 1 — Manual mode, always.&lt;/strong&gt; Claude Code asks permission before every command. On production, never enable any auto-approve. Reviewing each command takes seconds and it is the safety model.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Guardrail 2 — Scope the workspace.&lt;/strong&gt; When Claude starts, it asks whether you trust the current folder. Broad scope for read-only mapping, narrow scope for changes. I launched from the web root once for a read-only audit, but for editing work you launch from the one specific project folder.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Guardrail 3 — Snapshot before, not after.&lt;/strong&gt; Take a full server snapshot in your host's panel before any session that will change things. It's your catastrophic-failure undo button.&lt;/p&gt;
&lt;h3&gt;Step 4: The First Task Is Always a Read-Only Audit&lt;/h3&gt;
&lt;p&gt;Don't ask an AI to change anything on day one. Ask it to &lt;strong&gt;map&lt;/strong&gt;. Here's the kind of prompt I used — notice how hard it leans on "change nothing" and "never read secrets":&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Read-only audit — do not modify, create, or delete anything.
Do not read the contents of .env files; check existence and
permissions only.

1. List every project in /var/www and its stack
2. Cross-reference with nginx to find what's actually live
3. List all listening ports and the processes behind them
4. Flag exposed .env files, world-writable dirs, .git in web roots
Output a summary table and rank the top 5 risks.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That "never read .env contents" rule matters: Claude can audit file permissions without a single database password ever entering the AI conversation. Asking for a ranked risk list turned raw findings into an action plan.&lt;/p&gt;
&lt;p&gt;The payoff was immediate. Claude produced a full inventory table of every site, cross-referenced against nginx to show which were truly live, listed every open port — and flagged, among other things, a Python API listening on &lt;code&gt;0.0.0.0:8100&lt;/code&gt;, wide open to the internet. Months of exposure I'd never noticed, surfaced by an AI in its first session.&lt;/p&gt;
&lt;h3&gt;What You've Got After Part 1&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A hardened, non-root account for the AI to operate through&lt;/li&gt;
&lt;li&gt;Claude Code running in a persistent tmux session&lt;/li&gt;
&lt;li&gt;Manual-approval mode so nothing runs without you&lt;/li&gt;
&lt;li&gt;A complete, ranked map of your server's risks — written by the AI, reviewed by you&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mental shift is the real lesson: an AI agent's first hour on your server will probably find something you missed. Mine found a publicly exposed financial API. In &lt;strong&gt;Part 2&lt;/strong&gt;, we fix it — and you'll see the exact human-in-the-loop pattern that lets an AI plan a production change while you keep your hand on the trigger.&lt;/p&gt;
&lt;p&gt;👉 Coming up in Part 2: "The Exposed API Claude Found in Its First Hour." Have you ever run &lt;code&gt;sudo ss -tlnp&lt;/code&gt; on your own server? Try it and tell me in the comments what's listening that you forgot about.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/claude-ai-manage-production-server-safely?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Verifying a WhatsApp webhook in Laravel: the three silent traps</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Fri, 14 Aug 2026 19:15:05 +0000</pubDate>
      <link>https://dev.to/dineshstack/verifying-a-whatsapp-webhook-in-laravel-the-three-silent-traps-25df</link>
      <guid>https://dev.to/dineshstack/verifying-a-whatsapp-webhook-in-laravel-the-three-silent-traps-25df</guid>
      <description>&lt;p&gt;Three things go wrong when a Laravel application receives WhatsApp webhooks, and each produces a different silent failure. Meta's verification challenge arrives with dots in its query keys, which PHP renames before your code sees them — so &lt;code&gt;$request-&amp;gt;query('hub.mode')&lt;/code&gt; reads nothing and verification never succeeds. The delivery signature is an HMAC over the raw request bytes, so any comparison built on parsed-and-re-encoded JSON rejects genuine payloads. And Meta expects a response inside roughly twenty seconds, so a handler that processes inline works in development and starts collecting retries — then duplicate deliveries, then suspension warnings — under production load. This post walks the receiving side end to end: the handshake, the signature, the deadline, and what a processing job downstream of all three has to tolerate.&lt;/p&gt;
&lt;p&gt;Everything here is from a live integration on a booking platform, where the webhook carries &lt;a href="/en/whatsapp-per-message-cost-tracking-webhook"&gt;the only per-message cost data Meta will ever give you&lt;/a&gt; — which is what makes a silently rejecting receiver expensive rather than merely annoying.&lt;/p&gt;
&lt;h2&gt;The handshake: dots become underscores&lt;/h2&gt;
&lt;p&gt;When you register a callback URL, Meta sends a GET with three query parameters, exactly as documented:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /webhook?hub.mode=subscribe&amp;amp;hub.verify_token=YOUR_TOKEN&amp;amp;hub.challenge=1158201444&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The documented names are the trap. PHP converts dots in incoming query keys to underscores before the request reaches userland — a legacy of &lt;code&gt;register_globals&lt;/code&gt;, when &lt;code&gt;hub.mode&lt;/code&gt; could not be a variable name. Laravel builds its request object on top of that, so the keys your code can actually read are &lt;code&gt;hub_mode&lt;/code&gt;, &lt;code&gt;hub_verify_token&lt;/code&gt; and &lt;code&gt;hub_challenge&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public function verify(Request $request): Response
{
    // PHP renames hub.mode -&amp;gt; hub_mode before Laravel ever sees it.
    $mode      = $request-&amp;gt;query('hub_mode');
    $token     = (string) $request-&amp;gt;query('hub_verify_token', '');
    $challenge = (string) $request-&amp;gt;query('hub_challenge', '');

    $expected = (string) config('messaging.verify_token', '');

    if ($mode === 'subscribe' &amp;amp;&amp;amp; $expected !== '' &amp;amp;&amp;amp; hash_equals($expected, $token)) {
        return response($challenge, 200)-&amp;gt;header('Content-Type', 'text/plain');
    }

    return response('Forbidden', 403);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details that are each doing real work. The empty-string check on the expected token means an unconfigured server fails verification rather than accepting any token — the same fail-closed reasoning as &lt;a href="/en/whatsapp-otp-pumping-country-allowlist"&gt;the country allowlist&lt;/a&gt;, applied to a handshake. &lt;code&gt;hash_equals()&lt;/code&gt; keeps the comparison constant-time. And the response is the bare challenge as plain text: not JSON, not quoted, no framework envelope. A response helper that wraps everything in &lt;code&gt;{"status": true, "data": ...}&lt;/code&gt; will fail this handshake, and the dashboard will only tell you the URL could not be validated.&lt;/p&gt;
&lt;h2&gt;The signature: HMAC over bytes you must not touch&lt;/h2&gt;
&lt;p&gt;Every delivery POST carries an &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; header: &lt;code&gt;sha256=&lt;/code&gt; followed by an HMAC of the raw body, keyed with your app secret. The operative word is raw:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public function receive(Request $request): Response
{
    $raw    = $request-&amp;gt;getContent();          // bytes as sent — never re-encoded
    $header = (string) $request-&amp;gt;header('X-Hub-Signature-256', '');
    $secret = (string) config('messaging.app_secret', '');

    if ($header === '' || $secret === '') {
        return response('Forbidden', 403);     // unconfigured = reject, loudly
    }

    $expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

    if (! hash_equals($expected, $header)) {
        return response('Forbidden', 403);
    }

    ProcessWebhook::dispatch(json_decode($raw, true) ?? []);

    return response('', 200);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The classic mistake is computing the HMAC over &lt;code&gt;json_encode($request-&amp;gt;all())&lt;/code&gt;. It fails intermittently, which is worse than failing always: PHP re-encodes &lt;code&gt;/&lt;/code&gt; as &lt;code&gt;\/&lt;/code&gt; by default, reorders nothing but re-serialises floats and unicode differently than Meta did, and any single byte of difference produces a different digest. Payloads that happen to survive the round-trip verify; payloads with a URL or an emoji in them do not. The symptom is "some webhooks fail signature validation", which reads like an attack and is actually your own serialiser.&lt;/p&gt;
&lt;p&gt;Two adjacent traps. Middleware that touches the body — trimming strings, converting empty strings to null — must exclude this route, because the framework request object and &lt;code&gt;getContent()&lt;/code&gt; can diverge after mutation. And if the endpoint sits behind a proxy or gateway, that layer must pass the body through untouched: a gateway that pretty-prints, decompresses, or re-encodes JSON breaks the signature for every payload while looking completely healthy itself.&lt;/p&gt;
&lt;h2&gt;The deadline: answer in seconds, work later&lt;/h2&gt;
&lt;p&gt;Meta expects a fast 200. Take too long — the practical budget is seconds, with retries beginning when you exceed it — and the delivery is retried. Keep being slow and the same events arrive two and three times while the backlog compounds; sustained failure escalates to warnings and eventually to the subscription being disabled.&lt;/p&gt;
&lt;p&gt;The design consequence is one line: &lt;strong&gt;the HTTP handler validates and queues, and nothing else.&lt;/strong&gt; Signature check, dispatch, 200. The database writes, the Graph lookups, the cost reconciliation — all of it belongs to a queued job. In the code above, the only work between signature and response is a &lt;code&gt;json_decode&lt;/code&gt; and a &lt;code&gt;dispatch()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is also the correct place for that work for a second reason: retries. Once processing is a queued job, a transient failure is retried by your queue with your backoff policy, instead of by Meta with theirs — and Meta's retry arrives as a fresh HTTP delivery that must re-pass signature validation and re-enter the queue, which is how duplicates are born.&lt;/p&gt;
&lt;h2&gt;The job: assume duplicates, assume disorder&lt;/h2&gt;
&lt;p&gt;Which leads to the two properties the processing job must have. Meta redelivers on any failure it perceives — a timeout counts even if you processed the payload — and separate deliveries take separate paths, so nothing guarantees order. The job cannot prevent either; it has to be shaped so neither matters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Idempotency by natural key.&lt;/strong&gt; Every message and status carries a stable id (&lt;code&gt;wamid&lt;/code&gt; for messages). Guarded writes make the second delivery a no-op:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$message = MessageLog::firstOrCreate(
    ['wamid' =&amp;gt; $status['id']],
    ['direction' =&amp;gt; 'outbound', 'status' =&amp;gt; 'accepted']
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Monotonic state.&lt;/strong&gt; A retried &lt;code&gt;sent&lt;/code&gt; can arrive after the &lt;code&gt;delivered&lt;/code&gt; it precedes. Rank the lifecycle and only ever move forward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$rank = ['accepted' =&amp;gt; 0, 'sent' =&amp;gt; 1, 'delivered' =&amp;gt; 2, 'read' =&amp;gt; 3];

if ($rank[$state] &amp;gt; ($rank[$message-&amp;gt;status] ?? 0)) {
    $message-&amp;gt;update(['status' =&amp;gt; $state]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run both rules and redelivery becomes harmless: the row exists, the state does not regress, the second delivery changes nothing. Skip them and every Meta retry is a data corruption opportunity.&lt;/p&gt;
&lt;h2&gt;Keep the routes apart&lt;/h2&gt;
&lt;p&gt;One structural decision worth stating because the default is wrong: the webhook routes are unauthenticated by design — Meta cannot log in — and they should live in their own route file, not alongside authenticated API routes. The failure this prevents is a careless group edit: someone adds &lt;code&gt;auth:api&lt;/code&gt; to a shared group and the webhook starts returning 401 to Meta, or removes it and an admin surface goes public. Isolation makes both mistakes structurally harder:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// routes/webhooks.php — nothing else lives here
Route::prefix('v1/webhook')-&amp;gt;middleware('throttle:whatsapp-webhook')-&amp;gt;group(function () {
    Route::get('whatsapp', [WebhookController::class, 'verify']);
    Route::post('whatsapp', [WebhookController::class, 'receive']);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The named rate limiter is not decoration either. Meta delivers status bursts during campaigns — every message in a broadcast produces its own sent and delivered callbacks — and an unnamed &lt;code&gt;throttle:120,1&lt;/code&gt; here would share a counting bucket with the global API throttle and &lt;a href="/en/laravel-unnamed-throttle-shared-bucket"&gt;enforce half the number written on it&lt;/a&gt;. A named limiter owns its bucket, so the declared headroom is the real headroom.&lt;/p&gt;
&lt;h2&gt;Verify the whole chain with one message&lt;/h2&gt;
&lt;p&gt;The receiving side has a property that makes it easy to believe it works when it does not: every failure mode returns a clean-looking response to somebody. Signature rejections 403 to Meta and your logs stay quiet. A slow handler 200s eventually and the retry storm happens on Meta's side. So test it end to end, with one real message, and watch the data rather than the HTTP codes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Send one template message through the API.&lt;/li&gt;
&lt;li&gt;Within seconds, the ledger row should move from &lt;code&gt;accepted&lt;/code&gt; to &lt;code&gt;sent&lt;/code&gt; to &lt;code&gt;delivered&lt;/code&gt; — that is the webhook arriving, passing signature, and being processed.&lt;/li&gt;
&lt;li&gt;If the row stays at &lt;code&gt;accepted&lt;/code&gt;: deliveries are not arriving or not validating. Check the callback URL, then log signature failures explicitly — a silent 403 is indistinguishable from no traffic.&lt;/li&gt;
&lt;li&gt;If rows appear but pricing fields stay null: the handler is processing messages but skipping the &lt;code&gt;statuses&lt;/code&gt; array. Both live under the same &lt;code&gt;messages&lt;/code&gt; webhook field; handling one and not the other is easy to do without noticing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On the integration this came from, that single-message check is what proved the chain: send, &lt;code&gt;sent&lt;/code&gt; two seconds later with pricing attached, &lt;code&gt;delivered&lt;/code&gt; right behind it. Until you have seen that sequence in your own tables, the receiver is unverified — whatever the dashboard says.&lt;/p&gt;
&lt;p&gt;The receiving side described here — handshake, raw-body HMAC, queued processing, idempotent monotonic writes — ships assembled in &lt;a href="https://github.com/dineshstack/laravel-whatsapp-cost-control" rel="noopener noreferrer"&gt;laravel-whatsapp-cost-control&lt;/a&gt; (MIT, Laravel 12 and 13), wired into the cost ledger those webhooks feed. The part most worth stealing even if you build your own is the discipline: validate bytes you have not touched, answer before you work, and let every retry find a system that has already made itself safe to repeat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/whatsapp-webhook-verification-laravel?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>laravel</category>
      <category>php</category>
    </item>
    <item>
      <title>Random UUID keys fragment InnoDB. Ordered ones write clean</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:20:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/random-uuid-keys-fragment-innodb-ordered-ones-write-clean-4029</link>
      <guid>https://dev.to/dineshstack/random-uuid-keys-fragment-innodb-ordered-ones-write-clean-4029</guid>
      <description>&lt;p&gt;InnoDB stores a table physically ordered by its primary key. Give that table a random UUID key and every insert lands at a random position in the B-tree, splitting pages that were nowhere near full and evicting buffer-pool pages that inserts a moment later will need again. A time-ordered UUID — version 7, or Laravel's &lt;code&gt;Str::orderedUuid()&lt;/code&gt; — restores append-like behaviour while keeping everything that made UUIDs attractive. The difference is invisible on a small table and structural on a busy one, which is exactly the trap: the tables most likely to get UUID keys — audit logs, message ledgers, event streams — are the highest-write tables in the system, and the cost arrives months after the schema shipped.&lt;/p&gt;
&lt;p&gt;This came up while building a messaging cost ledger where the audit table takes a row for every API call and the ledger a row per message. Both wanted UUID keys for good reasons. Both would have been quietly wrong with random ones.&lt;/p&gt;
&lt;h2&gt;Why the clustered index cares where your key lands&lt;/h2&gt;
&lt;p&gt;An InnoDB table is its primary key index. Rows live in 16 KB pages ordered by key value, so the key you choose decides the physical write pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-increment&lt;/strong&gt;: every new key is the largest yet. Inserts append to the right-most page; pages fill completely and are written once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Random UUIDv4&lt;/strong&gt;: every new key is a coin flip across the entire keyspace. Inserts land in arbitrary pages; full pages split into two half-full ones; the working set for inserts becomes the whole index.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two costs compound. Page splits leave the index physically larger than its data — pages hovering half-full mean the same rows occupy roughly twice the pages, and every one of them flows through the buffer pool. And because the next insert is equally likely to touch any page, the buffer pool stops being a cache of hot pages and becomes a lottery. Secondary indexes make it worse: in InnoDB every secondary index entry carries the primary key as its row pointer, so a 36-character random key is paid for again in every index on the table.&lt;/p&gt;
&lt;h2&gt;The cost arrives late&lt;/h2&gt;
&lt;p&gt;The reason this survives review and load testing: while the whole index fits in the buffer pool, random inserts are nearly free — page splits happen in memory and the damage is only size. The behaviour changes when the index outgrows the pool. Random inserts now regularly touch pages that are not resident, each one a disk read before the write can proceed, and insert latency develops a long tail that no code change explains.&lt;/p&gt;
&lt;p&gt;Nothing in the application changed. The table crossed a size threshold, and a decision made in a migration file eighteen months earlier started charging interest. On an audit table that takes a row per API call, "eighteen months" is optimistic.&lt;/p&gt;
&lt;h2&gt;Time-ordered UUIDs restore the append&lt;/h2&gt;
&lt;p&gt;A UUIDv7 leads with a millisecond timestamp, so keys generated now sort after keys generated a moment ago. Inserts return to the right-most page, splits become rare, and the buffer pool goes back to caching the hot tail instead of the whole index. You keep what UUIDs bought you: client-side generation before the row exists, no cross-environment collisions, no information leak about row counts the way sequential integers leak them.&lt;/p&gt;
&lt;p&gt;Laravel has shipped this for years, with one version wrinkle worth knowing precisely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laravel 12 and 13&lt;/strong&gt;: the &lt;code&gt;HasUuids&lt;/code&gt; trait generates UUIDv7 out of the box. If you use it, you are already ordered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel 9.30 through 11&lt;/strong&gt;: &lt;code&gt;HasUuids&lt;/code&gt; generated ordered UUIDs too (a timestamp-first arrangement rather than spec v7), so the default was safe there as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trap is everything that is not &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;HasUuids&lt;/strong&gt;&lt;/code&gt;: a &lt;code&gt;Str::uuid()&lt;/code&gt; in a &lt;code&gt;creating&lt;/code&gt; callback, a package that mints its own v4, a database-side default — MySQL's own &lt;code&gt;UUID()&lt;/code&gt; is a version 1 laid out time-low first, which interleaves almost as badly as random.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the default has changed shape across versions, the codebase this came from pins the choice explicitly rather than inheriting whatever the framework does this year:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Support\Str;

/**
 * Drop-in replacement for HasUuids that guarantees time-ordered ids,
 * regardless of framework version or future default changes. New rows
 * land adjacent in the clustered index; existing rows are unaffected.
 */
trait OrderedUuid
{
    use HasUuids;

    public function newUniqueId(): string
    {
        return (string) Str::orderedUuid();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then every high-write model states it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ApiLog extends Model
{
    use OrderedUuid;
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An explicit trait also gives the decision a home for its documentation — the comment explains why the ordering matters, which is what stops a future refactor from "simplifying" it back to &lt;code&gt;Str::uuid()&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Or just use auto-increment?&lt;/h2&gt;
&lt;p&gt;Fair question, since it makes the whole problem vanish. Sometimes the answer is yes — a purely internal table that never shows its ids to anyone loses nothing by being keyed with a bigint.&lt;/p&gt;
&lt;p&gt;The ledger and audit tables kept UUIDs for two specific reasons. Their ids appear in API responses and admin URLs, and sequential integers there leak volume — how many messages you send a day is readable from the gap between two ids, which is commercial information handed to anyone with two data points. And their rows are correlated with external systems by ids that must be mintable before the row exists, from more than one process, without coordination. Ordered UUIDs keep both properties and give back the write pattern; they are the middle option, not a compromise.&lt;/p&gt;
&lt;h2&gt;What ordered keys cost you&lt;/h2&gt;
&lt;p&gt;Two honest trade-offs, one real and one usually imaginary.&lt;/p&gt;
&lt;p&gt;The real one: &lt;strong&gt;a time-ordered id carries its creation time.&lt;/strong&gt; Anyone who can read the id can recover roughly when the row was created, and sort any set of ids chronologically. For an internal audit log this is a feature. For a public-facing identifier it may not be — if exposing creation time matters, expose a separate random public id and keep the ordered key internal, rather than giving up the write pattern.&lt;/p&gt;
&lt;p&gt;The usually-imaginary one: "all inserts hitting the last page creates a hotspot." True in the sense that auto-increment has the same property; InnoDB has handled right-most-page insertion as its most common case for decades. Unless you are sharding writes across servers by key range, the hot tail is the fast path, not a problem.&lt;/p&gt;
&lt;p&gt;One adjacent decision while you are here: Laravel's &lt;code&gt;uuid()&lt;/code&gt; migration column is &lt;code&gt;CHAR(36)&lt;/code&gt;. Storing UUIDs as &lt;code&gt;BINARY(16)&lt;/code&gt; halves-and-more the key that every secondary index carries. It costs readability in ad-hoc queries; on a table with several indexes and heavy writes it is often worth it, and it is far easier to choose on day one than to convert later.&lt;/p&gt;
&lt;h2&gt;Measure your own table before believing any of this&lt;/h2&gt;
&lt;p&gt;Fragmentation is measurable, so check rather than assume. Free space trapped in the table is visible per table:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT table_name,
       ROUND(data_length / 1024 / 1024)   AS data_mb,
       ROUND(index_length / 1024 / 1024)  AS index_mb,
       ROUND(data_free / 1024 / 1024)     AS free_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY data_free DESC;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A high &lt;code&gt;free_mb&lt;/code&gt; relative to &lt;code&gt;data_mb&lt;/code&gt; on a UUID-keyed, insert-heavy table is the signature — space allocated, half-emptied by page splits, and not returned. For the before-and-after, generate a few million rows keyed with &lt;code&gt;Str::uuid()&lt;/code&gt; and again with &lt;code&gt;Str::orderedUuid()&lt;/code&gt; and compare the two numbers; the gap is the argument, and it is more persuasive from your own schema than from anyone's blog post.&lt;/p&gt;
&lt;p&gt;Two things to know about fixing an existing table. New ordered keys do not repair old fragmentation — they stop adding to it, and inserts stop landing in the fragmented middle, which is most of the win. And &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; (an online rebuild in modern MySQL) compacts what history left behind, at the price of a rebuild on what is, by definition, your busiest table — schedule it accordingly.&lt;/p&gt;
&lt;h2&gt;Where this landed in practice&lt;/h2&gt;
&lt;p&gt;In the messaging system this came from, the two tables that take a row per event — &lt;a href="/en/whatsapp-per-message-cost-tracking-webhook"&gt;the cost ledger&lt;/a&gt; that every send opens and &lt;a href="/en/whatsapp-webhook-verification-laravel"&gt;every webhook status updates&lt;/a&gt;, and the audit log recording each API call — both carry the trait. They are precisely the tables whose write rate is decided by customers rather than by engineers, which makes them the tables least able to afford a write pattern that degrades with size.&lt;/p&gt;
&lt;p&gt;Both ship that way in &lt;a href="https://github.com/dineshstack/laravel-whatsapp-cost-control" rel="noopener noreferrer"&gt;laravel-whatsapp-cost-control&lt;/a&gt; (MIT, Laravel 12 and 13) — the migrations and models arrive with ordered keys already wired, because a default you have to remember to apply is a default that will eventually be forgotten on the one table that mattered.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/ordered-uuid-innodb-high-write-tables?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Block the campaign at the cap. Never block the one-time code</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:40:07 +0000</pubDate>
      <link>https://dev.to/dineshstack/block-the-campaign-at-the-cap-never-block-the-one-time-code-520e</link>
      <guid>https://dev.to/dineshstack/block-the-campaign-at-the-cap-never-block-the-one-time-code-520e</guid>
      <description>&lt;p&gt;A spend cap that blocks every WhatsApp send when the budget runs out is a cap that will eventually block a login. The category that ran the budget dry is almost never the category that gets hurt: a marketing broadcast overshoots, the cap trips, and the next one-time code — costing a fraction of a cent — is refused. The customer standing at that moment sees an app that will not let them in, over a budget decision they were never part of. The fix is not a bigger budget. It is admitting that the four WhatsApp categories are not the same kind of traffic, and that only one of them deserves a hard stop.&lt;/p&gt;
&lt;p&gt;This post is about that asymmetry as a deliberate design decision — including the part that looks inconsistent until you see why: the budget guard and the fraud guard sitting in the same send funnel with opposite failure modes, both correct.&lt;/p&gt;
&lt;h2&gt;Four categories, two kinds of traffic&lt;/h2&gt;
&lt;p&gt;Meta prices WhatsApp messages in four categories, and &lt;a href="/en/whatsapp-per-message-cost-tracking-webhook"&gt;the pricing webhook tells you which one each send was charged under&lt;/a&gt;. From a budget's point of view they collapse into two groups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discretionary&lt;/strong&gt; — MARKETING. Somebody chose to run this campaign. Stopping it mid-flight costs reach, not function. It is also the expensive category, routinely several times the price of the others, which is why it is the one that empties budgets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Functional&lt;/strong&gt; — AUTHENTICATION, UTILITY, SERVICE. Nobody chose these individually; the product emits them because a customer did something. A one-time code, a booking confirmation, a reply inside a service window. Each is cheap, and each not-sent is a user-visible failure.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A single cap treats those identically, which produces the worst trade available: it saves fractions of a cent on functional messages while the campaign that actually spent the money has already gone out. The arithmetic is lopsided in the extreme — blocking a full day of OTP traffic usually saves less than a hundredth of what one modest broadcast costs.&lt;/p&gt;
&lt;h2&gt;The policy, stated plainly&lt;/h2&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;At the cap&lt;/th&gt;
&lt;th&gt;Approaching the cap&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MARKETING&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Hard block&lt;/strong&gt; — sends refused&lt;/td&gt;
&lt;td&gt;Warn at threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AUTHENTICATION&lt;/td&gt;
&lt;td&gt;Warn, send anyway&lt;/td&gt;
&lt;td&gt;Warn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTILITY&lt;/td&gt;
&lt;td&gt;Warn, send anyway&lt;/td&gt;
&lt;td&gt;Warn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SERVICE&lt;/td&gt;
&lt;td&gt;Warn, send anyway (it is free regardless)&lt;/td&gt;
&lt;td&gt;Warn&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two refinements that earn their keep in practice:&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;ALL&lt;/strong&gt; budget exists for visibility and never blocks anything — not even marketing. If a total-spend cap could refuse a marketing send, then the answer to "why was this campaign stopped?" depends on two budgets instead of one, and the person operating the dashboard has to simulate the guard in their head. One category blocks, one budget per decision, and the refusal is always explainable in a sentence.&lt;/p&gt;
&lt;p&gt;And the block message should say what to do, not just what happened. "Marketing spend cap reached — raise the budget to resume sends" turns a support escalation into a settings change.&lt;/p&gt;
&lt;h2&gt;Counting spend honestly&lt;/h2&gt;
&lt;p&gt;The guard is only as good as the number it compares against the cap, and three details decide whether that number is honest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use the reconciled cost when you have it, the estimate until then.&lt;/strong&gt; Cost arrives in two stages — an estimate at send time, and Meta's authoritative billable-and-category verdict when the status webhook lands. The spend query prefers the second:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$spent = (float) MessageLog::query()
    -&amp;gt;where('direction', 'outbound')
    -&amp;gt;where('created_at', '&amp;gt;=', $windowStart)
    -&amp;gt;whereNot('status', 'failed')                         // failures are never billed
    -&amp;gt;where(DB::raw('COALESCE(category, expected_category)'), $budgetCategory)
    -&amp;gt;sum(DB::raw('COALESCE(cost_actual, cost_estimated)'));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Meta's category outranks yours.&lt;/strong&gt; That first &lt;code&gt;COALESCE&lt;/code&gt; is not decoration. Meta can reclassify a template after approval — utility to marketing is the common direction, at roughly six times the price. A guard that groups spend by the category you intended lets a reclassified template drain the marketing budget while being counted against utility, where nothing blocks. The webhook's verdict fills the &lt;code&gt;category&lt;/code&gt; column; until it arrives, the send-time guess stands in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failures do not count.&lt;/strong&gt; A message that failed was never billed. Summing it anyway makes the guard trip early, and on the marketing side an early block looks exactly like the feature working — nobody investigates a cap that fired.&lt;/p&gt;
&lt;h2&gt;Pin the window to the operating timezone&lt;/h2&gt;
&lt;p&gt;A daily budget resets at midnight. The only question is whose midnight, and the default answer — the application timezone, which on a stock deployment is UTC — is wrong in a way nobody notices until it fires.&lt;/p&gt;
&lt;p&gt;For a product operating on Gulf time, a "daily" window keyed to UTC resets at 04:00 local. A cap that trips during the evening peak stays tripped through the next morning's peak too, then resets mid-morning. The window and the business day disagree by four hours, and every incident report about it reads as confusing until someone draws the timeline.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private function windowStart(string $period): CarbonInterface
{
    $tz = (string) config('messaging.timezone', 'Asia/Dubai');

    return $period === 'daily'
        ? now($tz)-&amp;gt;startOfDay()-&amp;gt;utc()
        : now($tz)-&amp;gt;startOfMonth()-&amp;gt;utc();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compute the boundary in the operating timezone, convert to UTC, query in UTC. Storage stays uniform; the reset lands where the business thinks it does.&lt;/p&gt;
&lt;h2&gt;The zero that blocks everyone&lt;/h2&gt;
&lt;p&gt;One more counting rule, learned the painful way on a different budget system in the same codebase: &lt;strong&gt;a limit of zero means "not configured", never "block everything".&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$budgets = Budget::query()
    -&amp;gt;where('is_active', true)
    -&amp;gt;whereNotNull('limit_amount')
    -&amp;gt;where('limit_amount', '&amp;gt;', 0)     // 0 = unconfigured, not "deny all"
    -&amp;gt;get();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trap is mechanical: an unset config value casts to zero, and a guard comparing &lt;code&gt;spent &amp;gt;= limit&lt;/code&gt; against zero blocks every send from the first one. On the marketing side that is an outage with a clear symptom. The subtle version is a seeded budget row someone zeroes "to disable it" — which, under naive comparison, does the opposite of disabling.&lt;/p&gt;
&lt;h2&gt;Two guards, opposite failure modes, same funnel&lt;/h2&gt;
&lt;p&gt;Here is the part that looks inconsistent. In the same send funnel:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;a href="/en/whatsapp-otp-pumping-country-allowlist"&gt;country allowlist fails closed&lt;/a&gt; — an empty list denies every send.&lt;/li&gt;
&lt;li&gt;The budget guard fails &lt;strong&gt;open&lt;/strong&gt; — if its evaluation throws (table missing mid-deploy, database hiccup), the send proceeds and the failure is logged.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;public function check(?string $category): array
{
    try {
        return $this-&amp;gt;evaluate($category !== null ? strtoupper($category) : null);
    } catch (Throwable $e) {
        // The guard protects money, not security. An OTP must not
        // die of a budget query.
        Log::error('Budget guard failed, allowing send', ['message' =&amp;gt; $e-&amp;gt;getMessage()]);

        return ['allowed' =&amp;gt; true, 'blocking_budget' =&amp;gt; null, 'warnings' =&amp;gt; []];
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The asymmetry follows from what each guard protects. The allowlist guards against an adversary: failing open converts a config mistake into a payout endpoint, so it must not. The budget guards against overspend: failing closed converts a database hiccup into locked-out customers, so it must not. "Fail closed" is not a universal virtue — it is a question you answer per control, by asking which failure is worse. Write the answer as a comment on the catch block, because the next reviewer will flag whichever direction you chose as the inconsistent one.&lt;/p&gt;
&lt;h2&gt;Warn long before you block&lt;/h2&gt;
&lt;p&gt;A block with no warning phase teaches the operator that budgets are landmines. Each budget carries an alert threshold — 80 per cent by default — and crossing it logs a warning with the numbers in it while sends continue:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;marketing monthly budget at 85% (424.15/500.00) — sends continue
authentication monthly budget exhausted (12.4/10.00) — sends continue&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That second line is the asymmetry doing its quiet work: a functional category over its cap is a visible fact and an unstopped flow. The message says so explicitly, because a warning that reads like a block generates the same panic a block would.&lt;/p&gt;
&lt;h2&gt;Pin it with the test that matters&lt;/h2&gt;
&lt;p&gt;One test carries this whole design, and it is the one to write first: exhaust every budget, then prove an OTP still sends.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public function test_an_otp_is_never_blocked_by_any_budget(): void
{
    $this-&amp;gt;setBudget('MARKETING', 1.0);
    $this-&amp;gt;setBudget('AUTHENTICATION', 1.0);
    $this-&amp;gt;setBudget('ALL', 1.0);
    $this-&amp;gt;spend('AUTHENTICATION', 50.0);
    $this-&amp;gt;spend('MARKETING', 50.0);

    $result = $this-&amp;gt;sender-&amp;gt;sendOtp('9715XXXXXXXX', '123456');

    $this-&amp;gt;assertTrue($result['success']);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Its mirror — a marketing send refused at the cap, with the refusal written to the audit log — pins the other half. Between them they encode the policy in a place a refactor cannot quietly reverse it.&lt;/p&gt;
&lt;h2&gt;Check your own guard&lt;/h2&gt;
&lt;p&gt;Three questions. Does your spend cap distinguish categories, or does one bucket govern everything — and if one bucket, what happens to a login the day a campaign empties it? Does a zeroed or missing limit block traffic or admit it? And if the guard's own query throws, which way does it fail — and is that the direction you would choose on purpose?&lt;/p&gt;
&lt;p&gt;The guard described here ships in &lt;a href="https://github.com/dineshstack/laravel-whatsapp-cost-control" rel="noopener noreferrer"&gt;laravel-whatsapp-cost-control&lt;/a&gt; (MIT, Laravel 12 and 13), wired into the same funnel as the allowlist and the cost ledger: caps per category per period, timezone-pinned windows, warn-then-block on marketing only, and the OTP test above in its suite. The defaults encode the asymmetry so that the first budget someone configures cannot accidentally become the one that locks customers out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/whatsapp-budget-hard-block-marketing-only?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>product</category>
    </item>
    <item>
      <title>OTP pumping: the fraud that bills you for every code you send</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Thu, 13 Aug 2026 01:23:03 +0000</pubDate>
      <link>https://dev.to/dineshstack/otp-pumping-the-fraud-that-bills-you-for-every-code-you-send-2d8f</link>
      <guid>https://dev.to/dineshstack/otp-pumping-the-fraud-that-bills-you-for-every-code-you-send-2d8f</guid>
      <description>&lt;p&gt;An unauthenticated endpoint that sends a one-time code is a payout mechanism for anybody who controls a block of premium-rate numbers. They trigger it in volume against numbers they profit from, and you pay per message. Rate limiting does not close this, because the attacker can vary everything your rate limiter keys on — IP address, phone number, timing — while the thing that actually earns them money stays fixed: the destination country. That is the control. A country allowlist that fails closed removes the economics of the attack rather than trying to out-run its volume.&lt;/p&gt;
&lt;p&gt;The fraud is old and well documented on SMS, where it is usually called SMS pumping or artificially inflated traffic. Per-message WhatsApp billing brings the same economics to the Cloud API, with one difference worth noting up front: on SMS the money often flows through an aggregator who may eventually notice a strange pattern. On WhatsApp you are billed directly by Meta, per message, with &lt;a href="/en/whatsapp-per-message-cost-tracking-webhook"&gt;no invoice arriving in time to warn you&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;How the fraud actually pays&lt;/h2&gt;
&lt;p&gt;The attacker's revenue does not come from you. It comes from the termination fee paid to whoever operates the number range the message is delivered to. Control a range — or hold a revenue-share arrangement with an operator who does — and every message delivered into it earns a fraction of a cent.&lt;/p&gt;
&lt;p&gt;Which produces a very specific attacker profile, and it is not the one most defences assume:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;They do not want your data, your accounts, or your service. Nothing is breached.&lt;/li&gt;
&lt;li&gt;They do not need to complete the flow. The code is never entered. The send is the entire transaction.&lt;/li&gt;
&lt;li&gt;They are indifferent to which phone numbers they use, provided the numbers sit in a range that pays.&lt;/li&gt;
&lt;li&gt;They are patient. Slow, steady traffic is better for them than a burst, because it survives longer.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last point is the one that defeats most monitoring. There is no spike to alert on. The bill simply arrives larger than the month before, and the traffic looks like signups that never converted — which is a thing that happens anyway.&lt;/p&gt;
&lt;h2&gt;Why rate limiting is not the control&lt;/h2&gt;
&lt;p&gt;Rate limits are necessary. They are not sufficient, and it is worth being precise about why.&lt;/p&gt;
&lt;p&gt;A rate limiter keys on something about the request — usually IP address, sometimes the authenticated user, occasionally the submitted phone number. Every one of those is attacker-controlled:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-IP&lt;/strong&gt; is defeated by rotation. Residential proxy pools are cheap and large. Five requests a minute across a thousand addresses is five thousand requests a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-phone-number&lt;/strong&gt; is defeated by having more numbers. The attacker is choosing the numbers; a range holds thousands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user&lt;/strong&gt; does not apply. The endpoint is unauthenticated. That is the point of it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Worse, a rate limit is often looser than its author believes. On the platform this came from, the customer-facing send-code route declared five requests a minute and the driver-facing one had no route-level limit at all — it inherited only the generic API group, roughly sixty times looser, on an endpoint that was about to start billing per message. Both had passed review. The declared numbers were &lt;a href="/en/laravel-unnamed-throttle-shared-bucket"&gt;not the numbers being enforced&lt;/a&gt; either.&lt;/p&gt;
&lt;p&gt;Keep the rate limits. Tighten them. Just do not mistake them for the ceiling on this particular fraud, because they bound the rate and the attacker is not in a hurry.&lt;/p&gt;
&lt;h2&gt;The control that works&lt;/h2&gt;
&lt;p&gt;The attacker needs the message delivered into a range that pays them. That range is in a country. If your product serves customers in three countries and your system refuses to send anywhere else, the attack has no revenue in it regardless of how many IPs or numbers they bring.&lt;/p&gt;
&lt;p&gt;This is a much stronger position than rate limiting because it is not a race. It does not degrade under load, it does not need tuning, and it cannot be worn down by patience.&lt;/p&gt;
&lt;h3&gt;Fail closed, or it is not a control&lt;/h3&gt;
&lt;p&gt;The single most important property: an unset or empty allowlist must mean deny everything, never allow everything.&lt;/p&gt;
&lt;p&gt;This sounds pedantic until you consider how the list actually gets emptied. A missing environment variable on a new server. A typo in a deploy. A config cache built before the key existed. In every one of those, the fail-open version silently converts a configuration mistake into an open payout endpoint, and nothing in your logs looks unusual because sends are succeeding.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$codes = array_values(array_filter(array_map(
    'trim',
    explode(',', (string) config('messaging.allowed_country_codes'))
)));

// A blank value must NOT mean "allow everywhere". A misconfigured
// environment falls back to the narrowest safe default, not the widest.
$this-&amp;gt;allowedCountryCodes = $codes === [] ? ['971'] : $codes;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fail-closed defaults are unpopular because they break things loudly during setup. That is the feature. The alternative breaks things quietly during an incident.&lt;/p&gt;
&lt;h3&gt;Put the check in the send funnel, not the controller&lt;/h3&gt;
&lt;p&gt;Every send in the system has to pass through it, which means it cannot live in a controller. Count the entry points on a mature codebase and there are always more than expected: the customer app, the driver app, an admin "resend code" button, a background job retrying a failed delivery, a console command someone wrote for testing.&lt;/p&gt;
&lt;p&gt;A guard on four of five entry points is not a guard. Funnel every send through one method and check there:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private function send(string $operation, array $payload, ?string $category = null): array
{
    $to = $payload['to'];

    if (! $this-&amp;gt;isAllowedDestination($to)) {
        return $this-&amp;gt;blockedResult($operation, $payload);   // never reaches Meta
    }

    if (! $this-&amp;gt;budget-&amp;gt;allows($category)) {
        return $this-&amp;gt;budgetBlockedResult($operation, $payload);
    }

    // ... timeout-bounded HTTP, audit log, cost ledger
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The matching itself is deliberately dull — prefix comparison against normalised digits, with an explicit opt-out for the rare system that genuinely sends anywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private function isAllowedDestination(string $phone): bool
{
    $digits = ltrim($phone, '+');

    if (in_array('*', $this-&amp;gt;allowedCountryCodes, true)) {
        return true;   // explicit, never the default
    }

    foreach ($this-&amp;gt;allowedCountryCodes as $code) {
        if (str_starts_with($digits, $code)) {
            return true;
        }
    }

    return false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Make the refusal visible&lt;/h2&gt;
&lt;p&gt;A blocked send should be recorded as loudly as a failed one. Blocks are the earliest signal you will get that somebody is probing, and a control that silently discards traffic teaches you nothing about who is testing it.&lt;/p&gt;
&lt;p&gt;The useful trick is to make the three outcomes distinguishable in one column. In the audit log, HTTP status encodes all of them:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a number&lt;/td&gt;
&lt;td&gt;Meta answered with that status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Meta was unreachable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;We refused before sending&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;A sudden rise in &lt;code&gt;null&lt;/code&gt; rows for destinations outside your markets is the attack being attempted and stopped. Without that record it is invisible, which feels like safety and is actually just missing data.&lt;/p&gt;
&lt;p&gt;Log the country and the last four digits. Do not log the whole number — you are storing the fraudster's data, but the same code path handles your customers, and the reason to keep the column narrow is that it never sees a distinction between them.&lt;/p&gt;
&lt;h2&gt;What this does not solve&lt;/h2&gt;
&lt;p&gt;Worth being straight about the limits, because a control oversold is a control someone will trust too far.&lt;/p&gt;
&lt;p&gt;An allowlist does nothing about abuse from inside your own market. Somebody with a payout arrangement on a range in a country you legitimately serve is not blocked by any of this, and that is the case where per-number caps and velocity monitoring earn their place.&lt;/p&gt;
&lt;p&gt;It does not help if your markets are genuinely global. A system that must send anywhere has to fall back on the weaker controls, and should expect to spend more on monitoring as a result.&lt;/p&gt;
&lt;p&gt;And it is not a substitute for a spend cap. The allowlist bounds where money can go; it says nothing about how much. Those are separate questions and they want separate answers — a budget that hard-blocks a runaway campaign while never blocking a login is the other half, and it gets its own post.&lt;/p&gt;
&lt;h2&gt;Check your own endpoints&lt;/h2&gt;
&lt;p&gt;Three questions, in order of how much they will tell you.&lt;/p&gt;
&lt;p&gt;First: can your send-code endpoint deliver to a country you do not sell in? Try it against a number outside your markets in a non-production environment. If the message goes, you have no allowlist.&lt;/p&gt;
&lt;p&gt;Second: what happens with the setting removed entirely? Blank the config value and try again. A send that still succeeds means the implementation fails open, which is the failure mode that actually bites — the list is rarely wrong on purpose, it is empty by accident.&lt;/p&gt;
&lt;p&gt;Third: how many entry points reach your sender? Grep for it and compare against where the guard lives:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep -rn "sendTemplate|sendOtp|sendMessage" app/ Modules/ --include="*.php" | grep -v "Tests|/Messaging/"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every result is a path that must pass the check. If the guard is in a controller and that list is longer than one, it is already being bypassed.&lt;/p&gt;
&lt;p&gt;The funnel described here — allowlist, then budget, then a timeout-bounded call, then the audit log and cost ledger — is packaged as &lt;a href="https://github.com/dineshstack/laravel-whatsapp-cost-control" rel="noopener noreferrer"&gt;laravel-whatsapp-cost-control&lt;/a&gt;, MIT licensed, for Laravel 12 and 13. The allowlist ships fail-closed: it will refuse to send anywhere until you configure the countries you actually serve, which is a deliberately annoying five minutes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/whatsapp-otp-pumping-country-allowlist?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>infosec</category>
      <category>security</category>
    </item>
    <item>
      <title>My CV Got Me UAE Tech Jobs Through Indeed for Three Years. Then It Stopped.</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Thu, 13 Aug 2026 01:00:07 +0000</pubDate>
      <link>https://dev.to/dineshstack/my-cv-got-me-uae-tech-jobs-through-indeed-for-three-years-then-it-stopped-2cn6</link>
      <guid>https://dev.to/dineshstack/my-cv-got-me-uae-tech-jobs-through-indeed-for-three-years-then-it-stopped-2cn6</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; My CV brought in multiple UAE offers between 2022 and the end of 2024, every one of them through Indeed and none through LinkedIn. Then it stopped. It did not stop because it got worse or because the market collapsed. It stopped because I changed — seniority, specialism, the kind of role I was going for — and the document did not. And LinkedIn never worked at all, for a separate reason it took me years to see: I had a profile the whole time, but I was never findable.&lt;/p&gt;
&lt;p&gt;This is the first of three posts. This one is what happened and why. The second is the mechanic underneath it — why job boards and LinkedIn are not two versions of the same thing. The third is the part nobody selling CV templates will tell you.&lt;/p&gt;
&lt;h2&gt;What actually happened&lt;/h2&gt;
&lt;p&gt;I moved from Sri Lanka to a tech job in the UAE. I am now a Tech Lead in Abu Dhabi with more than ten years of experience.&lt;/p&gt;
&lt;p&gt;The window this post is about — 2022 to late 2024 — is entirely after that move. I was living and working in the UAE for all of it. That matters, because it removes the easiest explanation before we start.&lt;/p&gt;
&lt;p&gt;Across those three years the same CV produced multiple UAE offers. Every single one came through Indeed. Not one came through LinkedIn, despite my having a LinkedIn profile the entire time.&lt;/p&gt;
&lt;p&gt;I have friends here who get roles through LinkedIn and genuinely cannot explain how. Ask them what they did and the answer is some version of "a recruiter messaged me." That is not modesty. It is the whole point, and it took me a long time to understand why.&lt;/p&gt;
&lt;p&gt;After December 2024 the same CV produced nothing.&lt;/p&gt;
&lt;h2&gt;Three reasons, and the first one is the least flattering&lt;/h2&gt;
&lt;h3&gt;1. I stopped applying&lt;/h3&gt;
&lt;p&gt;I got a job in December 2024. My application volume collapsed. Some meaningful portion of "my CV stopped working" is simply that I stopped sending it.&lt;/p&gt;
&lt;p&gt;I am putting this first because it is the one I would most like to skip. Every post in this genre blames the market, and blaming the market is comfortable — it makes the failure external and the solution purchasable. Before I tell you anything about market conditions, I want to be clear that a chunk of my own data is just reduced input. If you are drawing conclusions from your own search, run this check first. Fewer replies from a tenth as many applications is not a signal about your CV.&lt;/p&gt;
&lt;h3&gt;2. I crossed the seniority line&lt;/h3&gt;
&lt;p&gt;At mid-level, job boards work. The roles are posted, the volume is high, and the process is designed to filter a large inbound pile.&lt;/p&gt;
&lt;p&gt;Lead and senior roles are mostly not filled that way. They go through networks and recruiter outreach. By 2025 I was applying for roles that largely are not advertised on the boards I was searching — and the ones that are tend to have already been filled through other routes by the time they appear.&lt;/p&gt;
&lt;p&gt;The channel did not break. It aged out of my career stage.&lt;/p&gt;
&lt;h3&gt;3. The market tightened — but less than it feels, and not evenly&lt;/h3&gt;
&lt;p&gt;It is harder, and there is real data on it. &lt;a href="https://gulfnews.com/business/economy/why-finding-a-job-in-the-uae-may-soon-feel-very-different-as-72-seek-job-change-linkedin-1.500405585" rel="noopener noreferrer"&gt;LinkedIn research reported by Gulf News&lt;/a&gt; found that 65% of UAE professionals say finding a role has become harder over the past twelve months, while 72% plan to look for a new job anyway.&lt;/p&gt;
&lt;p&gt;But look at the reason they gave. 63% named an overcrowded candidate pool as the biggest obstacle — not a shortage of roles. That is a different problem with a different fix. If jobs had vanished, nothing about your CV would matter. If you are one of far more applicants for the same jobs, then standing out is the entire game, and a document that makes you look like everyone else is an active liability rather than a neutral one.&lt;/p&gt;
&lt;p&gt;Demand in tech also did not fall so much as move. PwC's &lt;a href="https://www.pwc.com/m1/en/publications/ai-jobs-barometer-uae-2026.html" rel="noopener noreferrer"&gt;2026 Global AI Jobs Barometer&lt;/a&gt; puts UAE job postings requiring AI skills at 1.0% in 2021 and 3.2% in 2025 — roughly 4,600 adverts rising to 12,200, moving the UAE from 21st to 13th globally in four years. Those roles pay a premium of up to 92% in financial services and around 50% in technology, media and telecoms.&lt;/p&gt;
&lt;p&gt;Read that last number again, because it is the whole argument for specialising. The market is not paying more for people who can do everything. It is paying up to 92% more for people who can demonstrably do one thing that is currently scarce.&lt;/p&gt;
&lt;p&gt;My CV led with "full stack" and listed more than twenty technologies. In 2022 that read as range. By 2025 it reads as someone who has not decided what they are — in a market where the premium goes to people who have.&lt;/p&gt;
&lt;h2&gt;The part I did not see for two years&lt;/h2&gt;
&lt;p&gt;Those three causes look independent. They are not. Look at what changed between 2022 and now:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;2022&lt;/th&gt;
&lt;th&gt;Late 2024 onward&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seniority&lt;/td&gt;
&lt;td&gt;Mid-level&lt;/td&gt;
&lt;td&gt;Tech Lead, 10+ years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roles I was going for&lt;/td&gt;
&lt;td&gt;Developer&lt;/td&gt;
&lt;td&gt;Lead and senior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Positioning&lt;/td&gt;
&lt;td&gt;Generalist&lt;/td&gt;
&lt;td&gt;Production AI and LLM work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;City&lt;/td&gt;
&lt;td&gt;Dubai&lt;/td&gt;
&lt;td&gt;Abu Dhabi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What my CV said&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~8 years, full stack, 20+ technologies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~8 years, full stack, 20+ technologies&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Read the last row again. Everything above it changed. That row did not.&lt;/p&gt;
&lt;p&gt;That is the actual failure, and it is not a writing problem. The CV was a snapshot of the person who last needed one. Almost nobody updates a CV except while job hunting, which means everyone's CV describes the last version of them that was looking — and the gap gets wider the longer the job goes well.&lt;/p&gt;
&lt;p&gt;Mine understated me in three separate ways at once. It said developer where the answer was Tech Lead. It said eight years where the answer was more than ten. And it said generalist where the honest answer had become a specialism in production AI systems — the kind of work I now write about in detail, like the &lt;a href="https://dineshstack.com/en/how-we-built-a-bilingual-ai-voice-assistant-in-laravel-arabic-english-part-1-of-4" rel="noopener noreferrer"&gt;bilingual Arabic and English voice assistant&lt;/a&gt; we ran at roughly 40ms latency.&lt;/p&gt;
&lt;p&gt;The city row is a small one, but it is the same failure in a different artifact. I moved from Dubai to Abu Dhabi when I took the December 2024 job. Recruiter search filters by city. If a profile still says the city you left, you are absent from searches for the city you are actually in — and, like the CV, nothing tells you.&lt;/p&gt;
&lt;h2&gt;Why that last one matters more than it looks&lt;/h2&gt;
&lt;p&gt;There is a mechanic under all of this that took me far too long to work out, and it explains my friends.&lt;/p&gt;
&lt;p&gt;Indeed and LinkedIn are not two places to find the same jobs. They run in opposite directions.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;Indeed&lt;/th&gt;
&lt;th&gt;LinkedIn&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;You apply outward&lt;/td&gt;
&lt;td&gt;Recruiters search inward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you control&lt;/td&gt;
&lt;td&gt;How many applications you send&lt;/td&gt;
&lt;td&gt;Whether you are findable at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How it fails&lt;/td&gt;
&lt;td&gt;You get rejected&lt;/td&gt;
&lt;td&gt;You are never seen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Recruiters do not browse LinkedIn the way you do. They use LinkedIn Recruiter, which is a search tool with dozens of filters running against a very large index of profiles. You either surface in the result set or you functionally do not exist for that search. There is no rejection, because there was never an application.&lt;/p&gt;
&lt;p&gt;That is why my friends cannot explain what they did. They are not doing anything. They are being found. You cannot apply your way into an inbound channel, and no amount of application volume substitutes for being in the result set.&lt;/p&gt;
&lt;p&gt;Now put my own three years against that. I was in the UAE the entire time. Both channels were open to me, the whole way through. Indeed produced offers for three years. LinkedIn produced nothing, ever — not fewer results, none.&lt;/p&gt;
&lt;p&gt;Same person, same city, same experience, same week. One channel worked and the other never did once. That is not a market story and it is not bad luck. I was doing the work for one channel and none of the work for the other. On Indeed I was sending applications, which is the entire job on Indeed. On LinkedIn I had a profile and assumed that was participation. It is not. A profile is not a fishing line in the water; it is a page that either matches a recruiter's search or does not.&lt;/p&gt;
&lt;p&gt;Mine did not. My headline said "full stack developer" — one of the most crowded search terms in the market, where I was competing with thousands of profiles saying exactly the same thing, and offering a recruiter no reason to pick mine out. I had never turned on the recruiter-facing "open to work" setting. My skills list was an afterthought. Every one of those is a filter I was failing without ever seeing a result.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;On the outbound channel, effort looks like applications. On the inbound channel, effort looks like being findable. Doing a lot of the first has never once produced the second.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;So there were two separate failures running at the same time, and I had been reading them as one. The CV went stale, which cost me the outbound channel as I moved up into roles that boards do not carry. And the profile was never findable, which meant the inbound channel — the one that actually serves senior roles — had never been switched on at all.&lt;/p&gt;
&lt;p&gt;One more filter worth naming, because it does not apply to me but will apply to a lot of people reading this: &lt;strong&gt;those search filters include location.&lt;/strong&gt; A recruiter hiring in Dubai searches Dubai. If you are still in Colombo or Chennai or Karachi, you are excluded before a word of your headline is read — which is why applying outward through job boards is the realistic route until you have arrived. I will cover the one setting that partly gets around it in the next post.&lt;/p&gt;
&lt;h2&gt;What this means if you are in the middle of it&lt;/h2&gt;
&lt;p&gt;Before you rewrite anything, work out which channel you are actually in, because the advice inverts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you are still outside the UAE&lt;/strong&gt;, outbound is your game, and that is fine — it is the channel that got me here too. Volume matters. Your CV is doing the heavy lifting because it is the only artifact in the process. State your visa position explicitly rather than leaving a recruiter to assume the expensive answer, and say that you can relocate. Most LinkedIn optimisation will not reach you yet, for the location reason above, with one exception I will cover next.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you are already here and applying,&lt;/strong&gt; as I was for three years: you are running one channel out of two. That can work for a long time, exactly as it worked for me — right up until the roles you want stop being posted on it. Then it stops, and because there is no rejection to read, it feels like the market turned rather than like a channel you never opened.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you are already here and senior&lt;/strong&gt;, volume is not your problem — visibility is. The CV matters less than you think, because it is what you send after being found, and being found is a different skill with different levers.&lt;/p&gt;
&lt;p&gt;And for everyone: open your CV and check the date on the claims, not the formatting. Does it say your current title? Your current years? The thing you are actually good at now, or the thing you were good at when you last needed a job? A stale CV fails quietly. There is no bounce, no rejection email, no signal at all — which is exactly why it can keep failing for a year without you noticing.&lt;/p&gt;
&lt;h2&gt;Check yours before you rewrite it&lt;/h2&gt;
&lt;p&gt;I built a free checker for this, because I could not find one that understood this market. It scores your CV against what Gulf recruiters actually filter on — visa status, notice period, formatting that survives an applicant tracking system, and whether you carry the keywords for the role you are targeting.&lt;/p&gt;
&lt;p&gt;It runs entirely in your browser. Your CV is not uploaded anywhere, there is no signup, and the score and every fix are free.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://dineshstack.com/career/cv-check" rel="noopener noreferrer"&gt;&lt;strong&gt;Check your CV against the UAE market&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;One honest limit, the same one I put on the tool itself: this tells you whether your CV survives the filter. It cannot tell you whether the right roles are open. If nothing posted matches your experience, a perfect CV will not create one.&lt;/p&gt;
&lt;p&gt;Next in this series: why you cannot apply your way into LinkedIn, what actually decides whether a recruiter's search returns you, and the one findability lever that works even before you have arrived.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to migrate Laravel 13 + Next.js to Zero-Downtime VPS Releases</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:12:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/how-to-migrate-laravel-13-nextjs-to-zero-downtime-vps-releases-17i3</link>
      <guid>https://dev.to/dineshstack/how-to-migrate-laravel-13-nextjs-to-zero-downtime-vps-releases-17i3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Part 9 of the CI/CD for Laravel Developers series.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Migrate Laravel 13 + Next.js to zero-downtime VPS releases in under 20 minutes — no downtime during the migration itself. This post is part of the &lt;a href="/blog/category/devops"&gt;DevOps series&lt;/a&gt; on deploying Laravel and Next.js with GitHub Actions. Most tutorials assume you're starting from scratch. This one assumes you already have a live project at &lt;code&gt;/var/www/your-project&lt;/code&gt; and want to move it to the releases/symlink pattern so GitHub Actions can deploy atomically.&lt;/p&gt;
&lt;p&gt;We'll migrate a monorepo with a Laravel 13 API and a Next.js 16 frontend running under Nginx and PM2 on a single Ubuntu VPS. The same steps apply to any project layout with minor path adjustments.&lt;/p&gt;
&lt;h2&gt;What Is the Releases Pattern and Why You Need It on VPS&lt;/h2&gt;
&lt;p&gt;In a normal VPS setup, your deployment overwrites files in the live directory. During that window — while &lt;code&gt;composer install&lt;/code&gt; or &lt;code&gt;npm ci&lt;/code&gt; runs — your app is in a broken state. Requests hit a mix of old PHP files and new ones, or a half-installed vendor directory.&lt;/p&gt;
&lt;p&gt;The releases pattern solves this by keeping every deployment in its own timestamped directory. The live path is a symlink that points to the current release. When a new deploy finishes, you flip the symlink atomically — the switch takes microseconds and Nginx follows it instantly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/var/www/visa-saas/&lt;br&gt;
├── api -&amp;gt; releases/20260710143022    ← symlink (one atomic flip)&lt;br&gt;
├── releases/&lt;br&gt;
│   ├── initial/                      ← your existing app (backed up here)&lt;br&gt;
│   ├── 20260710143022/               ← current live release&lt;br&gt;
│   └── 20260711091544/               ← next release (building here)&lt;br&gt;
├── shared/&lt;br&gt;
│   ├── .env                          ← one .env, symlinked into every release&lt;br&gt;
│   └── storage/                      ← persistent uploads and logs&lt;br&gt;
└── web/                              ← Next.js (swap pattern, explained below)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;shared/&lt;/code&gt; directory holds everything that must persist across releases: your &lt;code&gt;.env&lt;/code&gt; file and the Laravel &lt;code&gt;storage/&lt;/code&gt; directory (uploads, logs, sessions). Each release symlinks to them instead of containing its own copy.&lt;/p&gt;
&lt;h2&gt;Before You Start: What Your Live VPS Looks Like Now&lt;/h2&gt;
&lt;p&gt;Typical existing layout — a git clone or manual upload, served directly by Nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/var/www/visa-saas/&lt;br&gt;
├── api/            ← Laravel 13 app (Nginx serves api/public)&lt;br&gt;
├── web/            ← Next.js 16 (PM2 runs from this directory)&lt;br&gt;
└── README.md&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nginx root currently points to &lt;code&gt;/var/www/visa-saas/api/public&lt;/code&gt; and PM2 has &lt;code&gt;cwd: /var/www/visa-saas/web&lt;/code&gt;. After the migration, Nginx will point to the same path — but &lt;code&gt;api&lt;/code&gt; will be a symlink instead of a directory, and Nginx follows symlinks transparently with no config change.&lt;/p&gt;
&lt;h2&gt;Step 1: Pause the Queue Worker Before Touching VPS Files&lt;/h2&gt;
&lt;p&gt;Signal any queue workers to stop picking up new jobs before touching files. They'll finish their current job and exit cleanly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /var/www/visa-saas/api&lt;br&gt;
php artisan queue:restart      # signals workers to exit after current job&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you're running Laravel Horizon, use &lt;code&gt;php artisan horizon:pause&lt;/code&gt; instead. If you have no queue workers, skip this step.&lt;/p&gt;
&lt;h2&gt;Step 2: Create the Releases and Shared Directory Structure on VPS&lt;/h2&gt;
&lt;p&gt;Create the &lt;code&gt;releases/&lt;/code&gt; and &lt;code&gt;shared/&lt;/code&gt; directories alongside your existing &lt;code&gt;api/&lt;/code&gt; directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /var/www/visa-saas

&lt;/code&gt;&lt;p&gt;&lt;code&gt;mkdir -p releases&lt;br&gt;&lt;br&gt;
mkdir -p shared/storage/app/public&lt;br&gt;&lt;br&gt;
mkdir -p shared/storage/framework/{cache/data,sessions,views}&lt;br&gt;&lt;br&gt;
mkdir -p shared/storage/logs&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;h2&gt;Step 3: Back Up Your Live Laravel 13 App as the Initial Release&lt;/h2&gt;
&lt;p&gt;Your current live app becomes the first named release. Nothing is deleted — this is a copy, not a move:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cp -a api releases/initial&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cp -a&lt;/code&gt; (archive mode) preserves ownership, permissions, and symlinks. Your original &lt;code&gt;api/&lt;/code&gt; directory stays intact as a safety net until you've confirmed everything works through the symlink.&lt;/p&gt;
&lt;h2&gt;Step 4: Move the Laravel .env and Storage to the Shared Directory&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file and &lt;code&gt;storage/&lt;/code&gt; directory must live outside every release so they survive across deployments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Move .env:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cp releases/initial/.env shared/.env&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Move storage contents:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Copy contents (not the directory itself) into shared/storage&lt;br&gt;&lt;br&gt;
cp -a releases/initial/storage/. shared/storage/

&lt;h1&gt;
  
  
  Verify
&lt;/h1&gt;

&lt;p&gt;ls shared/storage/&lt;/p&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  app  framework  logs&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fix ownership&lt;/strong&gt; so PHP-FPM can write to the shared storage:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown -R www-data:www-data shared/storage

&lt;/code&gt;&lt;p&gt;&lt;code&gt;sudo chmod -R 775 shared/storage&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;h2&gt;Step 5: Symlink Shared Resources Into the Laravel Release&lt;/h2&gt;
&lt;p&gt;Wire &lt;code&gt;releases/initial&lt;/code&gt; to use the shared files instead of its own copies:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /var/www/visa-saas/releases/initial

&lt;h1&gt;
  
  
  Remove the copied storage and .env
&lt;/h1&gt;

&lt;p&gt;rm -rf storage&lt;br&gt;
rm -f .env&lt;/p&gt;

&lt;h1&gt;
  
  
  Symlink to shared — always use absolute paths
&lt;/h1&gt;

&lt;p&gt;ln -sfn /var/www/visa-saas/shared/storage storage&lt;br&gt;
ln -sfn /var/www/visa-saas/shared/.env .env&lt;/p&gt;

&lt;h1&gt;
  
  
  Verify
&lt;/h1&gt;

&lt;p&gt;ls -la storage .env&lt;/p&gt;

&lt;h1&gt;
  
  
  .env -&amp;gt; /var/www/visa-saas/shared/.env
&lt;/h1&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  storage -&amp;gt; /var/www/visa-saas/shared/storage&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;h2&gt;Step 6: Replace the api/ Directory With a Symlink on VPS&lt;/h2&gt;
&lt;p&gt;Back in the project root, rename the original &lt;code&gt;api/&lt;/code&gt; directory to a backup and create the symlink:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /var/www/visa-saas

&lt;h1&gt;
  
  
  Rename the original (keep as backup until confirmed working)
&lt;/h1&gt;

&lt;p&gt;mv api api-backup&lt;/p&gt;

&lt;h1&gt;
  
  
  Create the symlink
&lt;/h1&gt;

&lt;p&gt;ln -sfn /var/www/visa-saas/releases/initial api&lt;/p&gt;

&lt;h1&gt;
  
  
  Verify
&lt;/h1&gt;

&lt;p&gt;ls -la api&lt;/p&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  api -&amp;gt; /var/www/visa-saas/releases/initial&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;h2&gt;Step 7: Verify Nginx Follows the Symlink to Laravel public/&lt;/h2&gt;
&lt;p&gt;Nginx's &lt;code&gt;root /var/www/visa-saas/api/public&lt;/code&gt; directive resolves through the symlink automatically. Test the config and reload:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nginx -t

&lt;h1&gt;
  
  
  nginx: configuration file /etc/nginx/nginx.conf syntax is OK
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;sudo systemctl reload nginx&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;Hit your API health check to confirm:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s &lt;a href="https://api-visa-recruiter.orions360.com/up" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://api-visa-recruiter.orions360.com/up" rel="noopener noreferrer"&gt;https://api-visa-recruiter.orions360.com/up&lt;/a&gt;
&lt;br&gt;
&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  {"status":"ok"}&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;p&gt;If you get a 502 or permission denied, PHP-FPM may need explicit symlink permission. Add this to your Nginx server block:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {

&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root /var/www/visa-saas/api/public;

location / {
    try_files $uri $uri/ /index.php?$query_string;
    disable_symlinks off;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;}&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;Most Ubuntu + Nginx setups follow symlinks by default. &lt;code&gt;disable_symlinks off&lt;/code&gt; is only needed if your distro explicitly enabled the restriction.&lt;/p&gt;
&lt;h2&gt;Step 8: Remove the Backup Once the VPS Migration Is Confirmed&lt;/h2&gt;
&lt;p&gt;With the app confirmed working through the symlink, remove the backup directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rm -rf /var/www/visa-saas/api-backup&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 9: Update Your GitHub Actions Deploy Script for Zero-Downtime Releases&lt;/h2&gt;
&lt;p&gt;Now that the structure is in place on the VPS, your GitHub Actions SSH deploy script can use the full releases pattern. Each deploy creates a new timestamped directory, installs dependencies, warms caches, then flips the symlink atomically:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      - name: Run release on server&lt;br&gt;&lt;br&gt;
        uses: appleboy/ssh-action@v1&lt;br&gt;&lt;br&gt;
        with:&lt;br&gt;&lt;br&gt;
          host: ${{ secrets.VPS_HOST }}&lt;br&gt;&lt;br&gt;
          username: ${{ secrets.VPS_USER }}&lt;br&gt;&lt;br&gt;
          key: ${{ secrets.VPS_SSH_KEY }}&lt;br&gt;&lt;br&gt;
          script: |&lt;br&gt;&lt;br&gt;
            set -euo pipefail

&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        DEPLOY=/var/www/visa-saas
        RELEASE="$DEPLOY/releases/$(date +%Y%m%d%H%M%S)"
        SHARED="$DEPLOY/shared"

        # 1. Extract into a new timestamped release directory
        mkdir -p "$RELEASE"
        tar -xzf /tmp/api-release.tar.gz -C "$RELEASE"

        # 2. Wire shared .env and storage
        cd "$RELEASE"
        rm -rf storage
        ln -sfn "$SHARED/storage" storage
        ln -sfn "$SHARED/.env" .env

        # 3. Install and warm caches
        composer install --no-dev --optimize-autoloader --no-interaction --quiet
        php artisan migrate --force
        php artisan config:cache
        php artisan route:cache
        mkdir -p resources/views &amp;amp;amp;&amp;amp;amp; php artisan view:cache
        php artisan event:cache
        php artisan queue:restart || true

        # 4. Atomic symlink flip — zero downtime
        ln -sfn "$RELEASE" "$DEPLOY/api"

        # 5. Reload PHP-FPM to clear opcache
        sudo systemctl reload php8.4-fpm

        # 6. Keep only the last 5 releases
        ls -1dt "$DEPLOY/releases/"* | tail -n +6 | xargs rm -rf || true

        rm -f /tmp/api-release.tar.gz
        echo "✓ API deployed: $RELEASE"&amp;lt;/code&amp;gt;&amp;lt;/pre&amp;gt;&amp;lt;p&amp;gt;The atomic flip on step 4 is the key line: &amp;lt;code&amp;gt;ln -sfn "$RELEASE" "$DEPLOY/api"&amp;lt;/code&amp;gt; replaces the symlink in a single filesystem operation. Nginx reads the new target on the very next request — no reload, no downtime.&amp;lt;/p&amp;gt;&amp;lt;h2&amp;gt;How Next.js 16 Migration Works Differently on VPS&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;The releases/symlink pattern works perfectly for Laravel 13 because PHP reads files on every request — the new symlink target takes effect immediately. Next.js 16 running under PM2 is different: PM2 holds the process in memory with a fixed &amp;lt;code&amp;gt;cwd&amp;lt;/code&amp;gt;. Flipping a symlink doesn't cause PM2 to reload its running process.&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;For Next.js we use a &amp;lt;strong&amp;gt;staging swap&amp;lt;/strong&amp;gt; instead — extract to a staging directory, install deps, then atomically rename it into place:&amp;lt;/p&amp;gt;&amp;lt;pre&amp;gt;&amp;lt;code class="language-bash"&amp;gt;BASE=/var/www/visa-saas
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;STAGING="$BASE/web-staging"&lt;/p&gt;

&lt;h1&gt;
  
  
  Extract to staging
&lt;/h1&gt;

&lt;p&gt;rm -rf "$STAGING" &amp;amp;&amp;amp; mkdir -p "$STAGING"&lt;br&gt;
tar -xzf /tmp/web-release.tar.gz -C "$STAGING"&lt;br&gt;
ln -sfn "$BASE/shared/.env.local" "$STAGING/.env.local"&lt;/p&gt;

&lt;h1&gt;
  
  
  Install production deps (ignore husky and other dev lifecycle scripts)
&lt;/h1&gt;

&lt;p&gt;cd "$STAGING"&lt;br&gt;
npm ci --omit=dev --ignore-scripts&lt;/p&gt;

&lt;h1&gt;
  
  
  Swap: remove old web/, rename staging to web/
&lt;/h1&gt;

&lt;p&gt;rm -rf "$BASE/web"&lt;br&gt;
mv "$STAGING" "$BASE/web"&lt;/p&gt;

&lt;h1&gt;
  
  
  Restart PM2 from the new web/ directory
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;cd "$BASE/web"&lt;br&gt;&lt;br&gt;
pm2 delete visa-saas 2&amp;gt;/dev/null || true&lt;br&gt;&lt;br&gt;
pm2 start ecosystem.config.js&lt;br&gt;&lt;br&gt;
pm2 save&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;This causes ~1–2 seconds of PM2 downtime during the restart. For true zero-downtime Next.js deploys, apply the symlink pattern here too — but PM2's &lt;code&gt;ecosystem.config.js&lt;/code&gt; must point &lt;code&gt;cwd&lt;/code&gt; to a symlink path that you flip, not the real directory path.&lt;/p&gt;
&lt;h2&gt;Rollback to a Previous Laravel Release in One Command&lt;/h2&gt;
&lt;p&gt;The main benefit of keeping old releases on the VPS: if a deploy breaks production, rollback is a single symlink change — no re-deploy, no &lt;code&gt;composer install&lt;/code&gt;, no migration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# List releases newest first&lt;br&gt;&lt;br&gt;
ls -1dt /var/www/visa-saas/releases/*

&lt;h1&gt;
  
  
  Roll back to the previous release
&lt;/h1&gt;

&lt;p&gt;ln -sfn /var/www/visa-saas/releases/20260709091544 /var/www/visa-saas/api&lt;br&gt;
sudo systemctl reload php8.4-fpm&lt;/p&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;echo "✓ Rolled back"&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;The previous release directory is already fully installed. Rollback takes seconds.&lt;/p&gt;
&lt;h2&gt;Common Issues During VPS Migration&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Nginx 403 after creating the symlink:&lt;/strong&gt; The deploy user owns &lt;code&gt;releases/initial/&lt;/code&gt; but &lt;code&gt;www-data&lt;/code&gt; needs read access. Run &lt;code&gt;sudo chown -R &amp;lt;logged_in_user_name&amp;gt;:www-data /var/www/visa-saas/releases&lt;/code&gt; and &lt;code&gt;chmod -R 750 releases/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Laravel 13 can't write to storage after migration:&lt;/strong&gt; The &lt;code&gt;shared/storage/&lt;/code&gt; directory must be owned by &lt;code&gt;www-data&lt;/code&gt;. Run &lt;code&gt;sudo chown -R www-data:www-data /var/www/visa-saas/shared/storage&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;php artisan commands can't find .env:&lt;/strong&gt; The symlink in &lt;code&gt;releases/initial&lt;/code&gt; must use the full absolute path (&lt;code&gt;/var/www/visa-saas/shared/.env&lt;/code&gt;), not a relative path. Relative symlinks break when you &lt;code&gt;cd&lt;/code&gt; into the release directory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Opcache serving stale PHP after the symlink flip:&lt;/strong&gt; PHP's opcache caches the resolved real path, so it continues serving old bytecode after the symlink changes. Always reload PHP-FPM immediately after the symlink flip: &lt;code&gt;sudo systemctl reload php8.4-fpm&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Verify the Final VPS Structure&lt;/h2&gt;
&lt;p&gt;After your first automated deploy through GitHub Actions, confirm the structure looks exactly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls -la /var/www/visa-saas/

&lt;h1&gt;
  
  
  api -&amp;gt; /var/www/visa-saas/releases/20260710143022
&lt;/h1&gt;

&lt;h1&gt;
  
  
  releases/
&lt;/h1&gt;

&lt;h1&gt;
  
  
  initial/
&lt;/h1&gt;

&lt;h1&gt;
  
  
  20260710143022/
&lt;/h1&gt;

&lt;h1&gt;
  
  
  shared/
&lt;/h1&gt;

&lt;h1&gt;
  
  
  .env
&lt;/h1&gt;

&lt;h1&gt;
  
  
  storage/
&lt;/h1&gt;

&lt;h1&gt;
  
  
  web/
&lt;/h1&gt;

&lt;p&gt;ls -la /var/www/visa-saas/releases/20260710143022/&lt;/p&gt;

&lt;h1&gt;
  
  
  .env -&amp;gt; /var/www/visa-saas/shared/.env
&lt;/h1&gt;

&lt;h1&gt;
  
  
  storage -&amp;gt; /var/www/visa-saas/shared/storage
&lt;/h1&gt;

&lt;h1&gt;
  
  
  vendor/
&lt;/h1&gt;

&lt;h1&gt;
  
  
  app/
&lt;/h1&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  ...all Laravel 13 files&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;h2&gt;Related Posts in This Series&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="/blog/zero-downtime-deploy-vps-github-actions-laravel"&gt;Post 6: Zero-Downtime Deploy to VPS with GitHub Actions and Laravel&lt;/a&gt; — the complete GitHub Actions workflow that uses this directory structure&lt;/li&gt;
&lt;li&gt;
&lt;a href="/blog/github-actions-vps-deploy-laravel-13-nextjs-real-errors"&gt;Post 8: Deploy Laravel 13 to VPS — 12 GitHub Actions Errors Fixed&lt;/a&gt; — every error we hit running this pipeline for the first time&lt;/li&gt;
&lt;li&gt;
&lt;a href="/blog/github-actions-secrets-env-vars-laravel"&gt;Post 5: Managing Secrets and Environment Variables in GitHub Actions&lt;/a&gt; — set up VPS_HOST, VPS_SSH_KEY and the shared .env correctly before running any deploy&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/migrate-laravel-nextjs-vps-zero-downtime-releases-pattern?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>laravel</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>WhatsApp Cloud API sends you no invoice until it is too late</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:50:07 +0000</pubDate>
      <link>https://dev.to/dineshstack/whatsapp-cloud-api-sends-you-no-invoice-until-it-is-too-late-12lj</link>
      <guid>https://dev.to/dineshstack/whatsapp-cloud-api-sends-you-no-invoice-until-it-is-too-late-12lj</guid>
      <description>&lt;p&gt;Meta does not send you a WhatsApp invoice you can act on in time. Billing is per message, priced by template category and recipient country, and the rate card changes on Meta's schedule rather than yours. The API response to a send contains a message id and no price. The delivery-status webhook carries a &lt;code&gt;pricing&lt;/code&gt; object — and that has no amount in it either. What you get is a billable flag and a category, which you multiply against a rate card you maintain yourself. If you are not capturing that webhook, you have no per-message cost data and no way to reconstruct it, because Meta exposes no per-message billing history through the API.&lt;/p&gt;
&lt;p&gt;This came out of a booking platform where WhatsApp carried one-time codes. The webhook receiver already existed, validated Meta's signature correctly, and threw the entire payload away. Nobody noticed, because nothing about the system looked broken — it simply could not answer what a message cost.&lt;/p&gt;
&lt;h2&gt;What Meta actually charges for&lt;/h2&gt;
&lt;p&gt;WhatsApp Business Platform moved from per-conversation to per-message billing on 1 July 2025. Every template message is priced individually along two axes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Category&lt;/strong&gt; — marketing, utility, authentication or service. Marketing is by far the most expensive; service messages are free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recipient country&lt;/strong&gt; — the same template costs different amounts depending on where the handset is.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two further wrinkles matter more than they first appear. Meta re-prices on its own schedule, so a rate you hardcoded is a rate that will silently go stale. And utility templates sent inside an open 24-hour customer service window are not charged at all — meaning identical traffic can cost different amounts depending on whether the customer happened to message you first.&lt;/p&gt;
&lt;p&gt;None of that is visible from the sending API.&lt;/p&gt;
&lt;h2&gt;What the send response does not contain&lt;/h2&gt;
&lt;p&gt;Post a template message to the Cloud API and this comes back:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  "messaging_product": "whatsapp",
  "contacts": [ { "input": "9715XXXXXXXX", "wa_id": "9715XXXXXXXX" } ],
  "messages": [ { "id": "wamid.HBgMOTcx...FQIAERgSODQ4..." } ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the whole response. It acknowledges acceptance — not delivery, and certainly not billing. At this point you do not know whether the message will arrive, whether it will be charged, or which category it will be charged under. The last one is not rhetorical: Meta can reclassify a template after approving it.&lt;/p&gt;
&lt;h2&gt;Where the price actually comes from&lt;/h2&gt;
&lt;p&gt;Subscribe to the &lt;code&gt;messages&lt;/code&gt; webhook field and each outbound message produces a series of status callbacks. The &lt;code&gt;sent&lt;/code&gt; status carries billing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  "entry": [ { "changes": [ { "field": "messages", "value": {
    "statuses": [ {
      "id": "wamid.HBgMOTcx...",
      "status": "sent",
      "recipient_id": "9715XXXXXXXX",
      "pricing": {
        "billable": true,
        "pricing_model": "PMP",
        "category": "utility",
        "type": "regular"
      }
    } ]
  } } ] } ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four fields matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;billable&lt;/code&gt; — whether Meta is charging for this message at all.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pricing_model&lt;/code&gt; — &lt;code&gt;PMP&lt;/code&gt; is per-message pricing. Anything else means the account has not moved to the current model and the arithmetic here does not apply.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;category&lt;/code&gt; — &lt;strong&gt;Meta's classification, not the one you submitted.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type&lt;/code&gt; — &lt;code&gt;regular&lt;/code&gt; for a charged message, &lt;code&gt;free_customer_service&lt;/code&gt; for one that landed inside an open service window.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notice what is missing: no price, no currency, no running total. Meta tells you a message is chargeable and how it is classified. Turning that into money is your problem.&lt;/p&gt;
&lt;h2&gt;So cost is derived, not reported&lt;/h2&gt;
&lt;p&gt;Cost is &lt;code&gt;billable&lt;/code&gt; AND &lt;code&gt;category&lt;/code&gt; AND country, resolved against a rate card you own. That produces two numbers with different confidence, and collapsing them into one is the mistake to avoid:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Estimated&lt;/strong&gt; — computed at send time from the category you intended. Instant, occasionally wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actual&lt;/strong&gt; — computed when the webhook lands, from Meta's own billable flag and category. Authoritative on whether and which; still dependent on your rate card for how much.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Report both and label them. A dashboard showing a single number called "cost" is quietly wrong in the gap between sending and the webhook arriving — which, on a marketing broadcast, is exactly the window somebody is watching.&lt;/p&gt;
&lt;h3&gt;The rate card has to be effective-dated&lt;/h3&gt;
&lt;p&gt;Because Meta re-prices, a rate needs a start date and past rows must never be edited. Otherwise correcting today's price silently rewrites what last quarter cost:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Schema::create('message_rates', function (Blueprint $table) {
    $table-&amp;gt;id();
    $table-&amp;gt;string('country_code', 5);   // calling code, e.g. 971
    $table-&amp;gt;string('category', 20);      // MARKETING | UTILITY | AUTHENTICATION | SERVICE
    $table-&amp;gt;decimal('price_usd', 10, 6)-&amp;gt;nullable();
    $table-&amp;gt;decimal('price_local', 10, 6);
    $table-&amp;gt;date('effective_from');
    $table-&amp;gt;timestamps();

    $table-&amp;gt;unique(['country_code', 'category', 'effective_from']);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Resolution is the newest row whose &lt;code&gt;effective_from&lt;/code&gt; is on or before the message date:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public function rateFor(?string $country, ?string $category, ?CarbonInterface $on = null): ?float
{
    if ($country === null || $category === null) {
        return null;
    }

    $rate = MessageRate::query()
        -&amp;gt;where('country_code', $country)
        -&amp;gt;where('category', strtoupper($category))
        -&amp;gt;where('effective_from', '&amp;lt;=', ($on ?? now())-&amp;gt;toDateString())
        -&amp;gt;orderByDesc('effective_from')
        -&amp;gt;first();

    return $rate?-&amp;gt;price_local === null ? null : (float) $rate-&amp;gt;price_local;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One caution on populating it: reseller rate summaries are convenient and are not Meta's rate card. Take the numbers from your own account in Business Manager and treat any figure copied from a blog post — including this one — as provisional until you have checked it there.&lt;/p&gt;
&lt;h3&gt;Visibly unknown, never silently wrong&lt;/h3&gt;
&lt;p&gt;That method returns &lt;code&gt;null&lt;/code&gt;, not zero, when the card cannot price something. The distinction is the whole design. A billable message your rate card does not cover is a real hole in the total, and a zero hides it perfectly — you get a number that looks complete and is not.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;'missing_rate' =&amp;gt; MessageLog::where('billable', true)
    -&amp;gt;whereNull('cost_actual')
    -&amp;gt;count(),&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put that count on the dashboard beside the total. Anything above zero means the figure next to it is understated and somebody needs to add a rate row. A cost report that cannot tell you how much it does not know is not a cost report.&lt;/p&gt;
&lt;h2&gt;Recording the send&lt;/h2&gt;
&lt;p&gt;Open the ledger row when Meta accepts the message, keyed on the returned id:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$wamid = $response-&amp;gt;json('messages.0.id');

if ($wamid !== null) {
    MessageLog::firstOrCreate(['wamid' =&amp;gt; $wamid], [
        'direction'         =&amp;gt; 'outbound',
        'template_name'     =&amp;gt; $payload['template']['name'] ?? null,
        'expected_category' =&amp;gt; $expectedCategory,   // what WE think it is
        'country_code'      =&amp;gt; $country,
        'recipient_hash'    =&amp;gt; hash('sha256', $payload['to']),
        'recipient_last4'   =&amp;gt; substr($payload['to'], -4),
        'status'            =&amp;gt; 'accepted',
        'cost_estimated'    =&amp;gt; $rateCard-&amp;gt;rateFor($country, $expectedCategory),
    ]);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two decisions worth copying. A failed send has no message id and no cost, so only accepted messages get a row and there is nothing to reconcile later. And the recipient is a hash plus last four digits — enough to answer "did this customer get their code?" without a phone number existing anywhere in the reporting tables.&lt;/p&gt;
&lt;h2&gt;Reconciling from the webhook&lt;/h2&gt;
&lt;p&gt;The handler stays idempotent, keeps the lifecycle honest, and captures pricing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Meta redelivers, and a status can arrive for a message this system never
// sent — another tool holding the same token. A skeleton row keeps its cost
// data rather than discarding it.
$message = MessageLog::firstOrCreate(
    ['wamid' =&amp;gt; $status['id']],
    ['direction' =&amp;gt; 'outbound', 'status' =&amp;gt; 'accepted']
);

$updates = [];

// Statuses arrive out of order — a read can beat the sent it follows.
// Only ever move forward through the lifecycle.
$rank = ['accepted' =&amp;gt; 0, 'sent' =&amp;gt; 1, 'delivered' =&amp;gt; 2, 'read' =&amp;gt; 3];
$state = $status['status'];

if ($state !== 'failed' &amp;amp;&amp;amp; isset($rank[$state])
    &amp;amp;&amp;amp; $rank[$state] &amp;gt; ($rank[$message-&amp;gt;status] ?? 0)) {
    $updates['status'] = $state;
}

if (isset($status['pricing'])) {
    $pricing = $status['pricing'];

    $updates['billable']      = $pricing['billable'] ?? null;
    $updates['pricing_model'] = $pricing['pricing_model'] ?? null;
    $updates['pricing_type']  = $pricing['type'] ?? null;
    $updates['category']      = isset($pricing['category'])
        ? strtoupper($pricing['category'])
        : null;

    $updates['cost_actual'] = ($pricing['billable'] ?? null) === false
        ? 0
        : $rateCard-&amp;gt;rateFor($message-&amp;gt;country_code, $pricing['category'] ?? null);
}

$message-&amp;gt;update($updates);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The monotonic status check is not defensive habit. Meta delivers statuses over separate HTTP calls with independent retries, so a retried &lt;code&gt;sent&lt;/code&gt; can land after the &lt;code&gt;delivered&lt;/code&gt; it precedes. Without the rank comparison a message that reached the customer gets demoted, and the delivery rate under-reports for reasons nobody can trace.&lt;/p&gt;
&lt;h2&gt;Three things that will make your totals wrong&lt;/h2&gt;
&lt;h3&gt;Counting at send instead of at status&lt;/h3&gt;
&lt;p&gt;A failed message is never billed. Summing rows at creation time inflates the total by every failure. Sum the ledger with failures excluded and let the webhook decide what counts.&lt;/p&gt;
&lt;h3&gt;Trusting your own category&lt;/h3&gt;
&lt;p&gt;Meta can reclassify a template after approval, and utility to marketing is roughly a sixfold increase. This is why &lt;code&gt;expected_category&lt;/code&gt; and &lt;code&gt;category&lt;/code&gt; are separate columns rather than one overwriting the other — the disagreement is the signal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MessageLog::whereNotNull('category')
    -&amp;gt;whereColumn('category', '!=', 'expected_category')
    -&amp;gt;selectRaw('template_name, category, count(*) as messages')
    -&amp;gt;groupBy('template_name', 'category')
    -&amp;gt;get();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Meta also fires a &lt;code&gt;message_template_category_update&lt;/code&gt; webhook when it does this. Subscribe and alert — it is the only notice you get, and the alternative is finding out from a bill.&lt;/p&gt;
&lt;h3&gt;Assuming the free window is rare&lt;/h3&gt;
&lt;p&gt;Utility messages sent inside an open 24-hour service window cost nothing, and the webhook reports it as &lt;code&gt;type: free_customer_service&lt;/code&gt;. Treat those as a first-class outcome rather than an anomaly, or a month where costs fell because customers happened to message first will look like a reporting bug.&lt;/p&gt;
&lt;h2&gt;What the ledger then makes possible&lt;/h2&gt;
&lt;p&gt;Cost data is not the goal. It is the precondition for two controls that cannot exist without it.&lt;/p&gt;
&lt;p&gt;The first is a spend cap that knows the difference between kinds of traffic. A budget that blocks every send at its limit will eventually block a login, which is why marketing deserves a hard stop and authentication never does — a campaign that pauses is the feature working, an undelivered one-time code is a locked-out customer.&lt;/p&gt;
&lt;p&gt;The second is fraud. An unauthenticated endpoint that sends a message costs money per request, which makes it a payout mechanism for anyone with premium-rate numbers to point it at. Rate limiting alone does not close that, and the ceiling turns out to be a country allowlist that fails closed.&lt;/p&gt;
&lt;p&gt;Both get their own posts. Both are only enforceable once you know what a message costs.&lt;/p&gt;
&lt;h2&gt;What it looks like when it works&lt;/h2&gt;
&lt;p&gt;One template message, watched end to end on a live account:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Moment&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Estimated&lt;/th&gt;
&lt;th&gt;Actual&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Send accepted&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;0.057658&lt;/td&gt;
&lt;td&gt;pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sent webhook (t+0s)&lt;/td&gt;
&lt;td&gt;billable, PMP, regular, UTILITY&lt;/td&gt;
&lt;td&gt;0.057658&lt;/td&gt;
&lt;td&gt;0.057658&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;delivered webhook (t+2s)&lt;/td&gt;
&lt;td&gt;delivered&lt;/td&gt;
&lt;td&gt;0.057658&lt;/td&gt;
&lt;td&gt;0.057658&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Two seconds from send to delivered, pricing confirmed on the first status. The estimate matched because the category we intended was the one Meta charged — the outcome you want, and precisely the thing you cannot assume without recording both.&lt;/p&gt;
&lt;h2&gt;Check your own account&lt;/h2&gt;
&lt;p&gt;Two questions, both answerable in a minute.&lt;/p&gt;
&lt;p&gt;Are you receiving status webhooks at all? Send one message and look for a row with a non-null &lt;code&gt;billable&lt;/code&gt;. If it is still null after a minute, your callback URL is not receiving them and every cost figure you hold is an estimate wearing a confident label.&lt;/p&gt;
&lt;p&gt;Is your billing model current? Any &lt;code&gt;pricing_model&lt;/code&gt; other than &lt;code&gt;PMP&lt;/code&gt; means the account is on an older model.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php artisan tinker --execute='
$m = MessageLog::latest()-&amp;gt;first();
echo ($m-&amp;gt;billable === null
    ? "NO PRICING WEBHOOK RECEIVED"
    : "model={$m-&amp;gt;pricing_model} type={$m-&amp;gt;pricing_type}").PHP_EOL;
'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And if these messages are triggered by an unauthenticated endpoint — a one-time code, a password reset — the rate limit in front of it is not a performance control, it is the spend cap. Worth confirming it admits the number it claims to, because &lt;a href="/en/laravel-unnamed-throttle-shared-bucket"&gt;in Laravel it frequently does not&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The ledger, the effective-dated rate card and the fail-closed country allowlist described here are packaged as &lt;a href="https://github.com/dineshstack/laravel-whatsapp-cost-control" rel="noopener noreferrer"&gt;laravel-whatsapp-cost-control&lt;/a&gt;, MIT licensed, for Laravel 12 and 13. Sending is already well served by existing packages; this one covers the part that decides what the sending costs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/whatsapp-per-message-cost-tracking-webhook?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Manual Capture in Production: Holds, Buffers, Split Payments, and the Seven-Day Clock</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Mon, 10 Aug 2026 19:10:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/manual-capture-in-production-holds-buffers-split-payments-and-the-seven-day-clock-51o5</link>
      <guid>https://dev.to/dineshstack/manual-capture-in-production-holds-buffers-split-payments-and-the-seven-day-clock-51o5</guid>
      <description>&lt;p&gt;Manual capture means the number you authorize and the number you capture are different numbers, and the gap between them is where production bugs live. The rules that matter: you may capture &lt;strong&gt;less&lt;/strong&gt; than you authorized (the remainder releases automatically), you may never capture &lt;strong&gt;more&lt;/strong&gt;, the hold dies on its own after about seven days, and a card portion that a split payment pushes below Stripe's per-currency minimum fails with &lt;code&gt;amount_too_small&lt;/code&gt;. Everything in this post is a consequence of those four facts, learned on a platform where the final amount almost never matches the estimate.&lt;/p&gt;
&lt;p&gt;This is Part 4 of the Stripe in Production series — the flow is the one from &lt;a href="https://dineshstack.com/en/stripe-integration-mistakes-laravel" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;: authorize at checkout, capture on fulfilment, void on cancellation. &lt;a href="https://dineshstack.com/en/stripe-authorize-cancel-race-stale-client-secret" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; covered what happens when cancellation races the authorization; this part assumes the order survived.&lt;/p&gt;
&lt;h2&gt;Authorize the estimate plus a buffer — on the card only&lt;/h2&gt;
&lt;p&gt;If you authorize exactly the estimate, every order whose final total runs slightly over diverts into a second payment step — the exact friction manual capture exists to avoid. So authorize with headroom:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// GOOD: buffer absorbs ordinary variance; only the excess needs a second payment
public static function withBuffer(float $cardPortion, ?float $bufferPercent): float
{
    // Negative or null =&amp;gt; no buffer. Never authorize LESS than the
    // estimate — that guarantees a second payment on every order.
    $percent = max(0.0, (float) ($bufferPercent ?? 0));

    return round($cardPortion * (1 + $percent / 100), 2);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The subtlety that cost us a support queue: apply the buffer to the &lt;strong&gt;card portion only&lt;/strong&gt;, never to a store-credit lock. A card hold with headroom inconveniences nobody; a credit lock with headroom blocks the customer whose balance exactly covers their order — the system tells them they cannot afford a thing they can afford.&lt;/p&gt;
&lt;h2&gt;The split that dips under the minimum&lt;/h2&gt;
&lt;p&gt;Store credit plus card is a normal split until the credit side eats almost everything and leaves the card 0.30 USD — below the floor from &lt;a href="https://dineshstack.com/en/stripe-integration-mistakes-laravel" rel="noopener noreferrer"&gt;Part 1's Mistake #5&lt;/a&gt;. Handle it at split time, not at API-error time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// GOOD: rebalance the split before Stripe ever sees it
$minimum = config('payment.stripe.minimums.'.strtoupper($currency)); // e.g. 0.50 USD

if ($cardPortion &amp;gt; 0 &amp;amp;&amp;amp; $cardPortion &amp;lt; $minimum) {
    $shortfall = round($minimum - $cardPortion, 2);

    if ($creditPortion &amp;gt;= $shortfall) {
        // Lift the card to the floor by giving credit back to the customer
        $creditPortion = round($creditPortion - $shortfall, 2);
        $cardPortion   = $minimum;
    } else {
        throw new PaymentProviderException('stripe', 'Split below card minimum', [],
            null, customerMessage: "The minimum card payment is {$minimum} {$currency}.");
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rebalancing in the customer's favour (they keep more credit for later, the card carries the floor) turns an error path into a silent adjustment. The error branch survives only for the customer with almost no credit at all.&lt;/p&gt;
&lt;h2&gt;Capture day: three outcomes, three different calls&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// GOOD: fulfilment settles the hold — final vs authorized decides the call
$creditUsed  = min($hold-&amp;gt;credit_amount, $finalTotal);
$cardPortion = max(0.0, $finalTotal - $creditUsed);

if ($cardPortion &amp;lt;= 0) {
    // Credit covered everything: capturing zero is an error — VOID instead,
    // releasing the customer's card hold immediately.
    $stripe-&amp;gt;paymentIntents-&amp;gt;cancel($hold-&amp;gt;capture_reference);
} elseif ($cardPortion &amp;lt;= $hold-&amp;gt;authorized_amount) {
    // The normal case, including partial capture — remainder auto-releases.
    $stripe-&amp;gt;paymentIntents-&amp;gt;capture($hold-&amp;gt;capture_reference, [
        'amount_to_capture' =&amp;gt; $this-&amp;gt;toMinorUnits($cardPortion, $currency),
    ], ['idempotency_key' =&amp;gt; "capture_{$order-&amp;gt;id}"]);
} else {
    // Final exceeded even the buffer: capture what the hold allows,
    // move the order to balance-due for the excess. Stripe will not
    // stretch a hold — do not retry the capture with a bigger number.
    $stripe-&amp;gt;paymentIntents-&amp;gt;capture($hold-&amp;gt;capture_reference, [
        'amount_to_capture' =&amp;gt; $this-&amp;gt;toMinorUnits($hold-&amp;gt;authorized_amount, $currency),
    ], ['idempotency_key' =&amp;gt; "capture_{$order-&amp;gt;id}"]);
    $order-&amp;gt;transitionTo(OrderStatus::BalanceDue, $finalTotal - $hold-&amp;gt;authorized_amount);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three notes. The idempotency key means a retried fulfilment job cannot double-capture. The zero-card branch voids rather than captures — a zero capture is an API error, and the void is also the kind gesture, releasing the hold now instead of in seven days. And the balance-due branch is where the minimum guard matters again: the excess is a delta, exactly the kind of small amount that dips under the floor.&lt;/p&gt;
&lt;h2&gt;The seven-day clock&lt;/h2&gt;
&lt;p&gt;An uncaptured authorization expires on its own after about seven days, and expiry is silent — the capture just fails when you finally call it. Two design consequences: if your fulfilment window can exceed the auth window, you need a re-authorization step in the flow, not a longer hope; and the expiry is also your last-resort cleanup, which is why &lt;a href="https://dineshstack.com/en/stripe-integration-mistakes-laravel" rel="noopener noreferrer"&gt;Part 1's reconciliation&lt;/a&gt; hunts for holds older than an hour — anything you find at day six was a bug for six days.&lt;/p&gt;
&lt;h2&gt;A trick the hold enables: the micro-authorization&lt;/h2&gt;
&lt;p&gt;Manual capture gives you a free card validity check: authorize the per-currency minimum, then void it immediately. No charge, no capture, a held-and-released minimum — and you learn the card is real and funded before the order commits to it. Tag these distinctly in metadata so your webhook handlers skip them (they have no order behind them), and remember the floor: the probe is the minimum, not a symbolic 0.01.&lt;/p&gt;
&lt;h2&gt;The checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Authorize estimate + buffer; buffer the card portion only, never store credit&lt;/li&gt;
&lt;li&gt;Rebalance splits before the API call: lift the card to the minimum from the credit side&lt;/li&gt;
&lt;li&gt;Capture ≤ authorized; excess goes to a balance-due payment, never a bigger capture&lt;/li&gt;
&lt;li&gt;Zero card portion =&amp;gt; void, not a zero capture&lt;/li&gt;
&lt;li&gt;Idempotency key on every capture&lt;/li&gt;
&lt;li&gt;Fulfilment longer than the auth window =&amp;gt; re-authorize by design&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Next in this series&lt;/h2&gt;
&lt;p&gt;Part 5 is the layer under all of this: &lt;strong&gt;payment webhooks that don't lie&lt;/strong&gt; — replay guards, why you mark a webhook processed only after success, and the difference between delivered and true. [LINK WHEN LIVE: /en/payment-webhook-replay-reconciliation]&lt;/p&gt;
&lt;p&gt;Cheap audit before then: find your capture call and check what happens when the final amount exceeds the authorization. If the answer is "we capture the final amount", that call has been failing on every over-estimate order and something downstream is eating the error.&lt;/p&gt;
&lt;p&gt;I post each part natively on &lt;a href="https://www.linkedin.com/in/dinesh-wijethunga/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; with the story that didn't fit — follow there for Part 5. I read every reply.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The Authorize/Cancel Race: When a Customer Pays for an Order That No Longer Exists</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Mon, 10 Aug 2026 16:32:10 +0000</pubDate>
      <link>https://dev.to/dineshstack/the-authorizecancel-race-when-a-customer-pays-for-an-order-that-no-longer-exists-3ncn</link>
      <guid>https://dev.to/dineshstack/the-authorizecancel-race-when-a-customer-pays-for-an-order-that-no-longer-exists-3ncn</guid>
      <description>&lt;p&gt;Cancelling an order in your database does nothing at Stripe. The client_secret you shipped to the payment sheet is a live capability — it keeps accepting confirmation after your order row says cancelled, and the authorization webhook will happily record money against an order that no longer exists. The race runs in both directions: the customer can pay after you cancel, and you can cancel after the customer pays, with the second gap measured at two seconds in our production logs. The fix is symmetrical: the cancel path must kill the intent at Stripe, and the confirm path must check the parent order before accepting the money.&lt;/p&gt;
&lt;p&gt;This is Part 3 of the Stripe in Production series. It assumes the manual-capture flow from &lt;a href="https://dineshstack.com/en/stripe-integration-mistakes-laravel" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; — authorize at checkout, capture on fulfilment, void on cancellation.&lt;/p&gt;
&lt;h2&gt;Two races, one root&lt;/h2&gt;
&lt;p&gt;Both incidents that taught us this were mundane. No load, no outage — just ordinary latency between a phone and two servers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RACE A — cancel, then pay (the stale sheet)
T+0s    customer opens payment sheet, closes the app
T+15m   sweeper expires the session, cancels the order
T+16m   customer reopens the app; the sheet is still on screen
        client_secret still valid at Stripe =&amp;gt; hold authorized
        =&amp;gt; money locked against an order that died a minute ago

RACE B — pay, then cancel (two seconds)
T+0.0s  customer confirms in the sheet
T+1.5s  authorization webhook lands; hold recorded
T+3.5s  order auto-cancels (no availability)
        =&amp;gt; release path runs immediately — into whatever
           state the webhook just wrote
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One root: &lt;strong&gt;two systems each believed their own state was the whole truth.&lt;/strong&gt; Your database is not Stripe's state, and Stripe is not yours. Every fix below is one side refusing to trust the other blindly.&lt;/p&gt;
&lt;h2&gt;A client_secret is a capability, not a record&lt;/h2&gt;
&lt;p&gt;Treat every client_secret you ship as an outstanding key to your customer's card. It lives on the customer's device, outside your transaction boundaries, and it does not check your order table before working. Deleting the order, cancelling it, even deleting the session row — none of it revokes the key. Only two things do: explicit cancellation of the PaymentIntent, or its natural expiry. Once you see it that way, the rule writes itself: whoever invalidates the order must also revoke the key.&lt;/p&gt;
&lt;h2&gt;Fix the cancel side: kill the intent when the session dies&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// BAD: local-only decline — the client_secret out there still works
public function decline(string $provider, string $orderRef): void
{
    $session = CheckoutSession::where('provider', $provider)
        -&amp;gt;where('merchant_order_id', $orderRef)-&amp;gt;first();

    $session?-&amp;gt;update(['status' =&amp;gt; CheckoutSessionStatus::Declined]);
    // ... cancel order, release hold — Stripe never hears about any of it
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;// GOOD: revoke the key at Stripe FIRST, then record the decline
public function decline(string $provider, string $orderRef, string $reason): void
{
    $session = CheckoutSession::where('provider', $provider)
        -&amp;gt;where('merchant_order_id', $orderRef)-&amp;gt;first();

    if (! $session || $session-&amp;gt;status === CheckoutSessionStatus::Declined) {
        return; // idempotent — sweeper and webhook may both call this
    }

    // HTTP deliberately OUTSIDE the transaction below, and best-effort:
    // an unreachable Stripe must never block the order cancellation.
    // Ordered FIRST so a failure that rolls back the transaction leaves
    // the session pending — and the next sweep retries the cancel.
    // After the commit, the session is Declined and never revisited.
    if ($provider === 'stripe' &amp;amp;&amp;amp; str_starts_with((string) $session-&amp;gt;provider_payment_id, 'pi_')) {
        try {
            $this-&amp;gt;stripe-&amp;gt;paymentIntents-&amp;gt;cancel($session-&amp;gt;provider_payment_id);
        } catch (\Throwable $e) {
            Log::warning('Could not cancel intent on decline', [
                'intent' =&amp;gt; $session-&amp;gt;provider_payment_id,
                'error'  =&amp;gt; $e-&amp;gt;getMessage(),
            ]);
        }
    }

    DB::transaction(function () use ($session, $reason) {
        $session-&amp;gt;update(['status' =&amp;gt; CheckoutSessionStatus::Declined]);
        $this-&amp;gt;holds-&amp;gt;release($session-&amp;gt;hold);
        $this-&amp;gt;orders-&amp;gt;cancel($session-&amp;gt;order, $reason);
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ordering carries the retry semantics, so it is not style — cancel-at-Stripe first means a crashed decline self-heals on the next sweep, while the reverse order can mark the session dead with the key still live. After this shipped, a reopened stale sheet fails at confirmation instead of taking the customer's money. That failure is the feature.&lt;/p&gt;
&lt;h2&gt;Fix the confirm side: check the parent before accepting money&lt;/h2&gt;
&lt;p&gt;Race B needs the mirror-image guard. Your authorization webhook handler was written imagining a live order — but it can fire after cancellation, and recording a hold nobody will ever settle creates the exact stranded-money problem from &lt;a href="https://dineshstack.com/en/stripe-integration-mistakes-laravel" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// GOOD: the webhook confirms into the world as it is, not as it was
public function confirm(string $provider, string $orderRef): void
{
    $session = CheckoutSession::where('provider', $provider)
        -&amp;gt;where('merchant_order_id', $orderRef)-&amp;gt;firstOrFail();

    if ($session-&amp;gt;status === CheckoutSessionStatus::Authorized) {
        return; // idempotent — webhooks redeliver
    }

    $order = $session-&amp;gt;order;

    // The order died while the customer was typing card details.
    // Accepting the authorization would strand the hold — refuse it,
    // and release the money we were just handed.
    if ($order-&amp;gt;status-&amp;gt;isTerminal()) {
        try {
            $this-&amp;gt;stripe-&amp;gt;paymentIntents-&amp;gt;cancel($session-&amp;gt;provider_payment_id);
        } catch (\Throwable $e) {
            Log::critical('Late authorization on dead order — cancel failed', [
                'intent' =&amp;gt; $session-&amp;gt;provider_payment_id,
                'order'  =&amp;gt; $order-&amp;gt;id,
            ]);
        }
        $session-&amp;gt;update(['status' =&amp;gt; CheckoutSessionStatus::Declined]);
        return;
    }

    // Normal path: record the authorization, advance the order.
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the log level on the failure branch: a late authorization on a dead order that you also failed to cancel is precisely the case your reconciliation must page a human about.&lt;/p&gt;
&lt;h2&gt;Why manual capture saved us&lt;/h2&gt;
&lt;p&gt;Both races happened with &lt;code&gt;capture_method: manual&lt;/code&gt;, so the exposure was a temporary hold — released in days even if everything else failed — rather than a captured charge needing an apologetic refund. That is worth stating as a design principle: &lt;strong&gt;for any order that can be cancelled after payment begins, authorize-then-capture bounds your worst case.&lt;/strong&gt; Automatic capture in the same races takes real money and turns an engineering bug into a support incident. Still, "it expires in a week" is not a fix; a customer watching held funds for days does not care about your capture method.&lt;/p&gt;
&lt;h2&gt;The backstop you already have&lt;/h2&gt;
&lt;p&gt;Every guard above can fail — Stripe unreachable on both attempts, a crash between webhook and handler. This is what the hourly &lt;code&gt;requires_capture&lt;/code&gt; reconciliation from &lt;a href="https://dineshstack.com/en/stripe-integration-mistakes-laravel" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; is for: any hold older than an hour whose local record claims finished is a bug being found the same day instead of at auth expiry. The races make it necessary; the reconciliation makes them survivable.&lt;/p&gt;
&lt;h2&gt;The checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Treat every shipped client_secret as a live capability until explicitly cancelled&lt;/li&gt;
&lt;li&gt;Cancel the PaymentIntent at Stripe when a session dies — before recording the decline, best-effort, retried by the sweep&lt;/li&gt;
&lt;li&gt;Make decline and confirm both idempotent; sweeper and webhooks will overlap&lt;/li&gt;
&lt;li&gt;In the authorization handler, check the parent order's state; void immediately on terminal orders&lt;/li&gt;
&lt;li&gt;Use manual capture wherever cancellation can race payment&lt;/li&gt;
&lt;li&gt;Keep the hourly reconciliation running — it catches what the guards miss&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Next in this series&lt;/h2&gt;
&lt;p&gt;Part 4 goes deeper into the hold itself: &lt;strong&gt;manual capture in production&lt;/strong&gt; — authorization buffers over estimates, split wallet-and-card payments where the card portion dips under Stripe's minimum, and the seven-day expiry as a deadline you design against. [LINK WHEN LIVE: /en/stripe-manual-capture-holds-split-payments]&lt;/p&gt;
&lt;p&gt;Before then, one cheap audit: grep your cancellation paths for a Stripe cancel call. If cancelling an order only touches your own tables, every client_secret you have ever shipped is still out there, and Race A is not a possibility — it is a schedule.&lt;/p&gt;
&lt;p&gt;I post each part natively on &lt;a href="https://www.linkedin.com/in/YOUR-PROFILE" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; with the war story that didn't fit — follow there for Part 4, or tell me about the race you lost. I read all of them. Part 2, on the wallet gates that block payment sheet buttons, is &lt;a href="https://dineshstack.com/en/apple-pay-google-pay-payment-sheet-gates" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
