<?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: Mahmut Gündüzalp</title>
    <description>The latest articles on DEV Community by Mahmut Gündüzalp (@mahmut_gndzalp_c736ac4b).</description>
    <link>https://dev.to/mahmut_gndzalp_c736ac4b</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%2F2031144%2F507c6d99-33de-4ad3-b203-16e7eba3fc01.png</url>
      <title>DEV Community: Mahmut Gündüzalp</title>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mahmut_gndzalp_c736ac4b"/>
    <language>en</language>
    <item>
      <title>The Socket Was Open. The Site Was Dead for Fourteen Hours.</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Mon, 07 Sep 2026 06:59:17 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/the-socket-was-open-the-site-was-dead-for-fourteen-hours-aie</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/the-socket-was-open-the-site-was-dead-for-fourteen-hours-aie</guid>
      <description>&lt;p&gt;A site had been down since 19:54 the previous evening. Nobody reported it. The monitoring did not fire. When I finally looked, the cause was one process that had outlived its parent and was holding a socket nobody could use.&lt;/p&gt;

&lt;p&gt;Every individual check passed. DNS resolved. Port 443 accepted connections. The TLS handshake completed against a valid certificate with two months left on it. The web server was running and serving other sites on the same machine. And every request to that one site returned 503.&lt;/p&gt;

&lt;p&gt;The failure lived in the gap between "the process exists" and "the process works," which is a gap most health checks do not cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the checks reported
&lt;/h2&gt;

&lt;p&gt;I started from the outside, because the outside is cheap to measure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;resolves to the expected address&lt;/td&gt;
&lt;td&gt;fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;ports 80 and 443 accept&lt;/td&gt;
&lt;td&gt;fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;handshake, chain, expiry&lt;/td&gt;
&lt;td&gt;fine, 68 days left&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP (port 80)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;302&lt;/strong&gt; to the HTTPS URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;first a 20-second hang, then &lt;strong&gt;503&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web server&lt;/td&gt;
&lt;td&gt;running, other sites on the box served fine&lt;/td&gt;
&lt;td&gt;fine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those rows are the reason nobody noticed.&lt;/p&gt;

&lt;p&gt;The port-80 row returns 302 because the HTTP-to-HTTPS redirect is a server-level rewrite. It never reaches PHP. A monitor watching &lt;code&gt;http://&lt;/code&gt; sees a chain that starts with a perfectly healthy 302, and if it treats a redirect as up, it reports up. Forever. The application behind it can be completely dead.&lt;/p&gt;

&lt;p&gt;The TLS row is worse, because it looks like the most meaningful check in the list. A successful handshake proves the listener is alive and the certificate is valid. It proves nothing whatsoever about whether anything can generate a response body.&lt;/p&gt;

&lt;p&gt;The first HTTPS request I made hung for twenty seconds and timed out. The second returned 503 in 143 milliseconds. That inconsistency is itself a clue: the server was trying to hand the request to a backend, waiting, giving up, and eventually not bothering to wait at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log line that mattered
&lt;/h2&gt;

&lt;p&gt;The site's own error log was useless. Its last entry was seventeen days old, because the log level was &lt;code&gt;WARN&lt;/code&gt; and this condition never produced a warning at the virtual-host level.&lt;/p&gt;

&lt;p&gt;The server-wide log had it, at &lt;code&gt;INFO&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[INFO] [...#site] Connection idle time too long: 301 while in state: 6 ...
[INFO] [...#site] HttpExtConnector state: 0, request body sent: 0,
                  response body size: -2, response body sent: 0, attempts: 0.
[INFO] [...#site] External processor is not available.
[NOTICE] [...#site] oops! 503 Service Unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;External processor is not available&lt;/code&gt; is the whole diagnosis in five words. The web server had a request, knew which backend should handle it, and could not get a usable connection to that backend. In LSAPI terms the external processor is the PHP process pool. In PHP-FPM terms it is the pool behind the socket you point &lt;code&gt;fastcgi_pass&lt;/code&gt; at. Same shape, same failure mode.&lt;/p&gt;

&lt;p&gt;Note &lt;code&gt;attempts: 0&lt;/code&gt; and &lt;code&gt;response body size: -2&lt;/code&gt;. It did not fail partway through a response. It never got one started.&lt;/p&gt;

&lt;h2&gt;
  
  
  PPID 1
&lt;/h2&gt;

&lt;p&gt;The pool for this site is configured for ten children and ten concurrent connections. I expected either zero processes — pool dead, should have respawned — or ten busy ones.&lt;/p&gt;

&lt;p&gt;There was exactly one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  PID   PPID STAT     ELAPSED     TIME %CPU   RSS CMD
793380      1 SN      13:52:58 00:07:31  0.9  7036 lsphp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that line carefully, because it contains the entire bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PPID 1&lt;/code&gt;.&lt;/strong&gt; The parent is gone. This process was adopted by init. A pool child whose master has died is an orphan, and an orphan is supervised by nothing. Nothing will restart it, nothing will kill it, nothing will notice it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ELAPSED 13:52:58&lt;/code&gt; against &lt;code&gt;TIME 00:07:31&lt;/code&gt;.&lt;/strong&gt; Fourteen hours of wall clock, seven and a half minutes of CPU. It was not spinning. No load-average alert was ever going to catch this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;STAT SN&lt;/code&gt;&lt;/strong&gt; — interruptible sleep, low priority. The kernel confirms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;State /proc/793380/status
&lt;span class="go"&gt;State:  S (sleeping)

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/793380/wchan
&lt;span class="go"&gt;hrtimer_nanosleep
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hrtimer_nanosleep&lt;/code&gt; means the process is inside a timed sleep. Some PHP path called &lt;code&gt;sleep()&lt;/code&gt; and the value it was handed put the process to bed for what may as well be forever. An API retry backoff, a lock-wait loop, a scraper delay — the specific call matters less than the shape: a wait nobody bounded.&lt;/p&gt;

&lt;p&gt;Its open file descriptors closed the case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/793380/fd
&lt;span class="gp"&gt;0 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;socket:[598208598]
&lt;span class="gp"&gt;1 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/usr/local/.../stderr.log &lt;span class="o"&gt;(&lt;/span&gt;deleted&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;2 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/usr/local/.../stderr.log &lt;span class="o"&gt;(&lt;/span&gt;deleted&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;3 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;socket:[598208598]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two descriptors on the listening socket. And stdout and stderr pointing at a log file marked &lt;strong&gt;(deleted)&lt;/strong&gt; — something removed that file while the process held it open, so every byte it wrote for fourteen hours went to an inode with no name. If it printed a fatal error, a stack trace, a reason, that is gone. Never let cleanup delete a log a live process still holds open; rotate it and signal the writer instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing restarted it
&lt;/h2&gt;

&lt;p&gt;Here is the part that generalizes past any one server.&lt;/p&gt;

&lt;p&gt;A process supervisor decides whether to start a pool by asking two questions: does the socket file exist, and is the PID in the pid file alive?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;srwxr-xr-x 1 ... /tmp/.../siteuser.sock
-rw-r--r-- 1 ... /tmp/.../siteuser.sock.pid
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both answers were yes. The socket file was on disk with the right permissions. The pid file named a process that was alive — sleeping, low priority, adopted by init, but very much running.&lt;/p&gt;

&lt;p&gt;So the supervisor concluded the pool was up and never spawned a replacement. Meanwhile the orphan, asleep inside a timer, accepted nothing. Every incoming connection landed on a socket with no reader, waited out the connect timeout, and became a 503.&lt;/p&gt;

&lt;p&gt;The pool was neither alive nor dead. It was &lt;strong&gt;occupying the identity of a working pool&lt;/strong&gt; — the socket path and the pid — without doing any of the work. A liveness check that asks "is the PID alive" cannot see this, and neither can anything watching for a crash, because nothing crashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourteen hours
&lt;/h2&gt;

&lt;p&gt;I went back to the access log and counted requests per hour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;19:00  306
20:00   19
21:00    9
22:00   13
23:00    4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the moment of death, and it matches the process start time to within a few minutes. From 20:00 onward the only requests left are bots, retries, and the handful of people who tried, got nothing, and left.&lt;/p&gt;

&lt;p&gt;Nineteen requests an hour is not zero, and that is the trap. A "traffic dropped to zero" alert never fires. A daily-total alert compares a bad day against a normal one and shrugs at a 90% drop if the threshold is 95%. The site served errors to every visitor for fourteen hours while the graph showed a line that was low but not flat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and it is boring
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-TERM&lt;/span&gt; 793380
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/.../siteuser.sock /tmp/.../siteuser.sock.pid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kill the orphan, remove the stale socket and pid file so the supervisor stops believing a pool exists, then send one request. The next request rebuilt the pool by itself. Local response: 200 in 0.171 seconds. From outside: 200 in 0.29 seconds, full page, no error signatures in the HTML. Four healthy children within seconds.&lt;/p&gt;

&lt;p&gt;Under ten seconds of repair after fourteen hours of downtime. That ratio is the actual story. The failure was trivial to fix and completely invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Monitor the URL users actually load, and follow redirects to the end.&lt;/strong&gt; A 302 from a server-level rewrite is not evidence that your application runs. If your monitor reports on the first response in the chain, it is monitoring your rewrite rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on the body, not the status code.&lt;/strong&gt; Match a string that only appears when the application really rendered — a title, a footer, a token in the markup. Byte length works too: a 503 page is a few hundred bytes, a real page is tens of kilobytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert on relative traffic collapse, not on zero.&lt;/strong&gt; A drop from 306 to 19 requests in one hour is a far better signal than any absolute floor, and it fires within the hour instead of the next morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat long-lived pool children as suspect.&lt;/strong&gt; Pool workers are meant to be recycled. Any child running for hours with almost no CPU deserves a look. &lt;code&gt;ps -eo pid,ppid,etime,time,cmd&lt;/code&gt; plus &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/wchan&lt;/code&gt; costs nothing in a cron job, and a worker with &lt;code&gt;PPID 1&lt;/code&gt; is an orphan by definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap every sleep and every outbound call.&lt;/strong&gt; The root cause under all of this is an unbounded wait. Timeouts on HTTP clients, bounded retry backoffs, lock waits that eventually give up. A worker allowed to sleep forever will eventually do exactly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never delete a log file a process still holds open.&lt;/strong&gt; Rotate and signal. Otherwise the one artifact that would have told you why is writing into an unlinked inode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;DNS, TCP, TLS and the certificate can all be perfect while the site returns 503 to every visitor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;External processor is not available&lt;/code&gt; means the web server could not get a working connection to the PHP pool. It is a backend problem, not a network or certificate problem.&lt;/li&gt;
&lt;li&gt;A pool child whose parent has died gets &lt;code&gt;PPID 1&lt;/code&gt; and is supervised by nothing.&lt;/li&gt;
&lt;li&gt;If that orphan keeps the socket and pid file, the supervisor sees a live pool and refuses to spawn a real one. Neither alive nor dead, just occupying the identity.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/proc/&amp;lt;pid&amp;gt;/wchan&lt;/code&gt; naming a sleep function, with hours of elapsed time and seconds of CPU, is the confirmation.&lt;/li&gt;
&lt;li&gt;Health checks that follow redirects and assert on body content would have caught this in the first minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site was down for fourteen hours. The fix was one signal and two deleted files. Everything expensive about this outage happened in the gap between a process existing and a process working.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written from production work at Alesta WEB.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>linux</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Lowercasing "İ" Adds a Character: How Turkish Text Broke a Keyword Router</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:41:21 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/lowercasing-i-adds-a-character-how-turkish-text-broke-a-keyword-router-3com</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/lowercasing-i-adds-a-character-how-turkish-text-broke-a-keyword-router-3com</guid>
      <description>&lt;p&gt;A classifier was sending questions to the wrong bucket. Not occasionally. I measured it against twenty real inputs and seven of them landed somewhere they did not belong. Thirty-five percent.&lt;/p&gt;

&lt;p&gt;The routing code was thirty lines long and did the obvious thing: lowercase the incoming text, then check whether any keyword for a category appears in it. First match wins.&lt;/p&gt;

&lt;p&gt;Every one of those thirty lines was wrong in a way that only shows up in Turkish, and two of the three problems will bite you in any language whose case mappings are not one-to-one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Users type free-form questions. The router picks a topic so that the right prompt and the right reference data get loaded. When it picks wrong, the answer is confidently about the wrong subject, which is worse than no answer.&lt;/p&gt;

&lt;p&gt;The reported complaint was "the answers feel shallow." The instinct is to blame the model. The model was fine. It was being handed the wrong context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: lowercasing a dotted capital I produces two code points
&lt;/h2&gt;

&lt;p&gt;Here is the whole thing, measured rather than remembered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"İş"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// "work", capital İ&lt;/span&gt;
&lt;span class="nv"&gt;$l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"UTF-8"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// original      : U+0130 U+015F&lt;/span&gt;
&lt;span class="c1"&gt;// mb_strtolower : U+0069 U+0307 U+015F&lt;/span&gt;
&lt;span class="c1"&gt;// mb_strlen     : 2 -&amp;gt; 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE&lt;/code&gt; has no single-code-point lowercase form in the default Unicode case mapping. It maps to &lt;code&gt;U+0069 LATIN SMALL LETTER I&lt;/code&gt; followed by &lt;code&gt;U+0307 COMBINING DOT ABOVE&lt;/code&gt;. The string got longer. It still renders as "i̇ş" and looks entirely normal in your terminal, your editor, and your database client.&lt;/p&gt;

&lt;p&gt;So this happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;mb_strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"İşten çıkarılır mıyım"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"UTF-8"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"iş"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"İSTANBUL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"UTF-8"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"istanbul"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your keyword is two code points. The haystack has three. No match, no error, no warning. The comparison returns false and the request goes to whatever category happens to be checked next.&lt;/p&gt;

&lt;p&gt;The reason this is not an edge case: people capitalize the first word of a sentence. In Turkish a large number of everyday words begin with İ — the words for work, request, relationship, reputation, tender, transaction, need, cancellation. Any of them starting a sentence produces the three-code-point form. &lt;strong&gt;The broken form is the common form&lt;/strong&gt;, and the version your test uses — all lowercase, typed by you — is the rare one.&lt;/p&gt;

&lt;p&gt;That is why this survives a green test suite. You wrote &lt;code&gt;"işten çıkarılır mıyım"&lt;/code&gt; in the fixture, in lowercase, by hand. It matches. The user wrote &lt;code&gt;"İşten çıkarılır mıyım"&lt;/code&gt;. It does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unicode normalization will not save you
&lt;/h2&gt;

&lt;p&gt;The reflex is to reach for &lt;code&gt;Normalizer::normalize()&lt;/code&gt; and move on. I checked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lower    : U+0069 U+0307 U+015F
NFC      : U+0069 U+0307 U+015F
NFKC     : U+0069 U+0307 U+015F
casefold : U+0069 U+0307 U+015F
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NFC composes a base character with a combining mark when a precomposed code point exists. There is no precomposed "latin small letter i with dot above" — a lowercase i already has a dot, so Unicode never needed one. The sequence stays two code points under every composing form, and &lt;code&gt;casefold()&lt;/code&gt; does not touch it either.&lt;/p&gt;

&lt;p&gt;NFD is more interesting, because it goes the other way and decomposes everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NFD("i̇ş") : U+0069 U+0307 U+0073 U+0327
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the cedilla under ş is its own code point too. That gives you one rule that handles both problems at once: decompose, then drop every nonspacing mark.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NFD&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Mn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ı&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# dotless ı has no combining mark to strip
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'İş'         -&amp;gt; 'is'
'İSTANBUL'   -&amp;gt; 'istanbul'
'Değişiklik' -&amp;gt; 'degisiklik'
'İlişki'     -&amp;gt; 'iliski'
'Ölüm'       -&amp;gt; 'olum'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In PHP, where the &lt;code&gt;intl&lt;/code&gt; extension is frequently not loaded, the same thing without a normalizer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tr_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{0307}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// the leftover dot from İ&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;strtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'ı'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ş'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ğ'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ü'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ö'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ç'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'c'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'â'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'î'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'û'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most important line is the &lt;code&gt;U+0307&lt;/code&gt; removal. Delete it and the bug is back.&lt;/p&gt;

&lt;p&gt;Run your keyword list through the same function, too. Both sides of a comparison must see the same normalization, always. A keyword table normalized at author time and input normalized at request time will drift the moment somebody edits the table by pasting from a document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other direction, for readers who do not write Turkish
&lt;/h2&gt;

&lt;p&gt;Turkish has two distinct letters where English has one: dotted &lt;code&gt;İ/i&lt;/code&gt; and dotless &lt;code&gt;I/ı&lt;/code&gt;. They are different letters, not styling. So the correct Turkish lowercase of &lt;code&gt;I&lt;/code&gt; is &lt;code&gt;ı&lt;/code&gt;, not &lt;code&gt;i&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the well-known "Turkish I problem," and it usually appears as the mirror image of what I described. In Java, .NET, or any locale-sensitive &lt;code&gt;toLowerCase()&lt;/code&gt; running under a &lt;code&gt;tr-TR&lt;/code&gt; locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="s"&gt;"INFO"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;// "ınfo" under tr-TR, "info" everywhere else&lt;/span&gt;
&lt;span class="s"&gt;"FILE"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;// "fıle"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code that lowercases a protocol keyword, a header name, a file extension, or an SQL identifier and then compares it against an ASCII literal breaks on a machine whose locale happens to be Turkish. The fix there is the opposite of the one above: pass an explicit invariant locale, &lt;code&gt;toLowerCase(Locale.ROOT)&lt;/code&gt;, and never let ambient locale decide the semantics of a comparison.&lt;/p&gt;

&lt;p&gt;So there are two rules, pointing in opposite directions depending on what you are doing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Comparing &lt;strong&gt;protocol tokens and identifiers&lt;/strong&gt;: force a locale-invariant mapping. Never fold, never guess.&lt;/li&gt;
&lt;li&gt;Comparing &lt;strong&gt;human text for search or classification&lt;/strong&gt;: normalize aggressively, on both sides, with a function you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Confusing the two is how you end up with a system that is broken in one country and subtly wrong everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: unbounded substring matching
&lt;/h2&gt;

&lt;p&gt;With normalization fixed, the router still misrouted. The second bug had nothing to do with Unicode: the check was &lt;code&gt;strpos($text, $keyword) !== false&lt;/code&gt;, with no word boundary.&lt;/p&gt;

&lt;p&gt;Short keywords swallow longer words that merely contain them. Real collisions from the keyword table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keyword&lt;/th&gt;
&lt;th&gt;Means&lt;/th&gt;
&lt;th&gt;Also matches&lt;/th&gt;
&lt;th&gt;Which means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;olum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;death&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;olum&lt;/strong&gt;lu, &lt;strong&gt;olum&lt;/strong&gt;suz&lt;/td&gt;
&lt;td&gt;positive, negative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;goods, property&lt;/td&gt;
&lt;td&gt;nor*&lt;em&gt;mal&lt;/em&gt;&lt;em&gt;, **mal&lt;/em&gt;*zeme&lt;/td&gt;
&lt;td&gt;normal, material&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;work&lt;/td&gt;
&lt;td&gt;k*&lt;em&gt;is&lt;/em&gt;&lt;em&gt;i, deg&lt;/em&gt;&lt;em&gt;is&lt;/em&gt;&lt;em&gt;iklik, g&lt;/em&gt;&lt;em&gt;is&lt;/em&gt;*e&lt;/td&gt;
&lt;td&gt;person, change, counter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;love&lt;/td&gt;
&lt;td&gt;m*&lt;em&gt;ask&lt;/em&gt;*e&lt;/td&gt;
&lt;td&gt;mask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;din&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;religion&lt;/td&gt;
&lt;td&gt;ay*&lt;em&gt;din&lt;/em&gt;&lt;em&gt;, **din&lt;/em&gt;*len&lt;/td&gt;
&lt;td&gt;bright, rest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;olum&lt;/code&gt; was the expensive one. "Olumlu" — "positive" — is among the most common words in any yes/no question. Every such question was routed to the death-and-inheritance category, because that category happened to sit near the top of the map.&lt;/p&gt;

&lt;p&gt;Word boundaries in an agglutinative language are not just &lt;code&gt;\b&lt;/code&gt; around a fixed string. Turkish stacks suffixes, and stems mutate: "lost" appears as &lt;em&gt;kayıp&lt;/em&gt;, &lt;em&gt;kaybettiğim&lt;/em&gt;, &lt;em&gt;kaybolan&lt;/em&gt; — the stem itself shifts from &lt;code&gt;kayıp&lt;/code&gt; to &lt;code&gt;kayb&lt;/code&gt;. So match a stem plus a bounded run of following letters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/(?&amp;lt;!\p{L})'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;preg_quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'\p{L}{0,6}(?!\p{L})/u'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;(?&amp;lt;!\p{L})&lt;/code&gt; and &lt;code&gt;(?!\p{L})&lt;/code&gt; are Unicode-aware boundary assertions — plain &lt;code&gt;\b&lt;/code&gt; reasons about &lt;code&gt;\w&lt;/code&gt;, whose meaning depends on flags and locale. The &lt;code&gt;{0,6}&lt;/code&gt; allows suffixes without letting the stem float in the middle of an unrelated word. And keep a stem list per concept rather than one string: &lt;code&gt;kayıp|kayb|kaybol|kaybet&lt;/code&gt;, because sound changes will not occur to you at the keyboard, only in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: map order is a priority ranking you did not know you wrote
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$map&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$topic&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$words&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$topic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// first hit wins&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever order the array literal happens to be in &lt;em&gt;is&lt;/em&gt; your precedence rule. "Will my father's health improve" contains both a family keyword and a health keyword. Family was declared fourth, health sixth, so it went to the family bucket every time.&lt;/p&gt;

&lt;p&gt;Two changes fix the class of problem rather than the instance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Order specific before general, deliberately, with a comment saying so.&lt;/li&gt;
&lt;li&gt;Stop returning on first hit. Collect every match, score them, take the best. A reasonable score is the number of distinct stems matched, weighted by stem specificity — a rare six-letter stem is worth more than a three-letter one that appears everywhere.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scoring also gives you something first-match never can: a confidence number. When the top two topics score within noise of each other, you can fall back to a neutral path instead of committing to a coin flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize for matching, never for identity
&lt;/h2&gt;

&lt;p&gt;One caution, because the recipe above is lossy on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'ILIK'  -&amp;gt; 'ilik'
'ılık'  -&amp;gt; 'ilik'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different words collapse to the same key. That is exactly what you want for search and classification, and exactly what you must never do for identity.&lt;/p&gt;

&lt;p&gt;If you normalize usernames or email local parts this way before a uniqueness check or a login comparison, you have created an account-collision path: two visually and semantically distinct identifiers now compare equal. The same applies to case-insensitive uniqueness in general — the database collation, the application-level check and the login comparison have to agree on one rule, and that rule should be locale-invariant, not a fuzzy fold.&lt;/p&gt;

&lt;p&gt;Fold for relevance. Compare exactly for identity. Keep the two functions in separate files so nobody reuses one for the other by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify, because you cannot eyeball this
&lt;/h2&gt;

&lt;p&gt;Every symptom in this article is invisible on screen. The three-code-point string looks identical to the two-code-point one. You cannot review your way to correctness here; you have to measure.&lt;/p&gt;

&lt;p&gt;What I did, and would do again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write a table of 20–30 real inputs with the expected category&lt;/strong&gt;, taken from actual traffic rather than invented. Run it, print the wrong-rate as a number. "Seven of twenty" is a fact you can act on; "seems better now" is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweep for collisions mechanically.&lt;/strong&gt; For every keyword shorter than about five characters, generate common words containing it and feed them through the classifier. That is how &lt;code&gt;olum&lt;/code&gt; inside &lt;code&gt;olumlu&lt;/code&gt; surfaced — nobody was going to catch it by reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print code points, not strings&lt;/strong&gt;, whenever a comparison fails for no visible reason. &lt;code&gt;printf("U+%04X ")&lt;/code&gt; over the characters takes ten seconds and ends the argument.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-run the same table after the fix&lt;/strong&gt; and report the new number. A fix that is not re-measured is a hypothesis.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mb_strtolower('İ')&lt;/code&gt; returns &lt;code&gt;i&lt;/code&gt; + &lt;code&gt;U+0307&lt;/code&gt;. The string gets longer, looks the same, and stops matching.&lt;/li&gt;
&lt;li&gt;NFC and casefold do not collapse it. NFD plus stripping nonspacing marks does.&lt;/li&gt;
&lt;li&gt;Normalize the input and the keyword list with the same function.&lt;/li&gt;
&lt;li&gt;Match stems with Unicode-aware boundaries, not bare substrings.&lt;/li&gt;
&lt;li&gt;Map order is silent precedence. Score instead of returning on first hit.&lt;/li&gt;
&lt;li&gt;Fold for search; compare exactly for identity.&lt;/li&gt;
&lt;li&gt;Measure the wrong-rate before and after, on real inputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic Unicode trivia. It is one line of case mapping and one missing word boundary, and together they were quietly discarding a third of the routing decisions in a system that looked, from the outside, like a model quality problem.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written from production work at Alesta WEB.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>unicode</category>
      <category>i18n</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Word "null" Is Not a Null: How JSON Nulls Turn Into Text on Android</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 30 Aug 2026 10:39:10 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/the-word-null-is-not-a-null-how-json-nulls-turn-into-text-on-android-10kg</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/the-word-null-is-not-a-null-how-json-nulls-turn-into-text-on-android-10kg</guid>
      <description>&lt;p&gt;A user opened our mobile app and saw this on their profile screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Membership: null
Points: null
Last payment: null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same account on the website showed empty fields and a zero. No crash, no error dialog, no red line in the log. The app had received the data, parsed it successfully, and printed the four characters &lt;code&gt;n&lt;/code&gt;, &lt;code&gt;u&lt;/code&gt;, &lt;code&gt;l&lt;/code&gt;, &lt;code&gt;l&lt;/code&gt; into a &lt;code&gt;TextView&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The parsing code looked like it had already handled this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"membership"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole bug. On Android, when &lt;code&gt;membership&lt;/code&gt; is JSON &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;optString&lt;/code&gt; does not return &lt;code&gt;""&lt;/code&gt;. It returns the string &lt;code&gt;"null"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it does that
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;org.json&lt;/code&gt; on Android represents JSON null with a sentinel object, &lt;code&gt;JSONObject.NULL&lt;/code&gt;. It is not Java &lt;code&gt;null&lt;/code&gt; — it is a real object that exists specifically so the library can tell "the key is absent" apart from "the key is present and its value is null."&lt;/p&gt;

&lt;p&gt;Android's implementation of &lt;code&gt;optString&lt;/code&gt; is, in essence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;optString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ~ object.toString()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;opt("membership")&lt;/code&gt; returns the sentinel. The sentinel is not Java &lt;code&gt;null&lt;/code&gt;, so the fallback branch is never reached. &lt;code&gt;toString()&lt;/code&gt; is called on it, and &lt;code&gt;JSONObject.NULL.toString()&lt;/code&gt; returns — by design, because it has to serialize back to valid JSON — the text &lt;code&gt;"null"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fallback works exactly as documented for a &lt;em&gt;missing&lt;/em&gt; key. It does nothing for a &lt;em&gt;null&lt;/em&gt; key. Those are two different states and the API gives them one parameter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getString&lt;/code&gt; has the same outcome. It does not throw on a null value; it hands you &lt;code&gt;"null"&lt;/code&gt; as well.&lt;/p&gt;

&lt;p&gt;Now compare the neighbours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"points"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// JSON null -&amp;gt; 0     ✅&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optBoolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// JSON null -&amp;gt; false ✅&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"plan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// JSON null -&amp;gt; "null" ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;optInt&lt;/code&gt; and &lt;code&gt;optBoolean&lt;/code&gt; route through converters that return Java &lt;code&gt;null&lt;/code&gt; for the sentinel, so the fallback fires. Only the string path stringifies the sentinel. This inconsistency is why the bug survives review: three lines that look identical, and one of them behaves differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  It also depends on which &lt;code&gt;org.json&lt;/code&gt; you are compiled against
&lt;/h3&gt;

&lt;p&gt;This is worth checking rather than assuming. The reference implementation from json.org — the &lt;code&gt;org.json:json&lt;/code&gt; jar you would pull into a plain JVM or server-side project — has a version of the same method that reads roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;optString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;defaultValue&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;NULL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;defaultValue&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks the sentinel explicitly and returns your default. Same package, same class, same method signature, different answer.&lt;/p&gt;

&lt;p&gt;Android ships its own Harmony-derived implementation in the framework, and it wins on the classpath. So a unit test that runs on the JVM with the json.org jar can pass while the device fails. If your &lt;code&gt;test&lt;/code&gt; source set is green and your &lt;code&gt;androidTest&lt;/code&gt; source set is red on the same assertion, this is a candidate.&lt;/p&gt;

&lt;p&gt;Confirm it in ten seconds with an instrumented test rather than trusting either the docs or this article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;optStringOnJsonNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JSONObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"""{"a": null}"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// fails on device&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where the null came from in the first place
&lt;/h2&gt;

&lt;p&gt;Fixing the client is only half of it, because the response should not have contained nulls for those fields at all.&lt;/p&gt;

&lt;p&gt;The API was PHP, and the payload was built from a query that looked something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_paid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paid_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_payment&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate null factories in one query:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LEFT JOIN&lt;/code&gt; with no matching rows.&lt;/strong&gt; Every column from &lt;code&gt;payments&lt;/code&gt; comes back &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregates over an empty set.&lt;/strong&gt; &lt;code&gt;SUM()&lt;/code&gt; and &lt;code&gt;MAX()&lt;/code&gt; return &lt;code&gt;NULL&lt;/code&gt;, not &lt;code&gt;0&lt;/code&gt; and not &lt;code&gt;''&lt;/code&gt;, when there are no rows to aggregate. &lt;code&gt;COUNT()&lt;/code&gt; is the exception — it returns &lt;code&gt;0&lt;/code&gt; — which is exactly why people assume the others do too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The website never showed this because the view layer cast on the way out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'total_paid'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;htmlspecialchars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'plan_name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;?? 0&lt;/code&gt; there is doing real work — &lt;code&gt;??&lt;/code&gt; catches SQL &lt;code&gt;NULL&lt;/code&gt; once PDO has turned it into PHP &lt;code&gt;null&lt;/code&gt;. Web output was clean. The JSON endpoint serialized the same row directly, &lt;code&gt;json_encode&lt;/code&gt; mapped PHP &lt;code&gt;null&lt;/code&gt; to JSON &lt;code&gt;null&lt;/code&gt;, and the app printed the word.&lt;/p&gt;

&lt;p&gt;This is the general shape: &lt;strong&gt;a null that is invisible in one rendering path becomes visible in another.&lt;/strong&gt; Two consumers of the same data, one of which happens to have a defensive cast in its template.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the test accounts never caught it
&lt;/h3&gt;

&lt;p&gt;The seeded test users all had payment history. Every demo account had rows. The only way to produce this response was to log in as a member with &lt;em&gt;nothing&lt;/em&gt; — a brand new registration, or an account whose records had been archived.&lt;/p&gt;

&lt;p&gt;A user with zero rows is a boundary condition, and it is the one that seed scripts systematically do not create, because seed scripts exist to make screens look populated. If you write fixtures, make one account deliberately empty and put it in the smoke test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix both layers, not one
&lt;/h2&gt;

&lt;p&gt;The tempting move is to fix the server, since that is where the null originates, and declare the client fine. That is wrong for mobile specifically, and here is why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Old app versions live forever.&lt;/strong&gt; You control your server. You do not control which build is on a user's phone. A client that renders &lt;code&gt;"null"&lt;/code&gt; when handed a JSON null is a permanently loaded gun pointed at every future endpoint, including ones written by someone who has never heard of this bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caches outlive deploys.&lt;/strong&gt; An HTTP cache, a CDN, or a local response cache can hand the app a pre-fix payload well after the server is patched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New fields arrive constantly.&lt;/strong&gt; Server-side normalization is a policy you have to remember to apply. Client-side hardening is a helper you cannot forget to use, because it is the only way to read a string.&lt;/p&gt;

&lt;p&gt;So: both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server side — normalize once, at the boundary
&lt;/h3&gt;

&lt;p&gt;Recursively convert nulls at the point where the response is serialized, not in each handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;json_clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;json_clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// single exit point for every API response&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;api_respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type: application/json; charset=utf-8'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;json_clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;JSON_UNESCAPED_UNICODE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat worth stating plainly: this flattens the difference between "no value" and "empty value". For a display API that is what you want. For an API where a client needs to distinguish "field not provided" from "field explicitly cleared" — a PATCH-style contract, for instance — do not do this globally. Fix those fields at the query instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_paid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;plan_name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COALESCE&lt;/code&gt; is the honest fix; the recursive normalizer is the safety net under it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client side — never call &lt;code&gt;optString&lt;/code&gt; directly again
&lt;/h3&gt;

&lt;p&gt;Wrap it once, ban the raw call in review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Json&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JSONObject&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;// a server can also send the *string* "null" — from string concatenation,&lt;/span&gt;
        &lt;span class="c1"&gt;// a logging wrapper, or an older client that echoed this bug back&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"null"&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"undefined"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JSONObject&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;textOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JSONObject&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ifEmpty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isNull(key)&lt;/code&gt; is the load-bearing line. It returns true for both Java &lt;code&gt;null&lt;/code&gt; and the &lt;code&gt;JSONObject.NULL&lt;/code&gt; sentinel, which is the check &lt;code&gt;optString&lt;/code&gt; fails to make on your behalf.&lt;/p&gt;

&lt;p&gt;Filtering the literal &lt;code&gt;"null"&lt;/code&gt; string looks paranoid until you have seen a server do &lt;code&gt;"Welcome, " + name&lt;/code&gt; in a template and store the result. Once a &lt;code&gt;"null"&lt;/code&gt; has been written to a database by an upstream system, no amount of correctness downstream removes it — you have to strip it at read time.&lt;/p&gt;

&lt;p&gt;Then make the raw call unusable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build.gradle.kts — fail the build, not the user&lt;/span&gt;
&lt;span class="c1"&gt;// (or use Detekt's ForbiddenMethodCall rule)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A lint rule beats a code review comment, because the review comment only catches the calls written while the reviewer remembers the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Gson, Moshi, kotlinx.serialization?
&lt;/h2&gt;

&lt;p&gt;Mostly they handle this correctly, with one trap of their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moshi&lt;/strong&gt; and &lt;strong&gt;kotlinx.serialization&lt;/strong&gt; are aware of Kotlin nullability. Declare &lt;code&gt;val plan: String&lt;/code&gt; as non-null and feed it JSON null, and they throw at parse time — a loud, immediate, correctly-located failure. That is the behaviour you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gson&lt;/strong&gt; does not. It constructs objects through unsafe reflection and bypasses Kotlin's null checks entirely, so a JSON null lands in a field the type system swears is non-null. Nothing fails at parse time. The NPE happens later, somewhere unrelated, and the stack trace points at the innocent code that touched the field rather than the parser that lied about it. That is arguably worse than &lt;code&gt;"null"&lt;/code&gt; on screen, because at least &lt;code&gt;"null"&lt;/code&gt; on screen tells you where it came from.&lt;/p&gt;

&lt;p&gt;If you are on Gson, declare the fields nullable (&lt;code&gt;String?&lt;/code&gt;) and handle the nulls explicitly. Fighting for non-null fields you cannot enforce buys nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;org.json&lt;/code&gt; remains everywhere despite all three of these, for a simple reason: it is in the Android framework, weighs nothing, and needs no annotation processor. Small apps and old modules keep using it. It works fine — as long as you know that its string path does not have a null story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute audit
&lt;/h2&gt;

&lt;p&gt;Worth running on any app that talks to an API you also control:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "optString(" app/src&lt;/code&gt; — every hit is a candidate. Replace with a helper that calls &lt;code&gt;isNull&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "getString(" app/src&lt;/code&gt; on &lt;code&gt;JSONObject&lt;/code&gt; receivers — same problem, no fallback parameter to give false comfort.&lt;/li&gt;
&lt;li&gt;In the API, find aggregates without &lt;code&gt;COALESCE&lt;/code&gt;: &lt;code&gt;SUM(&lt;/code&gt;, &lt;code&gt;MAX(&lt;/code&gt;, &lt;code&gt;MIN(&lt;/code&gt;, &lt;code&gt;AVG(&lt;/code&gt; — and every &lt;code&gt;LEFT JOIN&lt;/code&gt; selecting columns from the right-hand table.&lt;/li&gt;
&lt;li&gt;Create one test account with zero related rows and open every screen with it. This finds more null bugs in ten minutes than reading code for a day.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; your database for the literal string &lt;code&gt;'null'&lt;/code&gt; in text columns. If you find any, something upstream has already been concatenating nulls into text, and that data needs cleaning independently of any code fix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The underlying lesson is not about one library method. It is that &lt;code&gt;null&lt;/code&gt; crosses system boundaries badly. SQL &lt;code&gt;NULL&lt;/code&gt;, PHP &lt;code&gt;null&lt;/code&gt;, JSON &lt;code&gt;null&lt;/code&gt;, Java &lt;code&gt;null&lt;/code&gt;, Kotlin &lt;code&gt;null?&lt;/code&gt;, and a sentinel object that stringifies to &lt;code&gt;"null"&lt;/code&gt; are six different things wearing the same name, and every hop between two of them is a place where one quietly becomes another. Put the conversion in one function per boundary, and you only have to be right once.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build and run news, e-commerce and membership platforms at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;. This one took an afternoon and a screenshot from a user who assumed "null" was our membership tier.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>json</category>
      <category>api</category>
    </item>
    <item>
      <title>MySQL Will Quietly Eat Your Data If You Let It: Truncation, Charsets, and the 1366 You Never See</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 23 Aug 2026 08:30:21 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/mysql-will-quietly-eat-your-data-if-you-let-it-truncation-charsets-and-the-1366-you-never-see-2690</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/mysql-will-quietly-eat-your-data-if-you-let-it-truncation-charsets-and-the-1366-you-never-see-2690</guid>
      <description>&lt;p&gt;A WhatsApp button on a shop was dialling the wrong number. Not a formatting problem — a genuinely different number, three digits short of the one in the admin panel.&lt;/p&gt;

&lt;p&gt;The admin panel had saved it. The page was reading it. Nothing errored. The column was &lt;code&gt;varchar(10)&lt;/code&gt;, the number arrived with a country code, and MySQL had done exactly what it was configured to do: chop the value to fit and carry on.&lt;/p&gt;

&lt;p&gt;That column had been wrong for months. Nobody noticed because nothing anywhere in the stack raised its voice.&lt;/p&gt;

&lt;p&gt;Here is the family of bugs that behaves this way, how to find them in a database you inherited, and why the obvious fix — "just turn on strict mode" — creates a different bug if you do only that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: The truncation
&lt;/h2&gt;

&lt;p&gt;Run this on any MySQL or MariaDB instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt;&lt;span class="k"&gt;GLOBAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql_mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt;&lt;span class="k"&gt;SESSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql_mode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If neither contains &lt;code&gt;STRICT_TRANS_TABLES&lt;/code&gt; or &lt;code&gt;STRICT_ALL_TABLES&lt;/code&gt;, then for every &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; your server is in the business of making data fit, not of telling you it doesn't.&lt;/p&gt;

&lt;p&gt;What that means concretely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'905551234567'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;WARNINGS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Level: Warning  Code: 1265  Message: Data truncated for column 'phone' at row 1&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 9055512345&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the level. &lt;strong&gt;Warning&lt;/strong&gt;, not error. The statement succeeded. &lt;code&gt;affected_rows&lt;/code&gt; is 1. PDO in exception mode throws nothing, because there is nothing to throw — MySQL considers this a completed statement. Your ORM reports success. Your integration test asserting "the row exists" passes. The value is wrong.&lt;/p&gt;

&lt;p&gt;The same mechanism applies well beyond strings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You wrote&lt;/th&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Non-strict result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'905551234567'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(10)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'9055512345'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;300&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TINYINT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;127&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'2026-02-31'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'0000-00-00'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;''&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INT NOT NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12.999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DECIMAL(4,2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;13.00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those is a silent, permanent difference between what your application believed and what your database holds. And unlike a crash, there is no timestamp to correlate against — you cannot tell from the row when it happened or how many rows before it went the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding the damage in a database you inherited
&lt;/h3&gt;

&lt;p&gt;You cannot recover truncated values — the tail is gone. But you can find the columns where it is &lt;em&gt;happening&lt;/em&gt;, which is what matters going forward. Truncated values pile up at exactly the column limit, so the suspects are the columns where real rows sit at the maximum length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. list the string columns and their limits&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CHARACTER_MAXIMUM_LENGTH&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_len&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;DATA_TYPE&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'varchar'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'char'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;CHARACTER_MAXIMUM_LENGTH&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;      &lt;span class="c1"&gt;-- short columns are where this bites&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;CHARACTER_MAXIMUM_LENGTH&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, per interesting column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;at_the_limit&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;CHAR_LENGTH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A handful of rows sitting exactly at the limit can be a coincidence. A &lt;em&gt;majority&lt;/em&gt; of rows sitting exactly at the limit is not a coincidence, it is a report. In our case the count was every row that had ever been saved with a country code.&lt;/p&gt;

&lt;p&gt;Two details that matter when you run this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;CHAR_LENGTH()&lt;/code&gt;, not &lt;code&gt;LENGTH()&lt;/code&gt;. &lt;code&gt;LENGTH()&lt;/code&gt; counts &lt;strong&gt;bytes&lt;/strong&gt;; &lt;code&gt;CHAR_LENGTH()&lt;/code&gt; counts &lt;strong&gt;characters&lt;/strong&gt;. On multi-byte text they disagree, and &lt;code&gt;VARCHAR(n)&lt;/code&gt; limits characters, not bytes.&lt;/li&gt;
&lt;li&gt;Do this on a replica or a restored dump if the table is large. &lt;code&gt;CHAR_LENGTH()&lt;/code&gt; in a &lt;code&gt;WHERE&lt;/code&gt; clause means a full scan.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Part 2: Strict mode is necessary and not sufficient
&lt;/h2&gt;

&lt;p&gt;The obvious response is to turn strict mode on. Do it — but understand what you are buying.&lt;/p&gt;

&lt;p&gt;Strict mode does not repair the mismatch between what your application sends and what the column accepts. It changes how you find out about it: instead of quietly storing a wrong value, MySQL raises error 1406 (&lt;code&gt;Data too long for column&lt;/code&gt;) and your code, which never expected a write to fail, hands the user a 500.&lt;/p&gt;

&lt;p&gt;I have seen exactly this on an admin form. Strict mode was correctly enabled; the title field in the form had no maxlength and no server-side length check; the column was &lt;code&gt;varchar(150)&lt;/code&gt;. Someone pasted a long headline. Instead of a validation message the editor got a blank error page, and — worse — a story they thought they had saved and hadn't.&lt;/p&gt;

&lt;p&gt;That is still better than silent corruption, because it is &lt;em&gt;loud&lt;/em&gt; and it is &lt;em&gt;immediate&lt;/em&gt;. But the actual fix is at the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// same number as the column, in one place&lt;/span&gt;
    &lt;span class="s1"&gt;'phone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;back&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: at most &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; characters."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three habits that keep this honest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate before you write, with the column's real limit.&lt;/strong&gt; Generate the limits from &lt;code&gt;information_schema&lt;/code&gt; if you can — a hand-copied number drifts the first time someone runs an &lt;code&gt;ALTER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn strict mode on so the boundary check has a backstop.&lt;/strong&gt; Validation you wrote can be bypassed; the database is the last honest party in the chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widen the column when the data is legitimately bigger.&lt;/strong&gt; &lt;code&gt;VARCHAR(10)&lt;/code&gt; for a phone number was never right. Truncation is a symptom; a wrong schema is the disease.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Part 3: Charsets, where "it worked on the old server" lives
&lt;/h2&gt;

&lt;p&gt;A database was copied to a new server. Everything imported. Row counts matched. A week later somebody noticed that Turkish characters in older records had turned into question marks in some tables and not others.&lt;/p&gt;

&lt;p&gt;The mistake was trusting the database default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- CHARACTER SET utf8mb4  ← looks fine, means almost nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database default applies to &lt;strong&gt;newly created tables that do not specify their own&lt;/strong&gt;. It says nothing about the columns you already have. The columns are where the truth is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CHARACTER_SET_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COLLATION_NAME&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'app'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CHARACTER_SET_NAME&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CHARACTER_SET_NAME&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8mb4'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns rows, you have a mixed-charset database, and every dump/restore across it is a chance to lose bytes.&lt;/p&gt;

&lt;p&gt;Two more charset facts worth having in your head:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;utf8&lt;/code&gt; in MySQL is not UTF-8.&lt;/strong&gt; The alias &lt;code&gt;utf8&lt;/code&gt; (historically &lt;code&gt;utf8mb3&lt;/code&gt;) stores at most three bytes per character, so it cannot hold anything outside the Basic Multilingual Plane — emoji, some CJK extensions, some historic scripts. Insert one into a &lt;code&gt;utf8&lt;/code&gt; column and, in non-strict mode, MySQL truncates the value &lt;em&gt;at the emoji&lt;/em&gt; and keeps whatever came before. &lt;code&gt;utf8mb4&lt;/code&gt; is real UTF-8. Use it everywhere, including the connection charset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The connection charset is part of the pipeline.&lt;/strong&gt; Your column can be &lt;code&gt;utf8mb4&lt;/code&gt;, your data can be perfect, and your terminal or client can still show you mojibake because the &lt;em&gt;session&lt;/em&gt; negotiated something else. Which leads to the most useful debugging habit in this whole article:&lt;/p&gt;

&lt;h3&gt;
  
  
  When you think data is corrupted, check the bytes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;CHAR_LENGTH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HEX()&lt;/code&gt; is the ground truth. It is not affected by your client, your terminal font, or the connection charset. I have twice now "found" data loss that turned out to be a display artefact of the command-line client, and both times &lt;code&gt;HEX()&lt;/code&gt; settled it in one query: the bytes were intact, the rendering was not.&lt;/p&gt;

&lt;p&gt;The tell for genuine UTF-8 content is &lt;code&gt;LENGTH()&lt;/code&gt; &amp;gt; &lt;code&gt;CHAR_LENGTH()&lt;/code&gt;. If they are equal on text you know contains non-ASCII characters, something upstream already flattened it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: The application-side version of the same bug
&lt;/h2&gt;

&lt;p&gt;Databases are not the only layer that cuts strings. This is a summary excerpt in PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;substr()&lt;/code&gt; counts &lt;strong&gt;bytes&lt;/strong&gt;. On UTF-8 text, byte 200 lands in the middle of a multi-byte character about half the time, and you send MySQL a string ending in half a character. Then either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the column is &lt;code&gt;utf8mb4&lt;/code&gt; and strict mode is on → error &lt;strong&gt;1366, &lt;code&gt;Incorrect string value&lt;/code&gt;&lt;/strong&gt;, and you get a 500 from what looked like a formatting line; or&lt;/li&gt;
&lt;li&gt;strict mode is off → MySQL drops the invalid tail silently, and your summaries are quietly one character shorter than you think, sometimes ending in a replacement glyph.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is one letter and three characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// counts characters&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whenever you see &lt;code&gt;strlen&lt;/code&gt;, &lt;code&gt;substr&lt;/code&gt;, &lt;code&gt;strtoupper&lt;/code&gt;, or &lt;code&gt;str_pad&lt;/code&gt; applied to user text, treat it as a bug report waiting to happen. Error 1366 in a log almost always traces back to one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Run these on any project you did not set up yourself. They take about ten minutes together.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;SELECT @@GLOBAL.sql_mode;&lt;/code&gt; — is &lt;code&gt;STRICT_TRANS_TABLES&lt;/code&gt; present? If not, plan to enable it &lt;em&gt;after&lt;/em&gt; auditing lengths, not before.&lt;/li&gt;
&lt;li&gt;List &lt;code&gt;varchar&lt;/code&gt; columns with small limits, then count rows sitting exactly at the limit. That count is your truncation report.&lt;/li&gt;
&lt;li&gt;List columns whose &lt;code&gt;CHARACTER_SET_NAME&lt;/code&gt; is not &lt;code&gt;utf8mb4&lt;/code&gt;. Fix them before the next migration, not during it.&lt;/li&gt;
&lt;li&gt;Confirm the connection charset your application actually negotiates — the DSN, not the config file you hope it reads.&lt;/li&gt;
&lt;li&gt;Grep the codebase for byte-based string functions applied to user content, and replace them with the &lt;code&gt;mb_&lt;/code&gt; versions.&lt;/li&gt;
&lt;li&gt;When you suspect corruption, run &lt;code&gt;HEX()&lt;/code&gt; before you conclude anything. Half the time the data is fine and the client is lying.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The theme across all of this: the database is willing to accept an approximation of what you gave it, and by default it will not argue. Make it argue. Then make sure your code is ready to hear it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build and run news and e-commerce platforms at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;. Every example above cost somebody a real afternoon.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>php</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Uptime Monitor Says 200. Your Contact Form Has Been Dead for Three Days.</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 23 Aug 2026 08:27:02 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/your-uptime-monitor-says-200-your-contact-form-has-been-dead-for-three-days-1n1p</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/your-uptime-monitor-says-200-your-contact-form-has-been-dead-for-three-days-1n1p</guid>
      <description>&lt;p&gt;The contact form on a company site I look after stopped producing leads. Not "fewer leads" — zero, for three days. No alert fired. The uptime monitor was green the entire time. The status page had a nice unbroken bar.&lt;/p&gt;

&lt;p&gt;The monitor was checking the homepage. The homepage was fine. &lt;code&gt;/contact&lt;/code&gt; had been returning a fatal error since a Tuesday afternoon deploy.&lt;/p&gt;

&lt;p&gt;This is the most boring class of outage there is, and it is the one that costs the most, because nothing tells you it is happening. Below is what actually broke, the four ways a site can be broken while still answering &lt;code&gt;200&lt;/code&gt;, and the small set of checks I run now instead of pinging the root URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;The site loads a list of helper files at boot. During an unrelated edit, that list was rewritten, and one entry did not survive the rewrite — a helper that supplied data to a few templates.&lt;/p&gt;

&lt;p&gt;Most pages never touch that helper, so they kept rendering. One page called it in a template. That page fataled. In production, with display errors off, a fatal means an empty response body or a generic error page — depending on how the stack is configured, sometimes with a &lt;code&gt;500&lt;/code&gt; status, sometimes with the web server's own branded page and a status you did not choose.&lt;/p&gt;

&lt;p&gt;The error log had the answer on line one, at the exact timestamp of the deploy. Nobody looked, because nothing said to look.&lt;/p&gt;

&lt;p&gt;Three days of a form that a campaign was actively driving traffic to. The traffic arrived. The page did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways to be broken and still return 200
&lt;/h2&gt;

&lt;p&gt;If your health check is &lt;code&gt;curl -o /dev/null -w "%{http_code}" https://example.com/&lt;/code&gt; you are testing one route, one time, for one property. Here are the failure modes that sail straight through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The route you don't check.&lt;/strong&gt; A site is not a URL, it is a few dozen to a few hundred routes. Homepages are the least likely page to break, because everything touches them and everyone looks at them. The pages that break are the ones with one unusual dependency: the form page, the search page, the report that joins four tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The page renders, but a part of it silently didn't.&lt;/strong&gt; Templates are forgiving by design. A partial that throws inside a try/catch, a widget whose data source returned an empty array, a block that renders only when a config key exists — none of these change the status code. The page looks right at a glance and is missing the thing that made it worth serving.&lt;/p&gt;

&lt;p&gt;I found one of these in the same audit: a function that emitted structured data (&lt;code&gt;Article&lt;/code&gt;, &lt;code&gt;BreadcrumbList&lt;/code&gt;) existed, was correct, and was called from nowhere. Thirty news URLs had been building JSON-LD that never reached the HTML. Status: 200. Value delivered: none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The page is fine and the form is not.&lt;/strong&gt; This one is hard to see. A full-page HTML cache was turned on. HTML caches are dumb by nature: they store bytes. Those bytes included a CSRF token. Every visitor got the first visitor's token, every POST failed validation, and the failure looked to the user like a page reload with no message. Every page on the site: 200. Every form on the site: broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The error page returns 200.&lt;/strong&gt; Custom error handlers that render a friendly "something went wrong" view and forget to set the status. Frameworks that catch late and fall back to a template. Reverse proxies serving a static maintenance page with the default status. Your monitor sees 200 and a body of some length, and reports health.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to assert instead
&lt;/h2&gt;

&lt;p&gt;The fix is not a bigger monitoring product. It is asserting the things you actually care about. Four checks, in increasing order of value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check every route, not one route
&lt;/h3&gt;

&lt;p&gt;You already have the route list — it is in your router, your sitemap, or both. Walk it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# routes.txt: one path per line, from the sitemap or the router&lt;/span&gt;
&lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; path&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/body &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; /tmp/body&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 2000 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; status=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;FAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;fi
done&lt;/span&gt; &amp;lt; routes.txt
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$FAIL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The size floor is doing real work here. A fatal that returns 200 with an empty or near-empty body is caught by &lt;code&gt;bytes&lt;/code&gt;, not by &lt;code&gt;status&lt;/code&gt;. Pick the floor from your own smallest legitimate page, then subtract a little.&lt;/p&gt;

&lt;h3&gt;
  
  
  Assert content, not just bytes
&lt;/h3&gt;

&lt;p&gt;One string per route, chosen to be the thing that page exists to do. The form page must contain a &lt;code&gt;&amp;lt;form&lt;/code&gt; and a submit button. The article page must contain an &lt;code&gt;&amp;lt;h1&lt;/code&gt;. The page with structured data must contain &lt;code&gt;application/ld+json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;form'&lt;/span&gt; /tmp/body &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: no form in body"&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s1"&gt;'fatal error\|&amp;lt;b&amp;gt;Warning&amp;lt;/b&amp;gt;\|stack trace\|Undefined variable'&lt;/span&gt; /tmp/body &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: error signature in HTML"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second grep catches the inverse case: the page that renders &lt;em&gt;and&lt;/em&gt; prints a warning into the output where a visitor — or a crawler — can read it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diff the error log around every deploy
&lt;/h3&gt;

&lt;p&gt;This is the cheapest high-value check in the list and almost nobody does it. Record the log size before the deploy, read the tail after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BEFORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;%s /path/to/error.log&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ... deploy ...&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="nv"&gt;AFTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;%s /path/to/error.log&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AFTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BEFORE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;AFTER &lt;span class="o"&gt;-&lt;/span&gt; BEFORE&lt;span class="k"&gt;))&lt;/span&gt; /path/to/error.log
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"New errors appeared during deploy — investigate before walking away."&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our incident this would have printed the missing helper, by name, thirty seconds after the deploy that caused it. The information was there the whole time. Nothing surfaced it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Round-trip the form
&lt;/h3&gt;

&lt;p&gt;The only way to know a form works is to submit it. A synthetic submission is about twenty lines and can run hourly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// 1. Fetch the page like a browser would, keeping cookies.&lt;/span&gt;
&lt;span class="nv"&gt;$ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://example.com/contact'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;curl_setopt_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="no"&gt;CURLOPT_RETURNTRANSFER&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;CURLOPT_COOKIEJAR&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/tmp/probe-cookies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;CURLOPT_COOKIEFILE&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/tmp/probe-cookies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Pull the CSRF token out of the markup, exactly like a browser.&lt;/span&gt;
&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/name="_token" value="([^"]+)"/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"FAIL: no CSRF token on the page&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Submit with a marker you can find and delete later.&lt;/span&gt;
&lt;span class="nv"&gt;$marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'probe-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'YmdHi'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;curl_setopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CURLOPT_POST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;curl_setopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CURLOPT_POSTFIELDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;http_build_query&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'_token'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'name'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$marker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'email'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'probe@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'automated probe, safe to delete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="nb"&gt;curl_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Assert the effect, not the response.&lt;/span&gt;
&lt;span class="nv"&gt;$found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"SELECT COUNT(*) FROM leads WHERE name = "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$marker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchColumn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$found&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"OK&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: submitted, nothing stored&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4 is the point of the whole thing. Not "did the POST return 200" — did a row appear. That single assertion catches the frozen-token case, the silently failing mail transport, the validation rule someone tightened, and the database column that quietly truncated the input.&lt;/p&gt;

&lt;p&gt;Give the probe an obvious marker and delete its rows on a schedule. Yes, this writes to production. That is the trade: a handful of tagged test rows a day against not knowing your lead form is dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;Three reasons, and none of them are laziness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring defaults are shaped by hosting, not by the product.&lt;/strong&gt; A default check answers "is the machine up and serving". That was the right question when a machine going down was the common failure. The common failure now is code that is up and wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Green is a feeling, and feelings are expensive to give up.&lt;/strong&gt; An unbroken status bar makes people stop looking. Silence should mean "the assertions passed". In practice it means "nothing asserted anything".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent failure has no symptom by definition.&lt;/strong&gt; A crash gets escalated in an hour. A form that swallows submissions gets noticed when someone asks why the pipeline is empty — which, in our case, took three days, and only because someone went looking for a different number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A status code is a &lt;em&gt;transport&lt;/em&gt; fact. It says the request reached something. It does not say the page did its job.&lt;/li&gt;
&lt;li&gt;Check every route, not the homepage. Your sitemap already lists them.&lt;/li&gt;
&lt;li&gt;Assert one meaningful string per route, and grep the body for error signatures while you are there.&lt;/li&gt;
&lt;li&gt;Diff the error log across every deploy, automatically. Cheapest check here.&lt;/li&gt;
&lt;li&gt;Round-trip the forms that make you money, and assert the &lt;em&gt;stored row&lt;/em&gt;, not the response.&lt;/li&gt;
&lt;li&gt;Anything that renders "sometimes" — structured data, widgets, conditional blocks — needs an output assertion, because code review will not catch a function that is never called.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is sophisticated. The whole set is a cron job and about a hundred lines. The reason to write it is that the alternative is finding out from a person, and by then you have already paid for the traffic.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build and maintain news and e-commerce platforms at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;. Most of what I know about monitoring came from outages exactly this dumb.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>The Query That Ate 75% of Our Database CPU: A MySQL Full-Text Post-Mortem</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 06 Aug 2026 22:11:21 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/the-query-that-ate-75-of-our-database-cpu-a-mysql-full-text-post-mortem-4ob0</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/the-query-that-ate-75-of-our-database-cpu-a-mysql-full-text-post-mortem-4ob0</guid>
      <description>&lt;p&gt;A news platform I maintain started serving pages in six seconds. Load average sat at 16 on a 12-core box for hours. Nothing had been deployed. Traffic was up, but not 10x up.&lt;/p&gt;

&lt;p&gt;The cause turned out to be a single &lt;code&gt;SELECT&lt;/code&gt; that looked completely reasonable — the kind of query that passes code review, works fine on a 5,000-row table, and quietly becomes a wrecking ball at 80,000 rows.&lt;/p&gt;

&lt;p&gt;Here is the whole investigation: how I found it, why it was slow, what the fix was, and the three unrelated things I learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom first
&lt;/h2&gt;

&lt;p&gt;The obvious metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Load average &lt;strong&gt;16.7&lt;/strong&gt; on 12 cores&lt;/li&gt;
&lt;li&gt;MySQL at &lt;strong&gt;92% CPU&lt;/strong&gt;, resident memory 9.8 GB out of 15 GB&lt;/li&gt;
&lt;li&gt;4.8 GB pushed into swap&lt;/li&gt;
&lt;li&gt;Time to first byte on article pages: &lt;strong&gt;6.3 seconds&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tempting move here is to start tuning. Bump the buffer pool, add more cache, blame the bots. I've done that before and it's mostly a way of not finding the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the query, not a query
&lt;/h2&gt;

&lt;p&gt;The mistake I see most often at this stage is taking one snapshot of the process list, spotting something slow, and declaring victory. One snapshot tells you what was running at one instant. It doesn't tell you what dominates.&lt;/p&gt;

&lt;p&gt;Sample it instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 20&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;mysql &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT info FROM information_schema.processlist
               WHERE command='Execute' AND info IS NOT NULL"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/.*MATCH.*/RELATED-ARTICLES/'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty cheap samples, bucketed by shape. The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    202 RELATED-ARTICLES
     41 other article queries
     21 rate-limit counter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;202 of 268 active queries — 75% — were the same statement.&lt;/strong&gt; That's not a slow query problem, that's a "one feature is eating the server" problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query
&lt;/h2&gt;

&lt;p&gt;It powers the "related articles" block under every story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AGAINST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;NATURAL&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;relevance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AGAINST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;NATURAL&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relevance&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATEDIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that &lt;code&gt;ORDER BY&lt;/code&gt; again. It divides the relevance score by the article's age in months, so a strong match from 2019 loses to a decent match from last week. It's a genuinely nice ranking idea. Freshness matters in news.&lt;/p&gt;

&lt;p&gt;And the search term passed in? The current article's &lt;strong&gt;entire title plus the first 200 characters of its summary&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's expensive
&lt;/h2&gt;

&lt;p&gt;Two things compound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Natural language mode is not a filter, it's a scorer.&lt;/strong&gt; Feed it a long string and it matches on any meaningful token in that string. I measured it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AGAINST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;full title + 200 chars of summary&amp;gt;'&lt;/span&gt;
                                    &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;NATURAL&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 16,141&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;16,141 rows matched out of 80,836&lt;/strong&gt; — about 20% of the table. A long natural-language term is a very wide net. Every extra common word widens it further.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;ORDER BY&lt;/code&gt; cannot use an index.&lt;/strong&gt; It sorts on an expression computed per row. The optimizer has no choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----------+----------------+----------------------------------------------+
| type     | key            | Extra                                        |
+----------+----------------+----------------------------------------------+
| fulltext | ft_title_summ  | Using where; Using temporary; Using filesort  |
+----------+----------------+----------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Using temporary; Using filesort&lt;/code&gt; on a fulltext scan is the whole story. To return &lt;strong&gt;6 rows&lt;/strong&gt;, the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;scores ~16,000 rows,&lt;/li&gt;
&lt;li&gt;materialises them into a temporary table,&lt;/li&gt;
&lt;li&gt;computes &lt;code&gt;relevance / (age/30 + 1)&lt;/code&gt; for every one of them,&lt;/li&gt;
&lt;li&gt;sorts all 16,000,&lt;/li&gt;
&lt;li&gt;throws away 15,994.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Measured cost: &lt;strong&gt;~0.22 s of CPU per call&lt;/strong&gt;. On every article page view. With half the traffic coming from crawlers walking the archive, each hitting a &lt;em&gt;different&lt;/em&gt; article — so per-article caching had a near-zero hit rate for exactly the traffic causing the load.&lt;/p&gt;

&lt;p&gt;At a handful of article views per second, that one query alone wants more cores than the machine has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: stop searching with a paragraph
&lt;/h2&gt;

&lt;p&gt;The search term should be the &lt;em&gt;subject&lt;/em&gt; of the article, not the article. I extract meaningful words from the title only — drop stopwords, drop anything under three characters, cap at eight words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;searchTermFromTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'and'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'the'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'with'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'for'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'from'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'that'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'this'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'was'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'are'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'has'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^\p{L}\p{N}\s]+/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\s+/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$clean&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nv"&gt;$picked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$clean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$picked&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$picked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$picked&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$picked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$clean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match count dropped from &lt;strong&gt;~16,000–58,000&lt;/strong&gt; (it varied wildly by article) to &lt;strong&gt;~2,500–13,000&lt;/strong&gt;. Better, not solved.&lt;/p&gt;

&lt;p&gt;A note for non-English text: build the stopword list for the language you actually store. Mechanically reusing an English list on Turkish, German or Finnish content will either strip nothing or strip the wrong things, and morphology means a naive list misses inflected forms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: rank in two stages
&lt;/h2&gt;

&lt;p&gt;The real problem isn't the match count, it's sorting all matches by an expression. So don't. Take the top N by raw relevance — which the fulltext index can drive with a bounded sort — then apply the freshness weighting to those N in an outer query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relevance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AGAINST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;NATURAL&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;relevance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank_date&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AGAINST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;NATURAL&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;relevance&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relevance&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATEDIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expensive sort now runs over &lt;strong&gt;60 rows instead of 16,000&lt;/strong&gt;. The derived table has a &lt;code&gt;LIMIT&lt;/code&gt;, so it can't be merged away — it's materialised, which is precisely what I want here.&lt;/p&gt;

&lt;p&gt;Ranking quality barely moves. A result that wins after freshness weighting was already a strong relevance match; it was never sitting at position 4,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Measured against real rows, old query vs new, five articles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 queries, total&lt;/td&gt;
&lt;td&gt;2.571 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.572 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of active queries&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load average&lt;/td&gt;
&lt;td&gt;16.7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTFB, worst page&lt;/td&gt;
&lt;td&gt;6.34 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.91 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;4.5x on the query. And no caching layer involved&lt;/strong&gt; — this is the same work, arranged so the database isn't asked to sort a haystack to hand back six needles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I learned that weren't the bug
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;InnoDB does not give space back.&lt;/strong&gt; I pruned about 2.9 million rows from some log tables. &lt;code&gt;data_free&lt;/code&gt; went up by 270 MB and the files didn't shrink. Deleted rows leave free pages inside the tablespace, reusable by that table but not returned to the OS. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; rebuilds it. If your cleanup job only deletes, your disk usage will never reflect it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dynamic variable can fail silently.&lt;/strong&gt; &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; is dynamic on modern MariaDB, so I resized it live. No error. The value didn't change. The reason: &lt;code&gt;innodb_buffer_pool_chunk_size&lt;/code&gt; read as &lt;code&gt;0&lt;/code&gt;, and resizing works in chunk units. Always read the variable back after you set it — don't trust the absence of an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-lived MySQL processes bloat.&lt;/strong&gt; That instance had been up 85 days with a 1 GB buffer pool and was holding 9.8 GB resident, 4.8 GB of it swapped. After a restart with a properly sized pool: 1.96 GB resident, swap essentially empty, and 8 GB handed back to the OS. Per-connection buffers and temp-table churn accumulate; a restart is sometimes the honest fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sample the process list, don't snapshot it.&lt;/strong&gt; The dominant query and the slowest query are usually different queries, and the dominant one is what's hurting you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Using temporary; Using filesort&lt;/code&gt; next to a &lt;code&gt;LIMIT 6&lt;/code&gt; is a smell.&lt;/strong&gt; It means the server built the whole set to return a fraction of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Natural language full-text search scales with term length.&lt;/strong&gt; A search term built from a whole paragraph is a query against a fifth of your table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the shape before you add cache.&lt;/strong&gt; Caching this would have hidden it from users while the crawler traffic — cache-missing by construction — kept the CPU pinned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bug had been there for a long time. It only became visible when the archive got big enough and the crawlers got busy enough for those two curves to cross.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build news and e-commerce platforms in PHP at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt;, an independent software company running since 2005.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>performance</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Self-Hosted MCP: Building a Model Context Protocol Server in PHP</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:11:26 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/self-hosted-mcp-building-a-model-context-protocol-server-in-php-36a2</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/self-hosted-mcp-building-a-model-context-protocol-server-in-php-36a2</guid>
      <description>&lt;p&gt;Most of the Model Context Protocol tutorials you'll find are written in TypeScript or Python, because those are the languages with official SDKs. That leaves a fair number of us — people maintaining PHP applications that hold years of business data — wondering whether the protocol is even available to us.&lt;/p&gt;

&lt;p&gt;It is. MCP is a wire protocol, not a library. If your language can read a line from standard input and write JSON back, you can implement a server in it. This post walks through what the protocol actually asks of you, what a PHP implementation looks like in outline, and — the part I underestimated — what changes when you expose an internal system to a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol is an open standard for connecting AI assistants to external systems: your data, your tools, your APIs. The problem it solves is combinatorial. Before a standard existed, every assistant needed a bespoke integration with every data source. MCP defines one interface so that any compliant client can talk to any compliant server.&lt;/p&gt;

&lt;p&gt;Underneath, it's &lt;strong&gt;JSON-RPC 2.0&lt;/strong&gt;. Requests carry a &lt;code&gt;jsonrpc&lt;/code&gt; version, a &lt;code&gt;method&lt;/code&gt;, a &lt;code&gt;params&lt;/code&gt; object, and an &lt;code&gt;id&lt;/code&gt;; responses carry the matching &lt;code&gt;id&lt;/code&gt; and either a &lt;code&gt;result&lt;/code&gt; or an &lt;code&gt;error&lt;/code&gt;. Notifications are requests without an &lt;code&gt;id&lt;/code&gt; and expect no reply. If you've implemented a JSON-RPC service before, you already know 80% of the transport story.&lt;/p&gt;

&lt;p&gt;A server exposes three kinds of primitive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; — actions the model can invoke. Each has a name, a description, and a JSON Schema describing its inputs. This is the primitive that does something: query a database, create a record, send a request. The model chooses when to call them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resources&lt;/strong&gt; — data the model can read, addressed by URI. Files, records, generated documents. These are for context, not action, and the client generally decides what to pull in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts&lt;/strong&gt; — reusable prompt templates the user can invoke deliberately, often surfaced in the client's UI as a slash command or menu item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction between tools and resources matters more than it first appears. A rough rule: &lt;strong&gt;tools are model-controlled, resources are application-controlled.&lt;/strong&gt; If the model should decide whether to fetch something, make it a tool. If the user or the host application decides, make it a resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two transports
&lt;/h2&gt;

&lt;p&gt;MCP defines two standard transports, and picking the right one is the first architectural decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;stdio.&lt;/strong&gt; The client launches your server as a subprocess and talks to it over standard input and output — newline-delimited JSON, one message per line. This is the simplest possible setup: no ports, no HTTP server, no authentication layer, because the only thing that can talk to your process is the parent that spawned it. It's the right choice for anything running on the same machine as the client.&lt;/p&gt;

&lt;p&gt;Two rules with stdio, both easy to break in PHP:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never write anything but protocol messages to stdout.&lt;/strong&gt; A stray &lt;code&gt;echo&lt;/code&gt;, a &lt;code&gt;var_dump&lt;/code&gt; left in from debugging, or a PHP warning printed to stdout corrupts the message stream and the client will fail to parse it. Send diagnostics to &lt;strong&gt;stderr&lt;/strong&gt; instead, which the client typically forwards to a log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off output buffering and flush after every write&lt;/strong&gt;, or your responses sit in a buffer while the client waits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Streamable HTTP.&lt;/strong&gt; The server runs as an ordinary HTTP endpoint. The client POSTs JSON-RPC messages to it; the server replies with either a single JSON response or a stream of server-sent events when it needs to push multiple messages for one request. This is the transport for a server that runs somewhere other than the user's machine — which, for most PHP shops, is the interesting case, because that's the deployment model we already know how to operate.&lt;/p&gt;

&lt;p&gt;(An older HTTP+SSE transport exists in earlier revisions of the spec. New work should target Streamable HTTP.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The handshake
&lt;/h2&gt;

&lt;p&gt;Whichever transport you pick, the conversation starts the same way. The client sends &lt;code&gt;initialize&lt;/code&gt; with the protocol version it speaks and the capabilities it supports. Your server replies with its own protocol version, its capabilities, and its name and version. The client then sends an &lt;code&gt;initialized&lt;/code&gt; notification, and normal operation begins.&lt;/p&gt;

&lt;p&gt;Capabilities are how the two sides negotiate. If your server doesn't implement resources, you don't advertise the resources capability, and a well-behaved client won't call &lt;code&gt;resources/list&lt;/code&gt;. Don't advertise what you haven't built.&lt;/p&gt;

&lt;p&gt;After the handshake, the methods that matter are predictable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&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;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return the tools you expose, with descriptions and input schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Execute one tool with the given arguments, return its result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resources/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return available resources with their URIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resources/read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return the contents of one resource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompts/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return available prompt templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompts/get&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return one filled-in prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A minimal but genuinely useful server is &lt;code&gt;initialize&lt;/code&gt; plus &lt;code&gt;tools/list&lt;/code&gt; plus &lt;code&gt;tools/call&lt;/code&gt;. Everything else is optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the PHP side looks like
&lt;/h2&gt;

&lt;p&gt;The structural shape, transport-independent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read a JSON-RPC message
  → dispatch on `method`
  → build a result (or an error)
  → write the response with the same `id`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For stdio, that's a loop over &lt;code&gt;fgets(STDIN)&lt;/code&gt;, &lt;code&gt;json_decode&lt;/code&gt;, a &lt;code&gt;match&lt;/code&gt; on the method name, and &lt;code&gt;fwrite(STDOUT, json_encode($response) . "\n")&lt;/code&gt;. For Streamable HTTP, it's a single endpoint that decodes the request body and returns the encoded response. The dispatch layer in the middle is identical; only the read and write ends change. Write it that way from the start and you can support both from one codebase.&lt;/p&gt;

&lt;p&gt;Three PHP-specific things worth knowing before you start:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool schemas.&lt;/strong&gt; Every tool needs a JSON Schema for its inputs. Hand-writing those as nested arrays gets tedious fast. Deriving them from something you already maintain — a validation ruleset, a DTO, a set of typed constructor parameters read via reflection — keeps the schema and the actual implementation from drifting apart. Schema drift is the single most common cause of "the model keeps calling the tool wrong."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error handling.&lt;/strong&gt; Distinguish two kinds of failure. A malformed request or an unknown method is a &lt;strong&gt;protocol error&lt;/strong&gt;: return a JSON-RPC &lt;code&gt;error&lt;/code&gt; object. A tool that ran but failed — record not found, validation rejected the input — is a &lt;strong&gt;tool error&lt;/strong&gt;: return a normal result with the error flag set and a human-readable message. The distinction matters because the second kind goes back to the model, which can read the message and adjust. A protocol error just tells it something broke. Convert PHP exceptions into the second kind wherever the failure is something the model could plausibly recover from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running work.&lt;/strong&gt; PHP's request-per-process model is a good fit for stdio (the process lives as long as the session) and a slightly awkward one for HTTP if a tool takes minutes. Keep tool calls short. If a tool kicks off something slow, return a job identifier immediately and expose a second tool that reports status. Models handle that pattern well; they handle a request that times out badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting it to Claude
&lt;/h2&gt;

&lt;p&gt;Once the server runs, there are three broad ways to reach it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local, over stdio.&lt;/strong&gt; Desktop and CLI clients — Claude Desktop, Claude Code, and various IDE integrations — let you register a server by specifying a command to run and its arguments (&lt;code&gt;php&lt;/code&gt;, plus the path to your server script), and they manage the subprocess for you. This is the fastest way to get from "it responds to &lt;code&gt;tools/list&lt;/code&gt;" to "I'm using it."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remote, over HTTP.&lt;/strong&gt; A Streamable HTTP server deployed at a URL can be registered with clients that support remote connections. The Claude API also has an MCP connector: you declare the server's URL in the request, and the API makes the connection server-side, so the model can call your tools without you writing a client loop. Note that hosted MCP servers generally authenticate with OAuth bearer tokens rather than a service's own native API key — those are different auth systems, and assuming the latter works is a common early stumble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From your own code.&lt;/strong&gt; If you're building an agent rather than using an existing client, most AI SDKs can convert MCP tool definitions into their native tool format, so an MCP server becomes a source of tools for a loop you control.&lt;/p&gt;

&lt;p&gt;Whichever route, &lt;strong&gt;debug over stdio first&lt;/strong&gt;. The failure modes are simpler: no TLS, no auth, no proxy, no CORS. Get the protocol right, then move it to HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I underestimated: exposure
&lt;/h2&gt;

&lt;p&gt;Here's the thing that changes when you put an MCP server in front of an existing system. Every tool you expose is a capability granted to a model that is, at least in part, steered by text it reads from the outside world. If the model can be persuaded to call your tool, your tool runs.&lt;/p&gt;

&lt;p&gt;The practical consequences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope tools narrowly.&lt;/strong&gt; A generic &lt;code&gt;run_query&lt;/code&gt; tool that accepts arbitrary SQL is the most convenient thing to build and the worst thing to ship. Prefer specific tools with typed parameters — &lt;code&gt;find_customer_by_email&lt;/code&gt;, &lt;code&gt;list_orders_in_range&lt;/code&gt; — and validate every argument server-side as if it came from an anonymous HTTP request. It did, effectively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read and write are different risk classes.&lt;/strong&gt; Read-only tools have a disclosure risk. Write tools have a &lt;em&gt;this actually happened&lt;/em&gt; risk. Separate them, and put anything destructive or irreversible behind an explicit confirmation in the host application rather than trusting the model to be careful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The description is part of the security surface.&lt;/strong&gt; Tool descriptions are instructions the model reads. A vague description invites the model to call a tool in situations you didn't intend. Being prescriptive about &lt;em&gt;when&lt;/em&gt; a tool should be used — not just what it does — measurably improves both correctness and safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't leak what you didn't mean to expose.&lt;/strong&gt; Return the fields the task needs, not whole records. An internal note, a cost price, or a personal phone number that shouldn't be in a customer-facing answer shouldn't be in a tool result either. Filter at the server, not in the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log every call.&lt;/strong&gt; Tool name, arguments, caller, outcome. When someone asks "why did it do that," the log is the only answer you'll have.&lt;/p&gt;

&lt;p&gt;None of this is exotic — it's the same discipline you'd apply to a public API endpoint. The difference is that the caller is a language model rather than a developer reading your docs, so ambiguity gets resolved in ways you didn't anticipate rather than surfacing as a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth it?
&lt;/h2&gt;

&lt;p&gt;For a PHP application sitting on years of accumulated business data, MCP is the cheapest bridge I've found between that data and an assistant that can actually reason about it. There's no SDK to wait for. It's JSON-RPC over a pipe or an HTTP endpoint — both things PHP has done well for twenty years.&lt;/p&gt;

&lt;p&gt;Start with three read-only tools that answer questions someone in your organization asks weekly. Ship it over stdio to one person. See what they actually ask for. That's a far better spec than anything you'd design up front.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've built an MCP server in a language without an official SDK, I'd be curious what tripped you up — the transport, the schemas, or the scoping.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Generic CMS to Purpose-Built: What 200 Site Migrations Taught Me</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:09:31 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/from-generic-cms-to-purpose-built-what-200-site-migrations-taught-me-c0h</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/from-generic-cms-to-purpose-built-what-200-site-migrations-taught-me-c0h</guid>
      <description>&lt;p&gt;Migrating a live website to a new content management system is one of those jobs that looks like a data transfer and turns out to be an exercise in risk management. The database part is usually the easy half. The hard half is that a working site has years of accumulated meaning attached to it — URLs search engines memorised, editors' habits, a content model that drifted far from whatever it was on day one.&lt;/p&gt;

&lt;p&gt;Over the past years I've moved a few hundred sites — mostly news portals and online stores — off general-purpose, plugin-assembled CMS setups onto purpose-built platforms. Some of those migrations were clean. A few taught me things the hard way. This is the accumulated checklist, aimed at anyone about to do their first serious one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sites migrate at all
&lt;/h2&gt;

&lt;p&gt;Nobody migrates a CMS for fun. The trigger is almost always one of four things, and it's worth naming yours honestly before you start, because it determines what "success" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin sprawl.&lt;/strong&gt; A site starts generic and grows domain-specific through add-ons: one plugin for the ticker, one for the photo gallery, one for the ad slots, one for the newsletter, three for SEO. Each one is maintained by a different party on a different release cadence. The site works, but nobody can predict what a routine update will break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance under spikes.&lt;/strong&gt; A general CMS with fifteen plugins executes a lot of code to render a page that is, semantically, one article. For a news site where a single story can bring 40× normal traffic in ten minutes, that overhead stops being academic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editorial friction.&lt;/strong&gt; This is the one clients articulate least well and feel most. If publishing a story with a photo gallery, a source credit, a related-articles block, and an agency attribution takes eleven clicks across four screens, your newsroom will invent workarounds, and your data model will slowly fill with those workarounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost of ownership.&lt;/strong&gt; Ten commercial add-on licences plus a maintenance retainer, renewed annually, forever.&lt;/p&gt;

&lt;p&gt;Write your trigger down. If it's performance, you need before/after numbers. If it's editorial friction, you need to time the actual publishing workflow before and after. A migration that can't be measured will be judged on vibes, and vibes after a migration are always bad for the first two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone underestimates: URLs
&lt;/h2&gt;

&lt;p&gt;Here is the single highest-risk element of any migration of an established site: &lt;strong&gt;the URL is not a detail, it's the asset.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A news site that has been publishing for eight years has tens of thousands of indexed URLs. Some carry external links. Some rank on page one for terms the business depends on. Every one of them that returns a 404 after cutover is value thrown away, and search engines are considerably slower to give it back than they were to take it.&lt;/p&gt;

&lt;p&gt;So before touching anything else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Export the complete URL inventory.&lt;/strong&gt; Not the sitemap — the sitemap is what the old CMS &lt;em&gt;thinks&lt;/em&gt; exists. Pull the real list from server access logs (every distinct path with a 200 in the last 12 months), from the search console's indexed-pages report, and from the database. Union all three. The gap between the sitemap and the logs is routinely 20–30%, and it's where the old, still-ranking content lives.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Classify it.&lt;/strong&gt; Articles, category pages, tag pages, author pages, static pages, media files, feeds, pagination, and the long tail of oddities — print views, AMP variants, tracking-parameter duplicates, dated archive pages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decide the target URL scheme once, on purpose.&lt;/strong&gt; If the new system can preserve the existing pattern, preserve it. Redesigning URLs "while we're in there" doubles the risk of the migration for aesthetic gain. If you genuinely must change the scheme, you now owe every old URL a permanent redirect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the redirect map as data, not as rules.&lt;/strong&gt; Regex rules feel elegant and fail silently on the exceptions. A generated lookup table of old path → new path, produced from the actual inventory, is boring and verifiable. You can test all 40,000 entries automatically; you cannot test a regex against the URLs you forgot existed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify with a crawl, not a spot check.&lt;/strong&gt; After cutover, crawl the full old inventory against the live site and assert: status is 301 (not 302, not 200-with-different-content), the destination resolves 200 in one hop, and no chains or loops exist. Redirect chains are where migrations quietly leak ranking.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule I'd tattoo on a junior developer: &lt;strong&gt;a migration is not done when the content is moved; it's done when the old URLs still work.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Content models never map one-to-one
&lt;/h2&gt;

&lt;p&gt;The second discovery of every migration is that the old content model is not what the documentation says. It's what editors made it.&lt;/p&gt;

&lt;p&gt;You will find, reliably:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "category" that is actually used as a workflow flag ("Ready", "Legal check").&lt;/li&gt;
&lt;li&gt;Three tags that mean the same thing with different capitalisation, plus one with a trailing space.&lt;/li&gt;
&lt;li&gt;HTML pasted into a plain-text summary field, because the summary needed a line break in 2019.&lt;/li&gt;
&lt;li&gt;Images referenced by absolute URL to the old domain, inside article bodies.&lt;/li&gt;
&lt;li&gt;A custom field that is empty on 90% of records and load-bearing on the other 10%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two practical responses. First, &lt;strong&gt;profile before you map.&lt;/strong&gt; For every field you intend to migrate, run the actual distribution: how many distinct values, how many nulls, the longest value, how many contain HTML, how many contain absolute URLs. Ten minutes of profiling prevents a mapping decision built on an assumption.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;decide explicitly what not to migrate.&lt;/strong&gt; Migration is the one moment when dropping dead weight is cheap. Fifteen years of unused tags, an abandoned events module, a decade of spam comments — carrying them forward means maintaining them forever. Just make the decision consciously and record it, rather than discovering the loss in month three.&lt;/p&gt;

&lt;p&gt;The transformation itself I'd keep as a repeatable, re-runnable script, never a hand-edited dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read source record
  → normalise (trim, fix encoding, resolve relative media paths)
  → map fields to target model
  → validate (required fields present, slug unique, dates sane)
  → write, or write to a rejects file with the reason
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-runnable matters more than it sounds. You will run the migration many times — against a copy, into staging, into a rehearsal, and finally for real. If the process is a script that takes the source and produces the target from scratch, every run is identical and every fix is permanent. If it's a script plus "and then I manually fixed those 40 rows", you have no repeatable process, and the final run will differ from the rehearsal in ways you can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Media is where the disk space and the surprises are
&lt;/h2&gt;

&lt;p&gt;Content rows are small. Media is not, and it's messier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Files referenced in article bodies by absolute URL — these break the moment the domain or path changes and must be rewritten during transformation.&lt;/li&gt;
&lt;li&gt;Orphan files: uploaded, never referenced. Often 30–50% of the media directory.&lt;/li&gt;
&lt;li&gt;Referenced files that don't exist. Someone cleaned up a folder in 2021.&lt;/li&gt;
&lt;li&gt;Generated thumbnail variants in a naming scheme the new system doesn't share.&lt;/li&gt;
&lt;li&gt;Filenames with non-ASCII characters and spaces, which behave differently across filesystems and web servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the migration, produce two lists: &lt;strong&gt;referenced-but-missing&lt;/strong&gt; (fix or accept — these become broken images), and &lt;strong&gt;present-but-unreferenced&lt;/strong&gt; (usually skip). Never regenerate thumbnails on first request under production traffic; pre-generate them during the migration, or your launch-day load test will be an unintentional one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rehearse the cutover, then rehearse it again
&lt;/h2&gt;

&lt;p&gt;The cutover is a sequence of steps under time pressure, which is exactly the condition in which people improvise. So write it down as a runbook with times, owners, and — for each step — the answer to "how do we undo this?"&lt;/p&gt;

&lt;p&gt;A workable shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;T-7 days:&lt;/strong&gt; full migration into staging with production data. Editors do real work in it for a week. This is the only reliable way to surface editorial-workflow problems, and it's the step most often skipped for schedule reasons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-2 days:&lt;/strong&gt; dry run of the entire cutover against staging, stopwatch running. You now know how long the real one takes. It's always longer than the estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-1 day:&lt;/strong&gt; DNS TTL lowered. Content freeze agreed with the newsroom, in writing, with a named person responsible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-0:&lt;/strong&gt; final delta sync (only content created since the main import), switch, smoke tests, redirect crawl.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T+1 hour:&lt;/strong&gt; the checks below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T+7 days:&lt;/strong&gt; daily monitoring of crawl errors and index coverage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick your window by traffic, not by convention.&lt;/strong&gt; For a news site the quiet hour is usually early morning local time — but check the actual analytics, and check the editorial calendar. Cutting over the night before a major scheduled news event is a self-inflicted wound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Define the rollback before you need it.&lt;/strong&gt; What's the trigger (error rate above X, homepage down more than Y minutes)? Who decides? How long does reverting take? A rollback plan that takes four hours isn't a rollback plan; it's a wish. In practice this usually means: keep the old stack running and untouched, switch at the DNS or proxy layer, and don't decommission anything for at least a fortnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in the first hour, and the first month
&lt;/h2&gt;

&lt;p&gt;Immediately after cutover, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Homepage and a sample of article pages return 200 with the right content.&lt;/li&gt;
&lt;li&gt;The redirect crawl passes across the full old-URL inventory.&lt;/li&gt;
&lt;li&gt;Feeds and the sitemap are valid and reachable; submit the new sitemap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; is the production one. (Staging's &lt;code&gt;Disallow: /&lt;/code&gt; reaching production is a classic, and it is genuinely expensive.)&lt;/li&gt;
&lt;li&gt;Canonical tags and structured data render correctly on article pages.&lt;/li&gt;
&lt;li&gt;Search: does querying an old, well-known headline return it?&lt;/li&gt;
&lt;li&gt;Publishing works end to end — an editor publishes a real story with an image while you watch.&lt;/li&gt;
&lt;li&gt;Analytics and any ad slots are firing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then over the following weeks, watch &lt;strong&gt;crawl errors&lt;/strong&gt; (a slow rise in 404s means the URL inventory had gaps), &lt;strong&gt;index coverage&lt;/strong&gt; (a drop means something is deindexing content — check canonicals and robots directives first), &lt;strong&gt;server response time under real traffic&lt;/strong&gt;, and &lt;strong&gt;the numbers that justified the migration in the first place&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Expect a short dip in traffic. A one-to-two week wobble while search engines re-crawl is normal even on a clean migration. A sustained four-week decline is not a wobble; it's a bug, and it's nearly always URLs, canonicals, or robots directives — in that order of likelihood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things I'd tell myself before the first one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Preserve URLs. Everything else is negotiable.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile the real data before mapping it.&lt;/strong&gt; The schema is a claim; the data is the fact.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the migration a re-runnable script, never a manual fix-up.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse with editors, not just with developers.&lt;/strong&gt; They will find in one afternoon what a test suite won't find in a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the old system alive and reversible for two weeks.&lt;/strong&gt; It costs a little hosting and buys a lot of sleep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the thing you migrated for.&lt;/strong&gt; Otherwise the only feedback you'll get is the first complaint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. A CMS migration is mostly the discipline to do unglamorous inventory work before the interesting part, and the honesty to define in advance what "it went wrong" looks like.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've done a large migration, I'm curious what bit you that isn't on this list — the failure modes seem to be endlessly inventive.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cms</category>
      <category>seo</category>
      <category>architecture</category>
    </item>
    <item>
      <title>PHP 8.2 in 2026: Why It's Still the Best Choice for News &amp; E-commerce CMS</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:16:52 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/php-82-in-2026-why-its-still-the-best-choice-for-news-e-commerce-cms-2hb5</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/php-82-in-2026-why-its-still-the-best-choice-for-news-e-commerce-cms-2hb5</guid>
      <description>&lt;p&gt;Every year someone announces that PHP is finished, and every year a large slice of the web keeps running on it — including most of the news portals and online stores I work with. That's not nostalgia. After building content and commerce systems for 200+ production sites, PHP 8.2 keeps winning the specific fight that matters for this domain: &lt;em&gt;shipping a stable, fast, server-rendered site that a small team can maintain for years without a rewrite.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This isn't a "PHP is actually cool now" hype piece. It's the concrete reasons a boring, typed, request-per-page runtime is still the right default for news and e-commerce CMS work in 2026 — and the honest places where it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workload, not the benchmark
&lt;/h2&gt;

&lt;p&gt;News and commerce sites have a very particular shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-heavy, cache-friendly.&lt;/strong&gt; Most visitors are anonymous and see the same article or product page. You want to render HTML on the server, cache it hard, and get out of the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spiky.&lt;/strong&gt; A breaking story or a campaign can multiply traffic in minutes. The runtime has to degrade gracefully, not fall over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-lived.&lt;/strong&gt; These sites run for 5–10 years. The team that maintains them in year 6 is rarely the team that built them in year 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO-critical.&lt;/strong&gt; For news, Google News and fast Largest Contentful Paint aren't nice-to-haves; they're the business.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHP's execution model fits this shape almost embarrassingly well. Each request starts clean, does its work, and dies. No long-lived process accumulating memory leaks, no shared mutable state to reason about across requests. For a page that reads from a database, renders a template, and returns HTML, "shared-nothing" is a feature, not a limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 8.2 actually gave us
&lt;/h2&gt;

&lt;p&gt;The jump from PHP 5.x/7.x thinking to 8.2 changed how these codebases read. A few features carry most of the weight in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;readonly&lt;/code&gt; properties&lt;/strong&gt; made value objects trustworthy. In a CMS you pass a lot of small immutable things around — a resolved article, a price with currency, a category node. Being able to say "this cannot change after construction" at the language level removes a whole category of "who mutated this?" bugs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// minor units (kuruş/cents)&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'TRY', 'USD'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;withVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$ratePercent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$ratePercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No setter, no accidental mutation three layers down, and money math stays in integer minor units where it belongs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enums&lt;/strong&gt; (from 8.1, but they land fully in 8.2 codebases) replaced the pile of &lt;code&gt;const STATUS_DRAFT = 0&lt;/code&gt; integers that every legacy CMS drags around. An article status or an order state becomes a real type the IDE and the type checker understand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Pending&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Paid&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Shipped&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Refunded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'refunded'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isFinal&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Refunded&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Shipped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Constructor promotion + typed properties&lt;/strong&gt; cut the ceremony that made older PHP feel heavy. A service class is now mostly signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleRenderer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;TemplateEngine&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;CacheInterface&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;never&lt;/code&gt; return types, &lt;code&gt;readonly&lt;/code&gt; classes, and stricter type coercion&lt;/strong&gt; together mean the type checker catches far more before code ever reaches staging. On a long-lived codebase, that's the difference between refactoring with confidence and refactoring with prayer.&lt;/p&gt;

&lt;p&gt;None of these are flashy. Collectively they turn PHP from "scripting language you tolerate" into "typed application language that happens to have the best deployment story on the web."&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-rendered HTML is a competitive advantage again
&lt;/h2&gt;

&lt;p&gt;For a few years the default answer to "how do I build the frontend?" was a JavaScript SPA. For a news article or a product page, that was almost always the wrong trade. You paid a bundle-size and complexity tax to re-implement, in the browser, the one thing the server already does perfectly: turn data into HTML.&lt;/p&gt;

&lt;p&gt;PHP renders HTML on the server as its native act. Pair it with a template engine and a sprinkle of hypermedia-style JavaScript for the genuinely interactive bits (filters, infinite scroll, cart updates) and you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First paint that doesn't wait on a JS runtime — good for LCP, good for Google News.&lt;/li&gt;
&lt;li&gt;HTML that's fully present for crawlers and AI scrapers without a headless-render step.&lt;/li&gt;
&lt;li&gt;A frontend a backend developer can maintain, instead of a second full stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The industry rediscovering server-rendered HTML in 2024–2026 was, from a PHP seat, watching everyone walk back to where the language already stood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the spikes: cache, don't scale
&lt;/h2&gt;

&lt;p&gt;The read-heavy, spiky profile has a well-worn answer in PHP land, and 8.2's execution model makes it clean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Full-page cache&lt;/strong&gt; for anonymous traffic. Most visitors on a breaking story are logged out and identical; serve them a cached HTML page and never touch the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opcode + preloading&lt;/strong&gt; so the framework itself isn't re-parsed on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cache layer that switches on under load&lt;/strong&gt; rather than being always-on — normal traffic hits the database for freshness, a spike flips the site into aggressive caching automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because each request is isolated, there's no warm-up state to protect and no cross-request corruption to worry about when you throw a cache in front. The mental model stays simple, which is exactly what you want at 3 a.m. when a story goes viral.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance argument nobody puts on slides
&lt;/h2&gt;

&lt;p&gt;The feature that keeps me choosing PHP 8.2 for client work isn't in any release note: &lt;strong&gt;you can hand the codebase to a different developer in year 4 and they can be productive in a week.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The request lifecycle is obvious. Request in, response out.&lt;/li&gt;
&lt;li&gt;Deployment is &lt;code&gt;rsync&lt;/code&gt; and a cache flush, not an orchestration diagram.&lt;/li&gt;
&lt;li&gt;The hosting is everywhere and cheap, which matters enormously for regional news sites and small stores.&lt;/li&gt;
&lt;li&gt;The type system now documents intent well enough that a newcomer can read a service class and know what it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For software that has to &lt;em&gt;outlive its authors&lt;/em&gt;, that boringness is the whole point. A clever runtime that only its original author understands is a liability on a 10-year site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where PHP 8.2 is &lt;em&gt;not&lt;/em&gt; the answer
&lt;/h2&gt;

&lt;p&gt;Being honest keeps this credible. I don't reach for PHP when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The workload is long-lived and stateful&lt;/strong&gt; — a websocket server, a real-time collaboration backend, a streaming pipeline. Shared-nothing-per-request is the wrong shape there; that's a job for a long-running process in another runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The product is fundamentally a rich client app&lt;/strong&gt; — a design tool, an editor with heavy live interaction. That genuinely wants a real frontend stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need CPU-bound number crunching.&lt;/strong&gt; PHP will do it; it won't be the tool you're happy with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;News and e-commerce CMS work is none of those. It's data-in, HTML-out, cache-heavy, maintained-for-years. That's PHP's home field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 verdict
&lt;/h2&gt;

&lt;p&gt;Language choice is a maintenance decision disguised as a technical one. For content and commerce systems, the constraints that actually bite are: fast server-rendered pages, graceful behavior under spikes, cheap ubiquitous hosting, and a codebase a small team can still understand years later. PHP 8.2 — typed, &lt;code&gt;readonly&lt;/code&gt;, enum-shaped, opcode-cached — hits every one of those without asking you to be clever.&lt;/p&gt;

&lt;p&gt;That's why the sites I build for news and e-commerce still start from PHP 8.2, and why I expect them to still be running, and still be maintainable, long after the next "PHP is dead" post. If you want to see what a modern PHP news and e-commerce stack looks like in production, that's the whole business at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pick the runtime you can still afford to maintain in year six. For this domain, that's still PHP.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building Newsroom AI Modules in PHP: 50+ Specialized Workflows</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Fri, 26 Jun 2026 08:50:23 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/building-newsroom-ai-modules-in-php-50-specialized-workflows-2co1</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/building-newsroom-ai-modules-in-php-50-specialized-workflows-2co1</guid>
      <description>&lt;p&gt;When people picture "AI in a newsroom," they usually imagine one big chat box bolted onto the editor. That's the wrong mental model, and it's the reason most of those features get used twice and then ignored.&lt;/p&gt;

&lt;p&gt;A newsroom doesn't have &lt;em&gt;one&lt;/em&gt; AI need. It has dozens of small, specific, repetitive ones: write three headline options, pull a 280-character social blurb, suggest a category, tag entities, generate alt text for the lead image, flag a defamation risk, translate the dek into English, propose an SEO title under 60 characters. Each is a tiny job with its own input shape, its own output contract, and its own tolerance for being wrong.&lt;/p&gt;

&lt;p&gt;After running this across 200+ news sites, the architecture that actually stuck was not "an AI feature." It was a &lt;strong&gt;registry of small, specialized workflows&lt;/strong&gt; behind a uniform interface. This article is how that's built in PHP, and the handful of decisions that matter more than which model you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit of work: a task, not a prompt
&lt;/h2&gt;

&lt;p&gt;The first thing to get right is the boundary. The reusable unit is a &lt;strong&gt;task&lt;/strong&gt; — a named workflow with a fixed contract — not a free-floating prompt string. A task knows three things: what it needs, what it promises to return, and how expensive it's allowed to be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AiTask&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 'headline.suggest'&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 'cheap' | 'standard' | 'premium'&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// -&amp;gt; messages for the model&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// -&amp;gt; validated structured output&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;parse()&lt;/code&gt; method is the part teams skip and regret. A model that returns prose when you expected JSON is not an edge case — it's Tuesday. If every task is responsible for validating its own output, a bad response fails &lt;em&gt;inside the task&lt;/em&gt; where you can retry or fall back, instead of leaking malformed data into the editor.&lt;/p&gt;

&lt;p&gt;A concrete task looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HeadlineSuggestTask&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AiTask&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'headline.suggest'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
                &lt;span class="s1"&gt;'You are a Turkish news copy editor. Return exactly 3 headline '&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'options as a JSON array of strings. Max 70 characters each. '&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'No clickbait, no ALL CAPS.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;mb_substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stripFences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadOutputException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'headline.suggest: not a list'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'strval'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have one of these, you have the shape for fifty. The interesting work isn't writing the fiftieth task — it's the machinery around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "50+" is an architecture choice, not a brag
&lt;/h2&gt;

&lt;p&gt;The number isn't the point; the &lt;em&gt;granularity&lt;/em&gt; is. You could collapse "suggest headline," "suggest SEO title," and "suggest social blurb" into one mega-prompt that returns all three. Don't. Three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different cost tiers.&lt;/strong&gt; A category suggestion can run on a fast, cheap model. A legal-risk flag should not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different failure handling.&lt;/strong&gt; If headline generation fails, you shrug and the editor types one. If entity tagging fails silently, your archive search quietly rots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different UX surfaces.&lt;/strong&gt; The blurb belongs to the social scheduler; the alt text belongs to the image picker. Coupling them in one call couples two unrelated screens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small tasks compose. Big prompts calcify.&lt;/p&gt;

&lt;p&gt;Here's the rough taxonomy that emerged — grouped, because grouping is how editors actually find them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Example tasks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Headlines &amp;amp; framing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;headline options, SEO title, social blurb, push-notification text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Structure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;summary/dek, key-points list, "read more" suggestions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Classification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;category suggest, tag/entity extraction, topic clustering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Media&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;image alt text, caption draft, thumbnail crop hint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Quality &amp;amp; risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;tone check, defamation/risk flag, fact-claim highlighter, profanity filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEO &amp;amp; distribution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;meta description, schema keywords, related-article linking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;translate dek, simplify, localize idiom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's already 25+ before you count per-language and per-section variants. The registry is what keeps it from becoming chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry and the router
&lt;/h2&gt;

&lt;p&gt;The registry is boring on purpose: a name-to-task map. The router is where the one genuinely valuable idea lives — &lt;strong&gt;routing by tier, not by vibes.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AiRouter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @param array&amp;lt;string, AiTask&amp;gt; $tasks */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ModelGateway&lt;/span&gt; &lt;span class="nv"&gt;$gateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;AiCache&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnknownTaskException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$raw&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BadOutputException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// one retry on the next tier up before giving up&lt;/span&gt;
            &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;escalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are doing real work here and each earns its place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Caching by (task, input) hash.&lt;/strong&gt; The same article body gets a headline suggestion once. Editors click these buttons repeatedly; without a cache you pay for every nervous re-click. This single layer was the biggest cost reduction we measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier-based escalation on bad output.&lt;/strong&gt; Cheap model first. If it returns garbage that fails &lt;code&gt;parse()&lt;/code&gt;, retry once on a stronger tier. Most cheap-model failures are formatting failures, and they don't repeat on the better model. You get cheap-model economics with premium-model reliability on the tail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway hides the provider.&lt;/strong&gt; &lt;code&gt;complete(tier, messages)&lt;/code&gt; is the entire surface the task sees. Whether &lt;code&gt;'cheap'&lt;/code&gt; maps to one provider this month and another next month is an ops decision, not a code change in 50 tasks.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The gateway: one seam for every provider
&lt;/h2&gt;

&lt;p&gt;The gateway is what makes provider diversity survivable. News work is bursty and rate limits are real, so you want the freedom to move a tier between providers without touching task code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ModelGateway&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tierConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tierConfig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$tier&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;          &lt;span class="c1"&gt;// provider + model + limits&lt;/span&gt;
        &lt;span class="nv"&gt;$provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProviderFactory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;maxTokens&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max_tokens'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff is operational, not architectural elegance for its own sake. When a provider degrades at 9 a.m. on an election morning — and it will — you change a config map, not a deployment. Keeping the model identity &lt;em&gt;out&lt;/em&gt; of the task and &lt;em&gt;in&lt;/em&gt; the tier config is the difference between a five-minute mitigation and a panic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality gates: cheap models lie confidently
&lt;/h2&gt;

&lt;p&gt;A specialized-task design tempts you toward cheap models everywhere, because each job is small. The trap is that small jobs still produce confidently wrong output. The defense is &lt;strong&gt;deterministic gates around non-deterministic output&lt;/strong&gt; — code, not another model, checks the boring constraints.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;validateHeadlines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$headlines&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$headlines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// length contract&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;mb_strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no ALL CAPS&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/!{2,}|\?{2,}/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no "!!!"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there's no model in that function. The expensive judgment ("is this a &lt;em&gt;good&lt;/em&gt; headline?") stays with the human editor. The cheap, mechanical judgment ("is this even a valid headline-shaped string?") is plain PHP that runs in microseconds and never hallucinates. Push every constraint you can express as code &lt;em&gt;out&lt;/em&gt; of the prompt and &lt;em&gt;into&lt;/em&gt; a gate. Prompts are for taste; code is for rules.&lt;/p&gt;

&lt;p&gt;The one place to spend a premium model deliberately is &lt;strong&gt;risk&lt;/strong&gt; — defamation, sensitive claims, anything where a wrong call has legal weight. That task should run on your strongest tier, never cache its "looks fine" verdict for long, and always present as &lt;em&gt;advisory&lt;/em&gt; to a human. AI flags; people decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asynchronous by default
&lt;/h2&gt;

&lt;p&gt;Editors will not wait four seconds for a button. Anything slower than roughly a second belongs in the background, with the result arriving when it's ready rather than blocking the save.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On save: enqueue, don't block.&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai.enrich'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'article_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tasks'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'summary.make'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tag.extract'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'category.suggest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'seo.meta'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// A worker drains the queue and writes suggestions back as drafts&lt;/span&gt;
&lt;span class="c1"&gt;// the editor can accept or ignore — never auto-published.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules that saved us real pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suggestions are drafts, never silent writes.&lt;/strong&gt; AI output lands in a "suggested" state. A human accepts it. The day you let a model write directly to the published field is the day you explain a hallucinated dateline to your editor-in-chief.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent jobs.&lt;/strong&gt; Queues retry. If &lt;code&gt;summary.make&lt;/code&gt; runs twice, the second run should overwrite the same suggestion slot, not create a duplicate. Key the write by &lt;code&gt;(article_id, task_name)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model the task contract first, the prompt second.&lt;/strong&gt; The interface outlives any specific model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate output inside the task.&lt;/strong&gt; Malformed responses are normal; treat them as control flow, not exceptions to your worldview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route by tier, cache by input.&lt;/strong&gt; These two together did more for cost and reliability than any prompt-engineering cleverness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep rules in code, taste in prompts.&lt;/strong&gt; Every constraint you can check deterministically is one the model can't violate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async, advisory, idempotent.&lt;/strong&gt; The newsroom trusts a tool that suggests and never surprises.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "50+" isn't a feature count to put on a slide. It's what falls out naturally once each AI job is small enough to have a single clear contract. Build the seam — task, registry, router, gateway, gate — and adding the fifty-first workflow is an afternoon, not a project.&lt;/p&gt;

&lt;p&gt;We've been refining this pattern in production news software at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt;, across publishers of very different sizes, and the lesson keeps repeating: the architecture, not the model, is what makes newsroom AI feel reliable instead of gimmicky.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>php</category>
      <category>news</category>
      <category>architecture</category>
    </item>
    <item>
      <title>KVKK, İYS, BİK: Turkish Software Compliance for Engineers (with PHP examples)</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 21 Jun 2026 12:45:03 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/kvkk-iys-bik-turkish-software-compliance-for-engineers-with-php-examples-1ibo</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/kvkk-iys-bik-turkish-software-compliance-for-engineers-with-php-examples-1ibo</guid>
      <description>&lt;p&gt;If you build software that serves Turkish users, three acronyms will eventually land on your desk: &lt;strong&gt;KVKK&lt;/strong&gt;, &lt;strong&gt;İYS&lt;/strong&gt;, and &lt;strong&gt;BİK&lt;/strong&gt;. They are not optional "nice to have" features — they are legal obligations with real fines attached. Yet most engineering write-ups about them are written by lawyers, for lawyers, and stop exactly where the interesting part begins: the code.&lt;/p&gt;

&lt;p&gt;This is the article I wish I had when we first had to make 200+ production sites compliant. It's the engineer's view — what each rule actually requires from your application, and how to implement it cleanly in PHP. The examples are intentionally generic; adapt the storage and framework details to your own stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three you'll meet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acronym&lt;/th&gt;
&lt;th&gt;Full name&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;th&gt;Who enforces it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KVKK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kişisel Verilerin Korunması Kanunu&lt;/td&gt;
&lt;td&gt;Personal data protection (Turkey's GDPR analogue)&lt;/td&gt;
&lt;td&gt;KVKK Authority (KVKK Kurumu)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;İYS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;İleti Yönetim Sistemi&lt;/td&gt;
&lt;td&gt;Commercial electronic messages (SMS/email/calls)&lt;/td&gt;
&lt;td&gt;Managed via a central national registry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BİK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basın İlan Kurumu&lt;/td&gt;
&lt;td&gt;Press/news site requirements &amp;amp; official announcements&lt;/td&gt;
&lt;td&gt;Basın İlan Kurumu (for news publishers)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;KVKK touches almost every app. İYS hits you the moment you send a marketing message. BİK is specific to news publishers. Let's take them in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. KVKK — personal data protection
&lt;/h2&gt;

&lt;p&gt;KVKK shares its DNA with GDPR, so if you've done GDPR work the concepts will feel familiar: lawful basis, explicit consent, data minimization, the right to erasure, breach notification, and registration with a central inventory (VERBİS) once you cross certain thresholds.&lt;/p&gt;

&lt;p&gt;The mistakes I see most often are not legal misreadings — they're engineering shortcuts. Three of them matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate consent from the action
&lt;/h3&gt;

&lt;p&gt;A checkbox that says "I accept the terms &lt;strong&gt;and&lt;/strong&gt; consent to marketing" bundles two different lawful bases. KVKK wants &lt;strong&gt;explicit, specific, unbundled&lt;/strong&gt; consent. In practice that means storing each consent as its own record with enough metadata to prove it later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each consent is its own row — never a single boolean on the user.&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;recordConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$granted&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'INSERT INTO consent_log
            (user_id, purpose, granted, ip, user_agent, created_at)
         VALUES (:uid, :purpose, :granted, :ip, :ua, :now)'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'uid'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'purpose'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// e.g. 'marketing_email', 'analytics'&lt;/span&gt;
        &lt;span class="s1"&gt;'granted'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$granted&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'ip'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'REMOTE_ADDR'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'ua'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'HTTP_USER_AGENT'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'now'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i:s'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of the &lt;code&gt;ip&lt;/code&gt;, &lt;code&gt;user_agent&lt;/code&gt;, and timestamp is not surveillance — it's that when someone asks "did this user actually consent, and when?", you can answer with evidence instead of a shrug. Consent is also revocable, so you append a new &lt;code&gt;granted = 0&lt;/code&gt; row rather than mutating the old one. The history is the proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cookie banner has to actually block scripts
&lt;/h3&gt;

&lt;p&gt;A banner that loads Google Analytics and the Meta pixel &lt;em&gt;before&lt;/em&gt; the user clicks "accept" is theatre. Under KVKK (as under GDPR) non-essential trackers must not fire until consent exists. The cleanest implementation is to gate the script tags server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;trackingScripts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'analytics'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// nothing loads, no third-party calls&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;script src="/js/analytics.js" defer&amp;gt;&amp;lt;/script&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your analytics is purely first-party and aggregated (no cross-site identifiers, no PII), you have a much easier compliance story — which is one practical reason a lot of Turkish products lean toward self-hosted, first-party analytics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Erasure means erasure (but keep the legal minimum)
&lt;/h3&gt;

&lt;p&gt;The right to be forgotten collides with other obligations: you may be legally required to keep invoice and transaction records for years. The resolution is &lt;strong&gt;anonymization&lt;/strong&gt;, not blind deletion. Strip the identifying fields, keep the financially/legally required skeleton.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;eraseUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Anonymize, don't orphan: invoices must survive for tax law.&lt;/span&gt;
    &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"UPDATE users SET
            name='[deleted]', email=CONCAT('deleted_', id, '@invalid'),
            phone=NULL, address=NULL, anonymized_at=:now
         WHERE id=:id"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i:s'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Hard-delete things with no retention requirement.&lt;/span&gt;
    &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DELETE FROM consent_log WHERE user_id=:id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decide your retention periods deliberately, document them, and make them queryable. "We delete logs after N days" is a sentence you want to back with a cron job, not a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. İYS — the commercial message registry
&lt;/h2&gt;

&lt;p&gt;İYS is the part that surprises foreign engineers. In Turkey there is a &lt;strong&gt;central national registry&lt;/strong&gt; for commercial electronic message consent. Before you send a marketing SMS, email, or call to someone, their consent must exist in that registry — and your own database agreeing isn't enough on its own.&lt;/p&gt;

&lt;p&gt;The engineering consequence: &lt;strong&gt;never send marketing straight from your app's consent table.&lt;/strong&gt; You check İYS first. Transactional messages (order confirmations, password resets, shipping updates) are exempt — but the line between "transactional" and "marketing" is exactly where people get fined, so be conservative.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;canSendMarketing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 'channel' is one of: MESAJ (SMS), EPOSTA (email), ARAMA (call)&lt;/span&gt;
    &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;iysLookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// calls the registry/integrator API&lt;/span&gt;

    &lt;span class="c1"&gt;// Only an explicit, active opt-in counts.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'ONAY'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// vs 'RET' (rejected) or 'ALICI_YOK' (unknown)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendCampaign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$recipients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recipients&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;canSendMarketing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;logSkipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no_iys_consent'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// skipping is cheaper than a fine&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes from running this at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sync both directions.&lt;/strong&gt; When a user opts in or out in your UI, push that change up to the registry. When the registry changes (a user opts out there), pull it down. A nightly reconciliation job catches drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache lookups, but not forever.&lt;/strong&gt; Hammering the lookup API per-recipient on a large campaign is slow and rude. A short-TTL cache (hours, not weeks) keeps you fast without letting stale "ONAY" values send messages to someone who opted out this morning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. BİK — for news publishers
&lt;/h2&gt;

&lt;p&gt;If you don't run a news site, skip this section. If you do, BİK compliance is what stands between you and being eligible for official announcements (resmî ilan) and the credibility that comes with it.&lt;/p&gt;

&lt;p&gt;BİK's requirements are more editorial than algorithmic, but several do translate into application features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mandatory imprint (künye):&lt;/strong&gt; publisher identity, responsible editor, contact, and address must be present and reachable. This is a structured page, not a footer afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source attribution (mahreç):&lt;/strong&gt; agency-sourced content must be labelled with its origin. If you ingest from news agencies, carry the source field through your pipeline to the rendered article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content cadence &amp;amp; originality:&lt;/strong&gt; a minimum flow of original editorial content, which in practice means your CMS needs solid authorship, scheduling, and audit metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Archive integrity:&lt;/strong&gt; published pieces should remain accessible and stable at their URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The imprint and source-attribution pieces are the ones that bite teams late, because they're easy to bolt on at the end and easy to get subtly wrong. Model them as first-class fields from the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Carry the source through to render — don't lose it in the import step.&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;renderArticleMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;div class="article-meta"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;span class="author"&amp;gt;'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author_name'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/span&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'source_agency'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;span class="source"&amp;gt;Kaynak: '&lt;/span&gt;
              &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'source_agency'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/span&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// mahreç&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;time datetime="'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'"&amp;gt;'&lt;/span&gt;
          &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatTr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/time&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/div&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of requirement that pushed our news CMS work toward treating source, author, and publish metadata as non-negotiable columns rather than optional extras — it's far cheaper than retrofitting attribution across an archive later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can paste into a ticket
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; consent stored per-purpose, with timestamp + evidence, revocable as an append&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; non-essential trackers gated server-side, fire only after consent&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; erasure = anonymize-and-retain-legal-minimum, with documented retention periods&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; VERBİS registration checked against your data-controller thresholds&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;İYS:&lt;/strong&gt; registry lookup before every marketing send; transactional messages clearly separated&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;İYS:&lt;/strong&gt; two-way sync (UI ⇄ registry) + nightly reconciliation + short-TTL cache&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;BİK:&lt;/strong&gt; structured imprint (künye) page, reachable&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;BİK:&lt;/strong&gt; source attribution (mahreç) carried end-to-end from import to render&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;BİK:&lt;/strong&gt; stable article URLs and accessible archive&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;None of this is exotic engineering. It's mostly the discipline of modeling consent and provenance as data you can prove, not state you assume. Do it at the schema level on day one and compliance becomes a property of the system. Bolt it on at the end and it becomes a migration you'll dread.&lt;/p&gt;

&lt;p&gt;We've shipped this pattern across 200+ Turkish production sites at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt;, and the single biggest lesson is the boring one: &lt;strong&gt;make consent and source first-class data, and the legal requirements mostly take care of themselves.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is general engineering guidance, not legal advice — confirm specifics for your situation with a qualified professional.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>legal</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How We Reduced LLM Costs by 95%: Cache + Batch + Cascade in PHP</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sat, 13 Jun 2026 11:10:36 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/how-we-reduced-llm-costs-by-95-cache-batch-cascade-in-php-1ok6</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/how-we-reduced-llm-costs-by-95-cache-batch-cascade-in-php-1ok6</guid>
      <description>&lt;h1&gt;
  
  
  How We Reduced LLM Costs by 95%: Cache + Batch + Cascade in PHP
&lt;/h1&gt;

&lt;p&gt;We build news software — a content platform used by more than 200 publishers at Alesta WEB. Once we wired language models into the newsroom workflow (headline suggestions, summaries, SEO fields, tag extraction, draft scaffolding), something predictable happened: the AI bill started growing faster than the feature list.&lt;/p&gt;

&lt;p&gt;The naive version of "add AI" is a thin wrapper around one expensive frontier model, called fresh on every request. It works in a demo. In production, across thousands of articles a day, it's a slow way to set money on fire.&lt;/p&gt;

&lt;p&gt;This is the architecture we settled on after eighteen months of running it. Three layers — &lt;strong&gt;cache&lt;/strong&gt;, &lt;strong&gt;batch&lt;/strong&gt;, &lt;strong&gt;cascade&lt;/strong&gt; — plus the quality gates that make the cheap layers safe to rely on. The result was roughly a 95% reduction in per-task cost versus the naive "frontier-model-only, no cache" baseline, with no measurable drop in editorial quality.&lt;/p&gt;

&lt;p&gt;The code is PHP, because the platform is PHP. The ideas are language-agnostic.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Naive Approach (and What It Costs)
&lt;/h2&gt;

&lt;p&gt;Here's the version almost everyone ships first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generateHeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$articleBody&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'You write concise news headlines.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$articleBody&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'choices'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with this code. The problem is the &lt;em&gt;usage pattern&lt;/em&gt; around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same wire-service story gets posted by dozens of sites, so we generate near-identical headlines over and over.&lt;/li&gt;
&lt;li&gt;Editors regenerate three or four times to compare options.&lt;/li&gt;
&lt;li&gt;A frontier model is doing work — extracting tags, normalizing a category — that a model costing a fraction as much would do just as well.&lt;/li&gt;
&lt;li&gt;Every call is synchronous and real-time, even when nothing about the task is urgent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you multiply "fresh frontier call every time" by real newsroom volume, the per-article AI cost lands somewhere that makes the CFO ask uncomfortable questions. Each of the three layers below attacks one of those waste sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Layer 1 — Cache: What to Cache, and What Not To
&lt;/h2&gt;

&lt;p&gt;The single biggest win is the most boring one: &lt;strong&gt;don't ask the same question twice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A large share of LLM calls in a news system are functionally identical. The key insight is that the cache key should be derived from the &lt;em&gt;meaningful&lt;/em&gt; inputs — the task type, the model, the prompt template version, and a normalized hash of the content — not from the raw request object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cachedComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$compute&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'llm:%s:%s:%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;PROMPT_VERSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// bump to invalidate on prompt change&lt;/span&gt;
        &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xxh128'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hit&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// ~0 cost, sub-millisecond&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// the actual API call&lt;/span&gt;
    &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ttlFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// collapse whitespace, strip volatile boilerplate so trivially&lt;/span&gt;
    &lt;span class="c1"&gt;// different inputs map to the same key&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\s+/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the cache engine you pick (we use Redis, but a database table works):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version the prompt in the key.&lt;/strong&gt; When you change a prompt template, you &lt;em&gt;want&lt;/em&gt; every cached answer for that task to become a miss. Putting a &lt;code&gt;PROMPT_VERSION&lt;/code&gt; constant into the key turns prompt edits into a clean, instant invalidation instead of a stale-output bug you chase for a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know what not to cache.&lt;/strong&gt; Anything personalized, anything real-time, anything where two identical inputs should legitimately produce different outputs (a "give me a fresh alternative" button) must bypass the cache. We mark those tasks explicitly rather than relying on TTL alone.&lt;/p&gt;

&lt;p&gt;In our workload the cache hit rate sits around 60–70%, mostly because syndicated content repeats across sites. That one layer alone removes well over half the spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Layer 2 — Batch APIs: Trade Latency for Money
&lt;/h2&gt;

&lt;p&gt;A surprising amount of LLM work in a newsroom is &lt;strong&gt;not time-sensitive.&lt;/strong&gt; Nightly re-tagging of the archive. Generating summaries for the previous day's articles. Backfilling SEO descriptions on older content. None of it needs an answer in 800 milliseconds.&lt;/p&gt;

&lt;p&gt;The major providers offer batch endpoints that run asynchronously — you submit a file of requests, and within a window (typically up to 24 hours) you get the results back, at roughly half the price of the synchronous API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Collect non-urgent jobs into a single batch submission&lt;/span&gt;
&lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pendingJobs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lines&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'custom_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'method'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'url'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/v1/chat/completions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'model'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$batchFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uploadJsonl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lines&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$batch&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'input_file_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$batchFile&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'endpoint'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/v1/chat/completions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'completion_window'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'24h'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// A worker polls for completion and writes results back by custom_id&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'poll_batch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'batch_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The discipline this forces is healthy: you have to classify each task as &lt;em&gt;interactive&lt;/em&gt; (editor is waiting) or &lt;em&gt;deferred&lt;/em&gt; (a cron job can handle it tonight). Once we did that audit, far more work turned out to be deferrable than we expected. Roughly a quarter of our remaining spend — after caching — moved onto batch pricing for a flat ~50% discount on that slice.&lt;/p&gt;

&lt;p&gt;A caveat: batch pricing differs by provider, and so does the completion window and the failure behavior. Build your batch layer behind an interface so the provider is swappable, and always handle partial failures — a batch of 5,000 requests will occasionally return 4,997.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Layer 3 — Cascade Routing: Match the Model to the Task
&lt;/h2&gt;

&lt;p&gt;The last layer is the one people resist, because it feels like cutting corners. It isn't — it's refusing to pay frontier prices for kindergarten work.&lt;/p&gt;

&lt;p&gt;Not every task needs the smartest model. Extracting tags from a story, mapping a category, cleaning up whitespace, classifying sentiment — small, cheap models handle these perfectly. Reserve the expensive model for genuinely hard generation: nuanced summaries, editorial rewriting, anything where a mistake is visible to readers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;TASK_TIER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'tag_extraction'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'category_map'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'sentiment'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'summary'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'headline'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'editorial_rewrite'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'frontier'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;TIER_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'cheap'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'mid'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'frontier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'the-strongest-model-you-trust'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;modelFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;TIER_MODEL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;TASK_TIER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'mid'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We run six providers behind one interface (the editorial team never sees which one answered), which means cascade routing can also fail over: if a cheap model's output fails a quality gate, the task is automatically re-run one tier up. That gives you the cost of the cheap tier on the 90%+ of cases it handles well, and the safety of the expensive tier on the cases it doesn't.&lt;/p&gt;

&lt;p&gt;Cascade routing is what takes you from "big savings" to "almost free for the easy majority."&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Quality Gates: Keeping Cheap Models Honest
&lt;/h2&gt;

&lt;p&gt;Cascade routing only works if you can &lt;em&gt;detect&lt;/em&gt; when a cheap model got it wrong — otherwise you're trading money for garbage. Quality gates are cheap, deterministic checks that run on the output before it's accepted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;passesGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'headline'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
                      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;looksTruncated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

        &lt;span class="s1"&gt;'summary'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
                      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;sentenceCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="s1"&gt;'tag_extraction'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;isValidJsonArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

        &lt;span class="k"&gt;default&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these call an LLM. They're string length, format validity, structure checks — the kind of thing that costs nothing and catches the most common cheap-model failures (truncation, wrong format, empty output). When a gate fails, the cascade re-runs the task one tier up and logs it. If a particular task fails its gate too often, that's your signal to move it up a tier permanently.&lt;/p&gt;

&lt;p&gt;This is the piece that makes the whole architecture trustworthy. Without gates, "use a cheaper model" is a gamble. With gates, it's a measured decision with an automatic safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A Cost Dashboard You Actually Look At
&lt;/h2&gt;

&lt;p&gt;You can't optimize what you don't measure. We log every LLM call with four fields: task, tier, whether it was a cache hit, and the token counts. That's enough to answer the only questions that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which task is costing the most? (Usually the surprise here is a "cheap" task being called a million times.)&lt;/li&gt;
&lt;li&gt;What's our real cache hit rate, per task?&lt;/li&gt;
&lt;li&gt;How often is the cascade escalating — and which tasks?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A weekly rollup of per-task economics turns cost control from a panic ("the bill doubled!") into a routine ("tag extraction escalation rate crept up, the prompt drifted, fix the template"). The dashboard is boring on purpose. Boring means in control.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The Numbers, After Eighteen Months
&lt;/h2&gt;

&lt;p&gt;Against the naive baseline (one frontier model, every call fresh and synchronous), the layers compound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache&lt;/strong&gt; removes ~60–70% of calls outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch&lt;/strong&gt; takes ~50% off a meaningful slice of what remains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cascade&lt;/strong&gt; routes the easy majority of the rest to models costing a fraction of frontier prices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stacked, that lands at roughly &lt;strong&gt;95% lower per-task cost&lt;/strong&gt; for the same workload — and, because cache hits are instant and cheap models are fast, the &lt;em&gt;median&lt;/em&gt; latency for AI features actually improved. Cheaper and faster, which is not the trade-off people expect when they hear "we cut the AI budget."&lt;/p&gt;

&lt;p&gt;The editorial quality held because the expensive model still does all the work that's actually hard; we just stopped paying it to do the easy work.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What's Next: Prompt Caching
&lt;/h2&gt;

&lt;p&gt;The newest lever we're rolling out is provider-side &lt;strong&gt;prompt caching&lt;/strong&gt; — where a long, stable system prompt (style guide, formatting rules, examples) is cached on the provider's side and billed at a steep discount on repeat calls. For a news system with a large, rarely-changing editorial style prompt prepended to thousands of calls, that's a natural fit on top of the three layers above.&lt;/p&gt;

&lt;p&gt;The throughline across all of it is the same: &lt;strong&gt;a language model is a power tool, not a default.&lt;/strong&gt; Cache the repeats, defer what isn't urgent, route easy work to cheap models, and verify cheap output with checks that cost nothing. Do that, and AI features stop being a line item that scares the finance team and go back to being what they should be — a feature.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We build news software used by 200+ publishers — agency integration, AI-assisted editorial workflows, native mobile apps, and subscription infrastructure. More on the platform at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>php</category>
      <category>performance</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
