<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged php</title>
<link>https://coderlegion.com/tag/php</link>
<description>Powered by Question2Answer</description>
<item>
<title>PHP2Shell: When a Simple Upload Feature Turns Into Remote Code Execution</title>
<link>https://coderlegion.com/33222/php2shell-when-a-simple-upload-feature-turns-into-remote-code-execution</link>
<description>&lt;p&gt;&lt;em&gt;Exploring CVE-2026-109618 and CVE-2026-109619 through a TryHackMe lab.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;October is Cybersecurity Awareness Month, and honestly, this month has been quite interesting from a cybersecurity perspective. There have been plenty of vulnerabilities, attacks and security incidents worth paying attention to.&lt;/p&gt;
&lt;p&gt;But while going through TryHackMe, something particularly caught my attention: &lt;strong&gt;PHP2Shell&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;At first, I was interested in the upload exploitation technique. I mean, how does something as ordinary as a file upload eventually lead to remote code execution?&lt;/p&gt;
&lt;p&gt;That question made me curious enough to investigate further.&lt;/p&gt;
&lt;p&gt;On October 21, 2026, two critical vulnerabilities were disclosed in PHP: &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt;. Together, they demonstrated how an unauthenticated attacker could potentially execute operating system commands on a vulnerable PHP server through specially crafted HTTP requests.&lt;/p&gt;
&lt;p&gt;And here's the part I found most interesting:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The application itself didn't even need to be vulnerable.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The weakness existed inside PHP's runtime, meaning that even an application with perfectly ordinary PHP code could potentially be affected.&lt;/p&gt;
&lt;p&gt;So, I decided to explore the vulnerability through a TryHackMe lab and understand what was actually happening behind the scenes.&lt;/p&gt;
&lt;p&gt;Let's break it down.&lt;/p&gt;
&lt;h3 id=&quot;1-first-what-exactly-is-php2shell-&quot;&gt;1. First, what exactly is PHP2Shell?&lt;/h3&gt;
&lt;p&gt;PHP2Shell is the name given to a pair of vulnerabilities affecting the PHP runtime.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;CVE &lt;/th&gt;&lt;th&gt;Description&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;CVE-2026-109618 &lt;/td&gt;&lt;td&gt;Remote code execution via out-of-memory (OOM) handling and request-state corruption&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CVE-2026-109619 &lt;/td&gt;&lt;td&gt;Remote code execution via stale global state and use-after-free&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Both vulnerabilities received a CVSS v4.0 score of 9.2 (Critical).&lt;/p&gt;
&lt;p&gt;The vulnerabilities involve PHP's internal request-processing and memory-management mechanisms.&lt;/p&gt;
&lt;p&gt;What makes this particularly concerning is that the vulnerable code isn't necessarily part of the application developers write. It exists deeper inside the PHP engine.&lt;/p&gt;
&lt;p&gt;So, updating your WordPress installation or fixing your Laravel application wouldn't automatically resolve the underlying problem.&lt;/p&gt;
&lt;p&gt;To understand why, I first needed to understand the PHP feature involved in the exploitation.&lt;/p&gt;
&lt;h3 id=&quot;2-the-interesting-part-php-upload-progress&quot;&gt;2. The interesting part: PHP Upload Progress&lt;/h3&gt;
&lt;p&gt;The TryHackMe exploitation scenario explores PHP's &lt;code&gt;session.upload_progress&lt;/code&gt; functionality as part of the attack path.&lt;/p&gt;
&lt;p&gt;PHP provides this feature to track file uploads.&lt;/p&gt;
&lt;p&gt;Imagine uploading a large file through a website. You might see a progress indicator showing how much of the file has been uploaded.&lt;/p&gt;
&lt;p&gt;PHP can help provide this information by tracking details such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Uploaded file name.&lt;/li&gt;
&lt;li&gt;Total upload size.&lt;/li&gt;
&lt;li&gt;Amount of data received.&lt;/li&gt;
&lt;li&gt;Current upload progress.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The relevant configuration directive is:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;session.upload_progress.enabled = On&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;And the default form field responsible for activating this functionality is:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Now, here's where things get interesting.&lt;/p&gt;
&lt;p&gt;PHP processes this upload-progress information while parsing an incoming multipart/form-data request.&lt;/p&gt;
&lt;p&gt;That happens before the application script actually starts executing.&lt;/p&gt;
&lt;p&gt;In simpler terms, PHP is already processing the upload before the website's own PHP code gets control.&lt;/p&gt;
&lt;p&gt;This means the relevant request-processing functionality can be reached without the target page necessarily implementing its own file-upload feature.&lt;/p&gt;
&lt;p&gt;I found this particularly interesting because it changes how we think about the attack surface.&lt;/p&gt;
&lt;p&gt;We normally think about vulnerabilities in terms of application code, authentication systems and user input validation.&lt;/p&gt;
&lt;h3 id=&quot;3-understanding-the-actual-memory-management-bug&quot;&gt;3. Understanding the actual memory-management bug&lt;/h3&gt;
&lt;p&gt;This was probably the part that required the most attention from me.&lt;/p&gt;
&lt;p&gt;To understand PHP2Shell, I needed to understand what happens when PHP runs out of memory.&lt;/p&gt;
&lt;p&gt;PHP has a configuration directive called &lt;code&gt;memory_limit&lt;/code&gt;, which restricts how much memory a request can consume.&lt;/p&gt;
&lt;p&gt;When a request exceeds that limit, PHP can trigger something called a bailout.&lt;/p&gt;
&lt;p&gt;Internally, the engine uses &lt;code&gt;longjmp()&lt;/code&gt; to escape the current execution path after certain fatal errors.&lt;/p&gt;
&lt;p&gt;Normally, software should clean up its resources properly when an operation finishes.&lt;/p&gt;
&lt;p&gt;However, an emergency bailout can interrupt normal request processing and leave internal state in an incomplete condition.&lt;/p&gt;
&lt;p&gt;This is where PHP2Shell becomes interesting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CVE-2026-109618: Out-of-memory handling&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this vulnerability, an out-of-memory condition can interrupt PHP while request data is only partially initialized.&lt;/p&gt;
&lt;p&gt;The bailout can leave PHP's internal request state inconsistent. When subsequent cleanup operations process that incomplete state, memory corruption can occur, potentially leading to remote code execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CVE-2026-109619: Stale global state and use-after-free&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The second vulnerability involves a different memory-management problem.&lt;/p&gt;
&lt;p&gt;Request-owned memory can be freed while stale global state continues to reference it.&lt;/p&gt;
&lt;p&gt;If a later request is handled by the same worker before that state is properly reinitialized, PHP may access memory that is no longer valid.&lt;/p&gt;
&lt;p&gt;This creates a use-after-free condition.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why is a use-after-free dangerous?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Think of memory as a collection of storage locations.&lt;/p&gt;
&lt;p&gt;A program allocates a location, stores information there and keeps a pointer to access it later.&lt;/p&gt;
&lt;p&gt;Now imagine that the location gets freed, but the program still has the old pointer.&lt;/p&gt;
&lt;p&gt;Another allocation might eventually reuse that same memory location.&lt;/p&gt;
&lt;p&gt;The original pointer now references something different from what it was supposed to reference.&lt;/p&gt;
&lt;p&gt;That's the fundamental problem behind a use-after-free scenario.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Apache worker connection&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The lab uses Apache with mod_php in a prefork configuration.&lt;/p&gt;
&lt;p&gt;In this setup, an Apache worker can process multiple requests during its lifetime.&lt;/p&gt;
&lt;p&gt;That means internal state left behind by one request can potentially affect a subsequent request handled by the same worker.&lt;/p&gt;
&lt;p&gt;This is particularly relevant when understanding the stale-state issue in CVE-2026-109619.&lt;/p&gt;
&lt;p&gt;So, the issue isn't simply that PHP encounters an error.&lt;/p&gt;
&lt;p&gt;The problem is that internal memory and request state can remain inconsistent or stale in ways that influence subsequent processing.&lt;/p&gt;
&lt;p&gt;And this is what makes the exploitation chain possible.&lt;/p&gt;
&lt;h3 id=&quot;4-understanding-the-two-vulnerabilities&quot;&gt;4. Understanding the two vulnerabilities&lt;/h3&gt;
&lt;p&gt;Now that we understand the different memory-management problems, let's look at how the two CVEs fit into the bigger picture.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CVE-2026-109618: RCE via Out-of-Memory Corruption&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This vulnerability involves PHP's handling of an out-of-memory condition during request processing.&lt;/p&gt;
&lt;p&gt;An OOM-related bailout can interrupt PHP while internal request data is only partially initialized.&lt;/p&gt;
&lt;p&gt;Subsequent cleanup may then operate on invalid or incomplete state, potentially resulting in memory corruption and remote code execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CVE-2026-109619: RCE via Stale Global State&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The second vulnerability involves stale internal state that survives after the memory backing it has been freed.&lt;/p&gt;
&lt;p&gt;When a later request is handled by the same worker, PHP may access that stale state before it has been properly reinitialized.&lt;/p&gt;
&lt;p&gt;This creates a use-after-free condition that can ultimately be exploited to achieve remote code execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where the lab's memory-disclosure stage fits&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In the TryHackMe exploitation walkthrough, the exploit first obtains information about the PHP worker's memory layout.&lt;/p&gt;
&lt;p&gt;The interesting part is where this information gets returned.&lt;/p&gt;
&lt;p&gt;PHP uses the following HTTP response header:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set-Cookie: PHPSESSID&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The resulting oversized session identifier can expose adjacent process memory.&lt;/p&gt;
&lt;p&gt;That memory may contain useful information, including heap and library addresses.&lt;/p&gt;
&lt;p&gt;Why does that matter?&lt;/p&gt;
&lt;p&gt;Because modern operating systems use a security mechanism called Address Space Layout Randomization (ASLR).&lt;/p&gt;
&lt;p&gt;ASLR randomizes memory locations to make exploitation more difficult.&lt;/p&gt;
&lt;p&gt;An attacker generally needs to know where important functions and memory regions are located to reliably manipulate execution.&lt;/p&gt;
&lt;p&gt;In the TryHackMe lab, the exploitation chain obtains two important addresses:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The PHP heap base address.&lt;/li&gt;
&lt;li&gt;The libphp base address.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This information becomes useful in the next stage.&lt;/p&gt;
&lt;p&gt;The important distinction is that this memory-disclosure stage is part of the exploitation chain demonstrated in the lab. It should not be confused with the primary vulnerability classification of CVE-2026-109618, which is remote code execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The HashTable and pDestructor technique&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is where things get considerably more interesting.&lt;/p&gt;
&lt;p&gt;The lab's exploitation chain uses a forged internal PHP structure called a HashTable.&lt;/p&gt;
&lt;p&gt;One of the important fields involved is:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pDestructor&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This field is associated with the function PHP calls when destroying certain values.&lt;/p&gt;
&lt;p&gt;The demonstrated exploitation technique manipulates this structure to redirect the cleanup process toward the &lt;code&gt;system()&lt;/code&gt; function inside PHP's library.&lt;/p&gt;
&lt;p&gt;Instead of executing the expected cleanup operation, PHP ends up invoking a function capable of executing operating-system commands.&lt;/p&gt;
&lt;p&gt;And that's how the memory manipulation in the demonstrated chain can ultimately result in remote code execution.&lt;/p&gt;
&lt;p&gt;What I found particularly interesting is that the normal cleanup process itself becomes part of the exploitation chain.&lt;/p&gt;
&lt;p&gt;Something designed to manage resources properly can become dangerous when the underlying memory state has been corrupted.&lt;/p&gt;
&lt;p&gt;It's worth remembering that these HashTable and &lt;code&gt;pDestructor&lt;/code&gt; details describe the exploitation technique demonstrated in the lab, rather than a universal explanation of the root cause of both CVEs.&lt;/p&gt;
&lt;h3 id=&quot;5-my-practical-investigation-on-tryhackme&quot;&gt;5. My practical investigation on TryHackMe&lt;/h3&gt;
&lt;p&gt;After understanding the concepts, I moved on to the practical investigation.&lt;/p&gt;
&lt;p&gt;The TryHackMe room provided a vulnerable PHP server running inside a Docker container.&lt;/p&gt;
&lt;p&gt;I connected to the machine using SSH with the provided credentials.&lt;/p&gt;
&lt;p&gt;The first thing I wanted to do was identify the running container and check its PHP version.&lt;/p&gt;
&lt;p&gt;I used:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker ps --format '{{.Names}}  {{.Image}}  {{.Status}}'&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The output showed:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;php2shell-target  php:8.4.26-apache  Up&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Next, I checked the PHP version inside the container:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker exec php2shell-target php -v&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The target was running:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PHP 8.4.26
Zend Engine v4.4.26&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This was important because PHP 8.4.27 was the fixed release.&lt;/p&gt;
&lt;p&gt;So, the lab environment was running a vulnerable version.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Inspecting the application&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Next, I wanted to see what the actual website was returning.&lt;/p&gt;
&lt;p&gt;I executed:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;curl -s http://127.0.0.1:8088/index.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The response?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OK&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;That's it.&lt;/p&gt;
&lt;p&gt;I also inspected the PHP file:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker exec php2shell-target cat /var/www/html/index.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;And found:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;?php echo &quot;OK\n&quot;; ?&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;At this point, I found the situation particularly interesting.&lt;/p&gt;
&lt;p&gt;There was no login system.&lt;/p&gt;
&lt;p&gt;There was no database.&lt;/p&gt;
&lt;p&gt;There was no application-level file-upload functionality.&lt;/p&gt;
&lt;p&gt;The application was literally printing OK.&lt;/p&gt;
&lt;p&gt;Yet the underlying PHP runtime was vulnerable.&lt;/p&gt;
&lt;p&gt;This reinforced one of the biggest lessons from the room: an application doesn't necessarily need to contain vulnerable code for its underlying server to be exploitable.&lt;/p&gt;
&lt;h3 id=&quot;6-observing-the-memory-disclosure&quot;&gt;6. Observing the memory disclosure&lt;/h3&gt;
&lt;p&gt;After confirming the target environment, I moved to the memory-disclosure stage.&lt;/p&gt;
&lt;p&gt;The lab provided an existing exploit repository:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;php2shell-bypass-aslr-exp&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I navigated into the directory:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cd ~/php2shell-bypass-aslr-exp&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The provided exploit handled the exploitation stages.&lt;/p&gt;
&lt;p&gt;I ran it against the local target:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;python3 exploit.py -u http://127.0.0.1:8088/index.php&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The output showed two particularly interesting stages:&lt;/p&gt;
&lt;p&gt;`[*] stage 1: heap leak&lt;br&gt;
[+] chunk base = 0x...&lt;/p&gt;
&lt;p&gt;[*] stage 2: libphp leak&lt;br&gt;
[+] libphp base = 0x...`&lt;/p&gt;
&lt;p&gt;These were the two memory addresses the exploitation chain needed.&lt;/p&gt;
&lt;p&gt;The first revealed the heap location, while the second revealed the PHP library's base address.&lt;/p&gt;
&lt;p&gt;I found this interesting because ASLR is specifically designed to make these locations unpredictable.&lt;/p&gt;
&lt;p&gt;Instead of simply guessing the addresses, the exploitation chain obtains information about the actual memory layout.&lt;/p&gt;
&lt;p&gt;This demonstrated how a memory disclosure stage can become a stepping stone toward something much more serious.&lt;/p&gt;
&lt;h3 id=&quot;7-from-memory-disclosure-to-remote-code-execution&quot;&gt;7. From memory disclosure to remote code execution&lt;/h3&gt;
&lt;p&gt;Now came the part I was particularly interested in: actually demonstrating command execution.&lt;/p&gt;
&lt;p&gt;After the memory disclosure stages, the exploit proceeded to the final stage involving the manipulated HashTable structure.&lt;/p&gt;
&lt;p&gt;The lab provided the following execution proof:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;And there it was.&lt;/p&gt;
&lt;p&gt;The command had executed successfully under the www-data account, which is commonly used by web servers on Linux systems.&lt;/p&gt;
&lt;p&gt;This confirmed that the exploitation chain had achieved remote code execution in the lab environment.&lt;/p&gt;
&lt;p&gt;The next step was retrieving the flag.&lt;/p&gt;
&lt;p&gt;The lab's exploit used the following command:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;python3 exploit.py \
-u http://127.0.0.1:8088/index.php \
-c 'cat /f&amp;gt;/out/p1' \
--proof-file out/p1&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The result returned the flag:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;THM{b4ilout_l3aves_th3_h3ap_dang1ing}&lt;/code&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;THM{b4ilout_l3aves_th3_h3ap_dang1ing}&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A pretty fitting flag for a vulnerability involving a bailout and a dangling pointer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;What made this practical exercise memorable for me was the fact that the target application itself never changed.&lt;/p&gt;
&lt;p&gt;It continued to be a simple PHP page returning OK.&lt;/p&gt;
&lt;p&gt;The exploitation happened because of the vulnerable runtime underneath it.&lt;/p&gt;
&lt;h3 id=&quot;8-detection-and-mitigation&quot;&gt;8. Detection and mitigation&lt;/h3&gt;
&lt;p&gt;Understanding how an attack works is one thing.&lt;/p&gt;
&lt;p&gt;Knowing how to prevent or detect it is equally important.&lt;/p&gt;
&lt;p&gt;Since PHP2Shell affects the PHP runtime, updating the application alone isn't enough.&lt;/p&gt;
&lt;p&gt;The PHP installation itself needs to be patched.&lt;/p&gt;
&lt;p&gt;The fixed releases identified in the security advisory are:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;PHP branch &lt;/th&gt;&lt;th&gt;Fixed release&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;PHP 8.5 &lt;/td&gt;&lt;td&gt;8.5.12&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;PHP 8.4 &lt;/td&gt;&lt;td&gt;8.4.27&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;PHP 8.3 &lt;/td&gt;&lt;td&gt;8.3.36&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;PHP 8.2 &lt;/td&gt;&lt;td&gt;8.2.35&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Temporary risk reduction&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If immediate patching isn't possible, administrators can review their PHP request-handling configuration and apply temporary defensive measures, such as restricting request-body sizes at the reverse proxy or web-server level.&lt;/p&gt;
&lt;p&gt;Disabling &lt;code&gt;session.upload_progress&lt;/code&gt; may also reduce exposure to the specific upload-progress attack path demonstrated in the lab:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;session.upload_progress.enabled = 0&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;However, these measures are only temporary risk-reduction steps. They do not fix the underlying vulnerabilities and should not be considered complete mitigations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The only safe solution is to update PHP to a fixed release.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;After updating, restart PHP-FPM workers or reload/restart Apache when using &lt;code&gt;mod_php&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If PHP runs inside containers, rebuild or replace the affected containers so that no vulnerable runtime instances remain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What should defenders monitor?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Some suspicious indicators worth investigating include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Unusually large PHPSESSID values in Set-Cookie response headers.&lt;/li&gt;
&lt;li&gt;Suspicious multipart POST requests.&lt;/li&gt;
&lt;li&gt;Unexpected PHP worker crashes.&lt;/li&gt;
&lt;li&gt;Memory exhaustion errors.&lt;/li&gt;
&lt;li&gt;PHP processes unexpectedly spawning shell processes.&lt;/li&gt;
&lt;li&gt;Unusual command execution under the web server account.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One important lesson here is that monitoring only the application layer isn't sufficient.&lt;/p&gt;
&lt;p&gt;Because the vulnerability is triggered during PHP's internal request processing, detection and prevention need to account for activity happening before the application script executes.&lt;/p&gt;
&lt;p&gt;Patching the runtime remains the priority.&lt;/p&gt;
&lt;h3 id=&quot;9-what-i-learned-from-this-investigation&quot;&gt;9. What I learned from this investigation&lt;/h3&gt;
&lt;p&gt;This was more than just another TryHackMe room for me.&lt;/p&gt;
&lt;p&gt;It helped me connect several concepts that I had previously encountered separately.&lt;/p&gt;
&lt;p&gt;Some of the things I explored included:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How PHP processes multipart/form-data requests.&lt;/li&gt;
&lt;li&gt;What session.upload_progress actually does.&lt;/li&gt;
&lt;li&gt;How memory allocation and dangling pointers work.&lt;/li&gt;
&lt;li&gt;Why an out-of-memory bailout can become dangerous.&lt;/li&gt;
&lt;li&gt;What ASLR does and why attackers try to bypass it.&lt;/li&gt;
&lt;li&gt;How memory disclosure can assist remote code execution.&lt;/li&gt;
&lt;li&gt;How PHP's internal cleanup mechanisms can become part of an exploitation chain.&lt;/li&gt;
&lt;li&gt;Why securing the application doesn't necessarily secure the runtime.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But there's one thing that stood out above everything else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sometimes, the vulnerability isn't in the code you wrote. It's in the code your code depends on.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;As someone learning cybersecurity alongside software development and AI/ML, I find these investigations particularly valuable.&lt;/p&gt;
&lt;p&gt;I don't want to simply learn which command executes an exploit or how to retrieve a flag.&lt;/p&gt;
&lt;p&gt;I want to understand what happens underneath, why a vulnerability exists, how it can be abused and, most importantly, how defenders can prevent it.&lt;/p&gt;
&lt;p&gt;Because there's a difference between knowing that something works and understanding why it works.&lt;/p&gt;
&lt;p&gt;And I'm genuinely enjoying the process of learning that difference.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;PHP2Shell was a great reminder that cybersecurity isn't just about finding vulnerabilities in websites.&lt;/p&gt;
&lt;p&gt;It's also about understanding the technologies, runtimes, libraries and systems that make those websites work in the first place.&lt;/p&gt;
&lt;p&gt;A seemingly harmless upload-progress feature, combined with a memory-management failure, demonstrated how something deep inside a runtime could eventually lead to remote code execution.&lt;/p&gt;
&lt;p&gt;And that's what makes cybersecurity so interesting to me.&lt;/p&gt;
&lt;p&gt;There's always another layer to investigate, another question to ask and another mechanism to understand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stay curious. Keep learning. Keep breaking things in controlled environments, and most importantly, understand what you're doing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: This investigation was conducted in an authorized TryHackMe lab environment. The exploit and command-execution results described above relate to that controlled environment.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33222/php2shell-when-a-simple-upload-feature-turns-into-remote-code-execution</guid>
<pubDate>Sat, 31 Oct 2026 12:16:03 +0000</pubDate>
</item>
<item>
<title>PHP2Shell: patch PHP now, then check</title>
<link>https://coderlegion.com/33143/php2shell-patch-php-now-then-check</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com/insights/php2shell-patch-php-now.html&quot;&gt;Dunime&lt;/a&gt; blog.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Two critical PHP flaws let an attacker run code on a web server without logging in. What changed on 21 October, which sites it touches, and what to do today.&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If a website runs on PHP, it is in scope. On 21 October 2026 the PHP project shipped fixes for two critical flaws that can let an unauthenticated attacker run code on the web server, and said that updating is the only complete fix.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;what-changed&quot;&gt;What changed&lt;/h3&gt;
&lt;p&gt;On 20 October the PHP security team gave advance notice of a release fixing two critical vulnerabilities, both rated CVSS v4.0 9.2. On 21 October it published PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 with a security advisory headed &quot;update now&quot;. The pair is widely reported under the name PHP2Shell.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CVE-2026-109618, code execution via out-of-memory corruption:&lt;/strong&gt; out-of-memory handling can interrupt PHP while internal request data is only partly set up, and request cleanup may then treat that invalid data as valid.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CVE-2026-109619, code execution via stale global state:&lt;/strong&gt; internal state belonging to one request can survive after its memory has been freed, and a later request handled by the same worker may use it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In PHP's words, there are no complete mitigations and updating is the only safe choice. On 23 October PHP added an informational note: keep session.upload_progress.enabled=0 until the next regular bugfix release, to close an unlikely related vector it will document as CVE-2026-115560.&lt;/p&gt;
&lt;p&gt;On exploitation: BleepingComputer reported on 21 October that Cloudflare's CTO said the company was already observing early attacks against both flaws before the patches shipped. The UK NCSC alert of the same day asks organisations to update immediately; it does not itself state that the flaws are being exploited.&lt;/p&gt;
&lt;h3 id=&quot;who-it-applies-to&quot;&gt;Who it applies to&lt;/h3&gt;
&lt;p&gt;PHP's advisory puts every PHP installation that serves web requests at risk: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. It rates the risk highest for mod_php, where a demonstrated exploit lets an unauthenticated request trigger the attack before any application code runs.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CMS and web apps:&lt;/strong&gt; WordPress, Drupal, Joomla, Magento, phpMyAdmin and Nextcloud all run on PHP. Help Net Security, reporting Searchlight Cyber's research, names all six as software on which exploitation has been confirmed. A fully updated plugin or theme does not help if the PHP underneath is unpatched.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared hosting and cPanel:&lt;/strong&gt; on shared hosting the provider usually owns the PHP build. The PHP version picker in the panel shows which branch a site uses, but whether that build carries the 21 October fix is the host's answer to give.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Old PHP:&lt;/strong&gt; PHP 8.1 and older no longer get updates from the PHP project. Some Linux distributions and long-term-support vendors backport fixes, so ask the vendor. PHP also published minimal source backports for 7.4, 8.0 and 8.1 for people who build from source. PHP 8.2 itself reaches end of life on 31 December 2026.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agencies and IT teams:&lt;/strong&gt; anyone who runs sites for others owns an inventory problem today. The NCSC asks for discovery of every PHP installation, internet-facing first, and warns not to limit the search to outdated PHP versions.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;Patching the plugin is not patching PHP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;what-to-do-today&quot;&gt;What to do today&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1. Find every PHP install.&lt;/strong&gt; Start with internet-facing sites, then internal tools. Include appliances and products that bundle PHP, and apply their vendors' updates as they arrive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2. Check the version.&lt;/strong&gt; On a server, php -v shows the command-line build. The web server can run a different build, so also check the hosting panel or your package manager. Distribution packages may carry the fix under their own version numbering, so confirm with the vendor's security notice rather than the number alone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3. Update.&lt;/strong&gt; Move to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or the patched package from your distribution or LTS vendor, internet-facing systems first. Restart PHP-FPM or Apache so every worker runs the new build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4. If you cannot update yet, reduce exposure.&lt;/strong&gt; PHP's interim measures narrow specific attack paths but do not make a server safe. They are listed in the next section.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;5. If even that is not possible:&lt;/strong&gt; the NCSC says to consider taking the system offline until it is fully mitigated, and, if it cannot be updated or disabled straight away, to check it for signs of compromise and backdoors first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;6. Check the logs.&lt;/strong&gt; Look for new or changed PHP files in upload and cache folders, unexpected admin accounts, and bursts of large multipart POST requests, memory-exhaustion errors or crashed PHP-FPM workers. A server that was exposed before patching should be checked, not just patched.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;7. Know who to call.&lt;/strong&gt; Your hosting provider for the PHP build, your application vendor for bundled PHP, and an incident response team if you find signs of compromise. In India, cyber incidents are reported to CERT-In.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;interim-mitigations-php-published&quot;&gt;Interim mitigations PHP published&lt;/h3&gt;
&lt;p&gt;Only for systems that cannot be updated immediately. PHP is explicit that these reduce exposure to specific attack paths and do not make an installation safe.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;For CVE-2026-109618:&lt;/strong&gt; set memory_limit=-1 in a way applications cannot override, such as php_admin_value in the FPM pool or Apache configuration, and check nothing sets a finite limit back. Set a low post_max_size: 8M for applications without file uploads, 32M if uploads are needed, with upload_max_filesize below it. Use a container or cgroup limit, ideally one that kills only the affected worker; PHP warns not to substitute RLIMIT_AS or RLIMIT_DATA.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For CVE-2026-109619:&lt;/strong&gt; set session.upload_progress.enabled=0 in php.ini, the FPM pool or server configuration (not ini_set() or .user.ini) and restart every worker. On PHP 8.5, also set fatal_error_backtraces=0 and restart. Remove invalid or deprecated per-request settings that emit diagnostics, and fix fatal errors and timeouts an attacker can reach.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What does not help:&lt;/strong&gt; hiding errors with error_reporting, display_errors or the @ operator.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;a-free-scanner-and-its-catch&quot;&gt;A free scanner, and its catch&lt;/h3&gt;
&lt;p&gt;On 22 October Searchlight Cyber released php2shell-scanner, a free, open-source command-line tool (github.com/assetnote/php2shell-scanner) that tests whether a host shows the vulnerable behaviour rather than only reading a version number. It first tries to produce a PHP memory-exhaustion error, then looks for a PHP-FPM worker dying mid-request, and only as a last resort reads the advertised PHP version.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The catch:&lt;/strong&gt; detection deliberately pushes PHP into an out-of-memory condition. The researchers call this &quot;fundamentally risky in nature&quot; and ask for care and caution. Each tested path can receive a small control request and three probes totalling about 29 MiB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;False positives:&lt;/strong&gt; the README warns that a patched build can return the same empty HTTP 500 when a probe exceeds a low memory_limit, so check any &quot;vulnerable&quot; result against the installed version and vendor backports.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run it only on systems you own or are authorised in writing to test,&lt;/strong&gt; and in a maintenance window. It is a check after patching, not a reason to delay the patch.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;questions-people-ask&quot;&gt;Questions people ask&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What is PHP2Shell?&lt;/strong&gt; The name widely used for CVE-2026-109618 and CVE-2026-109619, two critical PHP vulnerabilities fixed on 21 October 2026 that can allow unauthenticated remote code execution on web servers running PHP, including PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Which PHP versions fix PHP2Shell?&lt;/strong&gt; PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. PHP 8.1 and older no longer receive updates from the PHP project; some Linux distributions and LTS vendors provide backported fixes, and PHP published minimal source backports for 7.4, 8.0 and 8.1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My WordPress site is on shared hosting. What should I do?&lt;/strong&gt; Check the PHP version in your hosting panel and ask your host whether their PHP build includes the 21 October 2026 fixes. Updating WordPress, plugins and themes does not fix PHP itself. If the host cannot confirm, ask when it will, and follow the NCSC advice for systems that cannot be patched.&lt;/p&gt;
&lt;h3 id=&quot;sources&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.php.net/archive/2026.php#2026-10-21-5&quot;&gt;PHP: Security advisory, critical remote code execution vulnerabilities, update now (21 Oct 2026), and 20 and 23 Oct notices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.php.net/releases/8_4_27.php&quot;&gt;PHP: PHP 8.4.27 release announcement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.ncsc.gov.uk/news/critical-security-issue-affecting-php&quot;&gt;NCSC (UK): Critical security issue affecting PHP (21 Oct 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.slcyber.io/research/high-fidelity-detection-mechanism-for-php2shell-cve-2026-109618-and-cve-2026-109619&quot;&gt;Searchlight Cyber: High fidelity detection mechanism for php2shell (22 Oct 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/assetnote/php2shell-scanner&quot;&gt;php2shell-scanner on GitHub (README)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.bleepingcomputer.com/news/security/php-ships-emergency-patches-for-critical-php2shell-flaws/&quot;&gt;BleepingComputer, 21 Oct 2026: PHP ships emergency patches for critical PHP2Shell flaws&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.helpnetsecurity.com/2026/10/22/php2shell-scanner-tool/&quot;&gt;Help Net Security, 22 Oct 2026: PHP2Shell scanner tool&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Dunime maps live threats like this one to the specific checks and defences that answer them: &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com/now&quot;&gt;dunime.com/now&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Not sure what's exposed? Dunime's &lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com/services.html&quot;&gt;VAPT&lt;/a&gt; checks whether unpatched services like this are reachable and exploitable across your estate.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Published by &lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com&quot;&gt;Dunime Technologies&lt;/a&gt; — custom software, automation and cybersecurity, Delhi NCR, India.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33143/php2shell-patch-php-now-then-check</guid>
<pubDate>Fri, 30 Oct 2026 19:55:54 +0000</pubDate>
</item>
<item>
<title>WordPress Crash Recovery &amp; Plugin Conflict Resolution</title>
<link>https://coderlegion.com/33093/wordpress-crash-recovery-plugin-conflict-resolution</link>
<description>&lt;p&gt;When a WordPress site crashes immediately after an update, the immediate business impact is lost revenue, disrupted customer workflows, and compromised search standing. In production environments, standard advice like &quot;restore a backup from yesterday&quot; can cause critical data loss: orders placed between the backup and the crash, customer registrations, and recent transactions vanish.&lt;/p&gt;
&lt;p&gt;A disciplined engineering approach treats a crashed WordPress site as a contained system failure: isolate the execution failure, restore availability, fix the root cause, and verify data integrity before returning traffic to normal operations.&lt;/p&gt;
&lt;h3 id=&quot;common-crash-failure-modes-after-updates&quot;&gt;Common Crash Failure Modes After Updates&lt;/h3&gt;
&lt;p&gt;WordPress crashes after updates typically stem from four discrete architectural failure modes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;PHP Fatal Errors (Type Errors &amp;amp; Deprecations)&lt;/strong&gt;: A plugin update requires PHP 8.1+ syntax or library extensions that conflict with the server runtime or an older theme calling deprecated functions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hook and Filter Signature Conflicts&lt;/strong&gt;: Two active plugins hook into the same action (e.g., &lt;code&gt;woocommerce_checkout_order_processed&lt;/code&gt; or &lt;code&gt;template_redirect&lt;/code&gt;) expecting incompatible argument types or returning invalid objects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database Schema Migrations &amp;amp; Locked Tables&lt;/strong&gt;: Complex plugins (WooCommerce, membership tools, custom post-type managers) execute DDL migrations upon activation. If a migration times out or hits MySQL lock contention, queries fail across the application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Autoloaded Options &amp;amp; Memory Exhaustion&lt;/strong&gt;: Plugins accumulating transient data in &lt;code&gt;wp_options&lt;/code&gt; with &lt;code&gt;autoload = 'yes'&lt;/code&gt; spike PHP memory usage beyond &lt;code&gt;memory_limit&lt;/code&gt;, triggering fatal out-of-memory crashes on high-concurrency requests.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;systematic-diagnostic-protocol&quot;&gt;Systematic Diagnostic Protocol&lt;/h3&gt;
&lt;p&gt;When diagnosing a crashed WordPress production instance, follow this 5-step engineering triage:&lt;/p&gt;
&lt;h4 id=&quot;step-1-capture-raw-error-logs-without-exposing-stack-traces&quot;&gt;Step 1: Capture Raw Error Logs Without Exposing Stack Traces&lt;/h4&gt;
&lt;p&gt;Do not enable &lt;code&gt;WP_DEBUG_DISPLAY&lt;/code&gt; on a live production URL. Instead, enable debug logging to a private log destination:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// In wp-config.php (before /* That's all, stop editing! */)
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', '/var/log/wordpress/debug.log'); // Secure path outside public web root
define('WP_DEBUG_DISPLAY', false);
@ini_set('display_errors', '0');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inspect &lt;code&gt;debug.log&lt;/code&gt; or the web server error log (NGINX/Apache/FPM error log) to identify the exact file, line number, and exception stack trace.&lt;/p&gt;
&lt;h4 id=&quot;step-2-safe-plugin-isolation-via-wp-cli&quot;&gt;Step 2: Safe Plugin Isolation via WP-CLI&lt;/h4&gt;
&lt;p&gt;Avoid renaming the entire &lt;code&gt;wp-content/plugins&lt;/code&gt; folder if possible, as this deactivates all plugins and breaks widget and database mappings upon reactivation. Use WP-CLI to isolate the culprit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Check status of installed plugins
wp plugin list --status=active

# Deactivate the suspect plugin identified in the error stack trace
wp plugin deactivate offending-plugin-slug --skip-plugins --skip-themes

# Verify frontend HTTP response code
curl -I https://example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-check-mysql-database-integrity-and-migration-locks&quot;&gt;Step 3: Check MySQL Database Integrity and Migration Locks&lt;/h4&gt;
&lt;p&gt;If the error indicates missing tables or broken queries, verify table health:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Check and repair WordPress core and plugin tables
wp db check
wp db repair

# Identify oversized autoloaded options causing memory exhaustion
wp db query &quot;SELECT option_name, LENGTH(option_value) AS size FROM wp_options WHERE autoload='yes' ORDER BY size DESC LIMIT 10;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-4-patch-compatibility-or-pin-version&quot;&gt;Step 4: Patch Compatibility or Pin Version&lt;/h4&gt;
&lt;p&gt;If the site requires the updated plugin for business operations, evaluate whether a targeted patch (e.g., handling a null object check in a template hook) or a clean version downgrade via WP-CLI is appropriate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Safely roll back to previous known-stable version
wp plugin install offending-plugin-slug --version=2.4.1 --force
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-5-verification-and-regression-testing&quot;&gt;Step 5: Verification and Regression Testing&lt;/h4&gt;
&lt;p&gt;Verify that critical customer journeys work end-to-end:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Homepage, archive, and single post rendering&lt;/li&gt;
&lt;li&gt;User authentication and session persistence&lt;/li&gt;
&lt;li&gt;E-commerce cart, checkout, and webhook callbacks&lt;/li&gt;
&lt;li&gt;Scheduled cron tasks (&lt;code&gt;wp cron event list&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-to-seek-engineering-assistance&quot;&gt;When to Seek Engineering Assistance&lt;/h3&gt;
&lt;p&gt;If a WordPress crash involves corrupted database transactions, custom plugin architecture conflicts, security compromises, or legacy codebase friction, contained engineering intervention restores stability safely.&lt;/p&gt;
&lt;p&gt;For rapid assistance with an active failure or production regression, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://sazm.in/bug-fix-services&quot;&gt;WordPress &amp;amp; website bug fixing&lt;/a&gt; or queue an &lt;a rel=&quot;nofollow&quot; href=&quot;https://sazm.in/checkout/emergency&quot;&gt;urgent bug fix&lt;/a&gt;. To prevent update regressions entirely with weekly staging tests and daily backups, explore the &lt;a rel=&quot;nofollow&quot; href=&quot;https://sazm.in/wordpress-care&quot;&gt;WordPress Care Plan&lt;/a&gt;. If your platform requires broader architecture stabilization, review SazM's &lt;a rel=&quot;nofollow&quot; href=&quot;https://sazm.in/audit&quot;&gt;Diagnostic Audits&lt;/a&gt; or start a direct technical inquiry at &lt;a rel=&quot;nofollow&quot; href=&quot;https://sazm.in/contact&quot;&gt;the contact page&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published with full diagnostic protocols at &lt;a rel=&quot;nofollow&quot; href=&quot;https://sazm.in/articles/wordpress-crash-recovery-after-plugin-update&quot;&gt;SazM: WordPress Crash Recovery &amp;amp; Plugin Conflict Resolution&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33093/wordpress-crash-recovery-plugin-conflict-resolution</guid>
<pubDate>Fri, 30 Oct 2026 14:36:25 +0000</pubDate>
</item>
<item>
<title>php2shell: two ways PHP corrupts its own memory, and why turning that into a shell is still hard</title>
<link>https://coderlegion.com/33099/php2shell-two-ways-php-corrupts-its-own-memory-and-why-turning-that-into-shell-is-still-hard</link>
<description>&lt;p&gt;A single unauthenticated request can run code as your web worker on PHP before 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. The bug is real, the patch is out, and reliable exploitation outside a lab is far harder than the headlines suggest. This is the full analysis, and an honest account of how long it took to prove (and why so long).&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Lab-only research on a patched vulnerability. Knowledge sharing, not a how-to.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;if-you-read-nothing-else-do-these-five-things&quot;&gt;If you read nothing else, do these five things&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Upgrade now&lt;/strong&gt; to PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35. These fix &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt;. On an end-of-life branch, do not hand-apply an isolated source patch on top of the last EOL release: it closes these two CVEs but leaves you exposed to everything else fixed since the branch went out of support. Get a maintained build from your distro or a package maintainer (Ondřej Surý's &lt;a rel=&quot;nofollow&quot; href=&quot;https://deb.sury.org/&quot;&gt;&lt;code&gt;deb.sury.org&lt;/code&gt;&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://rpms.remirepo.net/&quot;&gt;Remi Collet's repo&lt;/a&gt;, Red Hat's module streams), which carry patched builds reaching back through 5.6, or upgrade. Check the package changelog for the CVE IDs before you trust any build. On &lt;strong&gt;Windows&lt;/strong&gt;, take the &lt;code&gt;-1&lt;/code&gt; rebuild (8.5.12-1 and friends): that one bundles a separate &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/google/brotli/releases/tag/v1.2.1&quot;&gt;Brotli 1.2.1&lt;/a&gt; fix for &lt;strong&gt;CVE-2026-111497&lt;/strong&gt;, an out-of-bounds write reachable through the cURL extension when automatic response decompression is on. Different bug, same download.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt;&lt;/strong&gt;, and turn off every extension and feature you don't use. The session flag is PHP's own &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.php.net/archive/2026.php#2026-10-23-1&quot;&gt;published mitigation&lt;/a&gt;. The general habit, shrinking the attack surface, is the part that pays off on the next bug you haven't heard about yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you run mod_php, move to php-fpm.&lt;/strong&gt; The FPM process model removes an entire class of these chains and isolates workers from the web server. It raises the bar. It does not close the door: I got a shell on FPM too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exploiting this in the wild is hard.&lt;/strong&gt; Not impossible, hard. Every chain is calibrated to one exact build, the heap grooming is fragile, and even with current LLMs doing the heavy lifting it took days of dead-ends to reach a reliable shell. Sansec and Wiz have both reported public &quot;php2shell&quot; PoCs that do not work against a default install.&lt;/li&gt;
&lt;li&gt;This article explains the mechanism and the mitigations. It is not a copy-paste exploit kit, and the working lab harness targets the vulnerable 8.5.11 image only.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;credit-where-it-is-due&quot;&gt;Credit where it is due&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Discovery and analysis:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://calif.io/php2shell/&quot;&gt;Calif&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.wiz.io/blog/php2shell-exploitation-so-far&quot;&gt;Wiz&lt;/a&gt;. Their public write-ups, along with the PHP source patch, are what let me understand the issue well enough to reproduce it. If you want the discovery story and the wider impact catalogue, read theirs first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; the PHP team. &quot;Avoid state corruption via bailout&quot;, authored by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka, Nora Dossche, and Nicolas Grekas. They turned a messy memory-corruption class into a two-line conceptual fix: stop using &lt;code&gt;zend_bailout&lt;/code&gt; on OOM, and reset the globals that startup touches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Review:&lt;/strong&gt; special thanks to Arnaud Le Blanc for reviewing an early draft and giving sharp, generous feedback that made this write-up more accurate.&lt;/p&gt;
&lt;h3 id=&quot;why-this-wasn-t-log4shell&quot;&gt;Why this wasn't Log4Shell&lt;/h3&gt;
&lt;p&gt;The response was loud. &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/NSACyber/status/2112903220209098897&quot;&gt;NSA&lt;/a&gt;, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/FBI/status/2113692197568630999&quot;&gt;FBI&lt;/a&gt;, and the UK's &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/NCSC/status/2112848265074847828&quot;&gt;NCSC&lt;/a&gt; all told anyone who could not patch immediately to take the affected system offline until it was mitigated; &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/CISAgov/status/2113984287481716741&quot;&gt;CISA said the same in its October 24 update&lt;/a&gt;, and days earlier had reported active exploitation and added both CVEs to its Known Exploited Vulnerabilities list. I would take the &quot;active exploitation&quot; label with a grain of salt. In waves like this, what gets reported as exploitation is often mass scanning and failed attempts. More than one vendor has quietly walked back a confident &quot;actively exploited&quot; claim once someone checked whether the traffic was a scan or a real shell, and this is not a bug that falls to a generic payload. The PHP team never called for shutting servers down. Their guidance was to patch, then apply the config mitigations.&lt;/p&gt;
&lt;p&gt;Everyone reached for the Log4Shell comparison. So why wasn't the whole internet on fire? Why targeted hits instead of a mass, worming wave? The answer is in the offsets, and it is the reason this write-up exists.&lt;/p&gt;
&lt;h3 id=&quot;the-root-cause-php-panics-by-longjmp-and-panic-skips-cleanup&quot;&gt;The root cause: PHP panics by longjmp, and panic skips cleanup&lt;/h3&gt;
&lt;p&gt;When a PHP request exhausts &lt;code&gt;memory_limit&lt;/code&gt;, the engine historically raised a fatal error and recovered with &lt;code&gt;zend_bailout()&lt;/code&gt;, a &lt;code&gt;longjmp&lt;/code&gt; back to a safe point. That unwind is the problem. A &lt;code&gt;longjmp&lt;/code&gt; does not run the cleanup that a normal return would. If the allocation failed halfway through building a data structure, the half-built structure survives, and a global still points at it.&lt;/p&gt;
&lt;p&gt;Both CVEs are that same mistake, caught in two different places.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;CVE &lt;/th&gt;&lt;th&gt;What it is&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CVE-2026-109618&lt;/strong&gt; &amp;middot; Bailout state corruption &lt;/td&gt;&lt;td&gt;An OOM during request startup, request parsing, or session handling leaves engine globals pointing at freed or half-initialized memory. The next request on the same worker inherits the mess.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CVE-2026-109619&lt;/strong&gt; &amp;middot; Pre-RINIT decode reuse &lt;/td&gt;&lt;td&gt;The upload-progress feature decodes a session &lt;em&gt;before&lt;/em&gt; the request re-initializes its globals. With the unserializer left in a dirty state by a prior OOM, that early decode reuses a dangling structure.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The patch stops the OOM from &lt;code&gt;longjmp&lt;/code&gt;-ing mid-operation. It defers the out-of-memory error and raises it at controlled chokepoints, an uncatchable &lt;code&gt;UnwindExit&lt;/code&gt; while a userland frame is on the stack, and resets the globals that startup reads. 389 files changed, and a pile of new &lt;code&gt;oom_rfc1867_*.phpt&lt;/code&gt; tests pin the exact spots where an allocation could fail mid-structure. On the supported branches an ordinary &lt;code&gt;memory_limit&lt;/code&gt; hit is now a graceful fatal that still runs shutdown handlers; only a request that blows past a new hard ceiling, 200% of &lt;code&gt;memory_limit&lt;/code&gt;, forces the process out with &lt;code&gt;_exit(137)&lt;/code&gt;. That rework did not reach the out-of-support branches: the fixes carried by distros and maintainers for 7.4, 8.0, and 8.1 take the blunt route and simply &lt;code&gt;_exit(1)&lt;/code&gt; on the offending allocation, tearing the whole process down rather than attempting the unwind that caused the bug. Either way, the corrupting bailout is gone.&lt;/p&gt;
&lt;h3 id=&quot;the-primitive-one-signed-integer-turns-a-session-into-an-arbitrary-write&quot;&gt;The primitive: one signed integer turns a session into an arbitrary write&lt;/h3&gt;
&lt;p&gt;Everything downstream rests on one function in the unserializer. &lt;code&gt;var_tmp_var()&lt;/code&gt; hands out scratch slots from a block of destructor entries. It reads how many slots are used, then returns a pointer past them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// ext/standard/var_unserializer.re  (simplified)
static zval *var_tmp_var_num(php_unserialize_data_t *var_hashx, zend_long num) {
    var_dtor_entries *var_hash = (*var_hashx)-&amp;gt;last_dtor;
    ...
    zend_long used_slots;           // &amp;lt;-- SIGNED
    for (used_slots = var_hash-&amp;gt;used_slots; ... )
        ZVAL_UNDEF(&amp;amp;var_hash-&amp;gt;data[used_slots]);
    return &amp;amp;var_hash-&amp;gt;data[used_slots];   // base + used_slots*16
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;used_slots&lt;/code&gt; is a signed &lt;code&gt;zend_long&lt;/code&gt;. Make it negative and the returned pointer lands &lt;em&gt;before&lt;/em&gt; the block, anywhere the attacker can reach. The session decoder then writes the parsed value through that pointer, one 8-byte value plus its type tag per session variable. Control where the block sits and what &lt;code&gt;used_slots&lt;/code&gt; holds, and you have a controlled write with controlled data.&lt;/p&gt;
&lt;p&gt;With that write, you build a lie: a chunk of memory that PHP believes is an array. The only field that matters is the array's destructor pointer. Aim it at the internal handler for &lt;code&gt;system()&lt;/code&gt;, and the moment PHP tears the &quot;array&quot; down, it runs your command.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20261029-php2shell-two-ways-php-corrupts-its-own-memory/php2shell-fake-hashtable.svg&quot; alt=&quot;A session-decode write with a negative used_slots lands attacker bytes on a forged HashTable whose pDestructor points at zif_system, so tearing the array down runs system(id) as www-data&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;PHP's array teardown calls &lt;code&gt;pDestructor(&amp;amp;bucket)&lt;/code&gt;. Overwrite that one pointer with the address of &lt;code&gt;zif_system&lt;/code&gt;, put the command where the bucket points, and freeing the array is a shell.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;from-one-write-to-a-shell-the-four-request-mod-php-chain&quot;&gt;From one write to a shell: the four-request mod_php chain&lt;/h3&gt;
&lt;p&gt;A single write primitive is not a shell. You still need to know &lt;em&gt;where&lt;/em&gt; to write, which means defeating ASLR, and you need something that turns a write into execution. The mod_php chain does both across four requests on one persistent Apache worker. It is pure HTTP. No files on the target, no debugger, no application code. The chain scripts live in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php2shell/tree/master/off_chain&quot;&gt;off_chain/&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20261029-php2shell-two-ways-php-corrupts-its-own-memory/php2shell-four-request-chain.svg&quot; alt=&quot;The four-request mod_php chain on one worker: A1 primes an OOM that leaves send_cookie and a dangling session id, B1 leaks libphp and heap bases off the Set-Cookie header, A2 primes again, and B2 sprays a fake reference and fake HashTable whose pDestructor is zif_system&quot;&gt;&lt;/p&gt;
&lt;p&gt;The payoff in B2 is a classic one: a fake PHP array whose destructor pointer is the internal handler for &lt;code&gt;system()&lt;/code&gt;. When PHP tears the array down, it calls that &quot;destructor&quot; with attacker-controlled data, and the command runs as &lt;code&gt;www-data&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cat out/proof
uid=33(www-data) gid=33(www-data) groups=33(www-data)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without the leak, every address is a guess, and a guess on a 64-bit ASLR heap is not an exploit. The forged &lt;code&gt;zend_string&lt;/code&gt; read in B1 is what makes the rest deterministic.&lt;/p&gt;
&lt;h4 id=&quot;reading-the-layout-from-the-wire-the-set-cookie-oracle&quot;&gt;Reading the layout from the wire: the Set-Cookie oracle&lt;/h4&gt;
&lt;p&gt;The leak needs no local access. It rides out on a header the server sends unprompted: when PHP starts a session it writes the session id verbatim into &lt;code&gt;Set-Cookie: PHPSESSID=...&lt;/code&gt;. That id is a &lt;code&gt;zend_string&lt;/code&gt;, and B1 controls which one.&lt;/p&gt;
&lt;p&gt;A1's bailout freed the session-id buffer but left &lt;code&gt;send_cookie=1&lt;/code&gt; and the dangling &lt;code&gt;PS(id)&lt;/code&gt; pointer in place. B1 reclaims that freed chunk with a crafted multipart field and overwrites it with a fake &lt;code&gt;zend_string&lt;/code&gt; header: a benign refcount and type word, hash zero, and a &lt;code&gt;len&lt;/code&gt; field widened to 2048. The character data is left pointing at the surrounding chunk. When the END cleanup emits the cookie, PHP copies &lt;code&gt;len&lt;/code&gt; bytes starting from that pointer, so the &lt;code&gt;PHPSESSID&lt;/code&gt; value becomes a 2 KB window of live engine memory rather than a 32-character id.&lt;/p&gt;
&lt;p&gt;Everything after that is parsing on the attacker's side. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php2shell/blob/master/off_chain/parse_leak.php&quot;&gt;parse_leak.php&lt;/a&gt; URL-decodes the cookie, walks it in 8-byte steps, and keeps the qwords that fall in the heap range. The pointer that recurs with its low 12 bits equal to &lt;code&gt;0x7a0&lt;/code&gt; is &lt;code&gt;zval_ptr_dtor&lt;/code&gt;, so &lt;code&gt;libphp&lt;/code&gt; base is that value minus &lt;code&gt;0x7b47a0&lt;/code&gt;; bucketing the rest by 2 MB chunk recovers the main heap base. Two reads off one response header, and ASLR is gone from outside the process.&lt;/p&gt;
&lt;h3 id=&quot;six-paths-two-execution-models-one-scorecard&quot;&gt;Six paths, two execution models, one scorecard&lt;/h3&gt;
&lt;p&gt;The same primitive reaches code execution through several routes. They are not equal. Some are pure HTTP, one still needs a debugger, and the FPM and mod_php models behave very differently under the same bug. Every path's exact container setup and commands are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php2shell/blob/master/RCE_PATHS.md&quot;&gt;RCE_PATHS.md&lt;/a&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Path &lt;/th&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;What it is &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;&lt;strong&gt;A&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;mod_php &lt;/td&gt;&lt;td&gt;Four-request chain, &lt;code&gt;display_errors=On&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color:#2e7d4f&quot;&gt;shell&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;mod_php &lt;/td&gt;&lt;td&gt;Same chain, stock &lt;code&gt;php.ini-production&lt;/code&gt;. Pure HTTP. &lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color:#2e7d4f&quot;&gt;shell&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;B2&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;mod_php &lt;/td&gt;&lt;td&gt;Path B with a &lt;code&gt;popen&lt;/code&gt; ROP stack instead of a direct call &lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color:#2e7d4f&quot;&gt;shell&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;php-fpm &lt;/td&gt;&lt;td&gt;Session-decode reuse, app-visible leak, ASLR on &lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color:#2e7d4f&quot;&gt;shell&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;C2&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;php-fpm &lt;/td&gt;&lt;td&gt;Path C fired through a &lt;code&gt;popen&lt;/code&gt; ROP pivot, no forged &lt;code&gt;execute_data&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color:#2e7d4f&quot;&gt;shell&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;E&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;mod_php &lt;/td&gt;&lt;td&gt;Multipart partial-bucket teardown (path1) &lt;/td&gt;&lt;td&gt;&lt;span style=&quot;color:#b3362b&quot;&gt;crash is HTTP-only; no native leak and the groom is blocked, so the write needs an external leak or a debugger&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;why-fpm-is-the-harder-target&quot;&gt;Why FPM is the harder target&lt;/h4&gt;
&lt;p&gt;mod_php runs PHP inside a long-lived Apache worker that shares the server's address space and survives across requests. That persistence is exactly what the four-request chain feeds on. php-fpm exits the worker the moment &lt;code&gt;php_request_startup()&lt;/code&gt; fails, so the whole startup-failure class, including path1, simply cannot carry to a second request there. FPM is the more resilient model, and moving to it is good advice.&lt;/p&gt;
&lt;p&gt;It is not a fix. The session-decode reuse (Path C) lives in exactly the pre-RINIT window FPM still has, and I landed a clean &lt;code&gt;uid=33&lt;/code&gt; on a stock FPM container. FPM falls two independent ways, in fact: the forged-frame route above, and a data-only variant (Path C2) that carries no fake &lt;code&gt;execute_data&lt;/code&gt; at all. It overwrites the destructor with a &lt;code&gt;jump_fcontext&lt;/code&gt; stack pivot into a short &lt;code&gt;popen&lt;/code&gt; ROP chain, and fires it through the upload-progress hash update's own old-value destructor rather than the teardown. Same leak, same reuse, no forged frame. &quot;Harder&quot; is the honest word. &quot;Safe&quot; is not.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20261029-php2shell-two-ways-php-corrupts-its-own-memory/php2shell-fpm-pre-rinit.svg&quot; alt=&quot;FPM kills the worker on a failed request startup, which blocks the mod_php chains, but Path C slips through: request A's OOM leaves the unserialize context d dangling at level 1, and request B's upload-progress callback decodes a session before RINIT resets the worker, reusing and overwriting d&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;FPM kills the worker on a failed request startup, which is what blocks the mod_php chains. Path C slips through anyway: the upload-progress decode runs &lt;/em&gt;before&lt;em&gt; RINIT resets the worker, so request A's dangling &lt;code&gt;d&lt;/code&gt; is still there for request B to reuse and overwrite with the same fake-HashTable frame. That pre-RINIT window is CVE-2026-109619.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;One more piece of honesty about the FPM result. The mod_php chains run out of memory at the stock 128 MB limit, driven by the request itself. Nothing on the target has to cooperate. The FPM chains here lean on a lab endpoint that hands back the pointer leak and, for determinism, sets &lt;code&gt;memory_limit&lt;/code&gt; to 4 MB with &lt;code&gt;ini_set()&lt;/code&gt; so a 1 MB session decode is enough to OOM. The 4 MB is a lab convenience, not a requirement. The arming OOM reaches the real 128 MB limit: a session record that declares a huge array count makes the deserializer pre-allocate the whole bucket table, 128 MB from under 5 MB of input, before it parses one element. The constraint the easy framing misses is where the OOM lands. It has to fire inside the session decode, so &lt;code&gt;php_var_unserialize_destroy()&lt;/code&gt; is skipped and the context dangles. An ordinary base64 or image-resize OOM in application code aborts in the wrong place and arms nothing. So the FPM chain needs three things from the target, not one: the pointer leak, an OOM inside session deserialization, and attacker-influenced session content to carry that record. Stock PHP hands over none of them, and the leak is still the hardest.&lt;/p&gt;
&lt;p&gt;A reader flagged that my FPM lab spoke FastCGI straight to php-fpm, a shortcut, not a requirement. So I reworked the lab to do it properly; the repo ships that path now: the same leak and write travel the full route over plain HTTP through a stock nginx with php-fpm unreachable from outside, and still land &lt;code&gt;uid=33&lt;/code&gt;, roughly 6 fresh workers in 10, every miss a clean retry. The FPM result is remote through a normal reverse proxy, not an artifact of reaching php-fpm directly.&lt;/p&gt;
&lt;p&gt;What I did leave on the table, deliberately, were routes that lean on an application &lt;em&gt;weakness&lt;/em&gt;. If the target calls &lt;code&gt;unserialize()&lt;/code&gt; on attacker input the primitive is one request away, but the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.php.net/manual/en/function.unserialize.php&quot;&gt;PHP manual&lt;/a&gt; has warned for years that unserializing untrusted input gets you hacked. That is object injection, the application's bug, not PHP's. I wanted the vulnerability shown in core PHP on a stock install, through mechanisms every install ships, not against someone's vulnerable code.&lt;/p&gt;
&lt;h3 id=&quot;the-barrier-the-pocs-skip-every-stage-needs-the-same-worker&quot;&gt;The barrier the PoCs skip: every stage needs the same worker&lt;/h3&gt;
&lt;p&gt;The chains above share a requirement the lab quietly satisfies. All four requests have to hit the &lt;em&gt;same&lt;/em&gt; PHP worker, because the corrupted state the exploit builds lives in that one worker's memory. With a single worker that is free, and a single worker is where I started. On a production server, with a pool of workers behind a load balancer, landing four requests in a row on one specific worker looks like the hardest part of the attack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In the lab:&lt;/strong&gt; one worker (&lt;code&gt;apache2 -X&lt;/code&gt;, or a single FPM child). Every request lands on it, so the state carries for free. This is where most public PoCs quietly live, and it is not a real deployment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In production:&lt;/strong&gt; many workers, a load balancer in front, other clients in the mix. The four stages scatter across the pool, and the state built on one worker is invisible to the next.&lt;/p&gt;
&lt;p&gt;The obvious fix, HTTP keep-alive to pin one connection to one worker, does not work here. The chain OOMs twice on purpose, and PHP closes the connection on a bailout: the response is &lt;code&gt;HTTP/1.0 500 ... Connection: close&lt;/code&gt; even when the request asked to keep the socket alive, and the next request on it gets EOF. I confirmed it is the SAPI bailout handling and not an unread body: a plain fatal error and a script-level OOM both close the socket the same way. Keep-alive carries a chain of healthy 200s. It cannot carry a chain whose whole point is to run out of memory.&lt;/p&gt;
&lt;p&gt;What does work, in the lab, is process pinning: hold every worker but one busy with slow, half-sent requests, so the single free worker serves all four stages. I got this to a clean shell on mod_php, 9 of 10 prefork children pinned, and on php-fpm, 3 of 4 workers pinned with truncated FastCGI records. Both landed &lt;code&gt;uid=33&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Pinning in a lab is one thing. Against a live server it is the noisy option: other clients hit that free worker too, you rarely know how many workers there are, and a fleet of half-sent connections is the shape a WAF or a bored load balancer drops. But pinning turned out to be unnecessary. Behind a stock-default nginx the php-fpm workers share one listen socket and the kernel hands new connections out in a strict round robin, so on a quiet pool the attacker never lands four stages on one worker: they walk the whole pool. Prime every worker, leak every worker, prime again, fire every worker. That is 2N ordinary complete requests for a pool of N, no slow bodies, nothing held open. I ran this end to end through a stock nginx with php-fpm unreachable from outside, on pools of 2, 4, and 8, and it landed &lt;code&gt;uid=33&lt;/code&gt; on every pool. Live traffic perturbs the round robin and I have not measured the hit rate under load, so this is not a one-shot against a busy server. But it refutes the idea that a worker pool behind default buffering is itself a defense. The barrier that actually holds is the next one.&lt;/p&gt;
&lt;h3 id=&quot;every-number-is-build-specific&quot;&gt;Every number is build-specific&lt;/h3&gt;
&lt;p&gt;This is why the public exploits don't work on your server. The chain is a list of offsets, and every one of them is pinned to a single compiled binary. Change the PHP build, the distro, or the CPU architecture, and they all move. A PoC calibrated on the author's machine is a crash on yours.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Offset &lt;/th&gt;&lt;th&gt;x86-64 &lt;/th&gt;&lt;th&gt;aarch64 (52-bit VA)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;mod_php &lt;code&gt;zval_ptr_dtor&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;libphp+0x7b47a0&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;libphp+0x761b80&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;mod_php &lt;code&gt;zif_system&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;libphp+0x5996e0&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;libphp+0x5314f0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;user-pointer range &lt;/td&gt;&lt;td&gt;&lt;code&gt;0x700000000000&lt;/code&gt; to &lt;code&gt;0x800000000000&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;0xf00000000000&lt;/code&gt; to &lt;code&gt;0x1000000000000&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;BG(unserialize).data&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;bg+0x368&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;bg+0x348&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;fpm leak: &lt;code&gt;BASE&lt;/code&gt; (from leaked pie_ptr) &lt;/td&gt;&lt;td&gt;&lt;code&gt;pie_ptr - 0x7c9777&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;pie_ptr - 0x6e7810&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I ported the two leak-based paths from x86-64 to a stock ARM server and both worked: the mod_php chain gave a shell in &lt;strong&gt;4 runs out of 5&lt;/strong&gt; across randomized ASLR bases, and the FPM chain in &lt;strong&gt;3 out of 3&lt;/strong&gt;. That port did not need new exploitation ideas. PHP's internal struct layout and allocator bin sizes are identical on 64-bit ARM, so the small-bin spray and the heap grooming carried over untouched. What changed was a short list of symbol offsets and one pointer-range constant. The leak absorbs the rest, because it reads the current build's addresses at run time instead of hardcoding them. The full re-derivation method is in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php2shell/blob/master/PORTING.md&quot;&gt;PORTING.md&lt;/a&gt;.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;The fixed-address FPM variant, and why I left it out&lt;/summary&gt;
&lt;p&gt;There is a sixth route I built early and then abandoned: an FPM type-confusion that hardcodes every address instead of leaking one. The worker runs under &lt;code&gt;setarch -R&lt;/code&gt; with ASLR off, so the PIE base and the reclaimed unserialize buffer &lt;code&gt;d&lt;/code&gt; sit at fixed addresses across runs. Two files carry the whole exploit: a session blob &lt;code&gt;sess_T&lt;/code&gt; whose values are &lt;code&gt;d&lt;/code&gt;-relative pointers into a fake &lt;code&gt;var_dtor_entries&lt;/code&gt; table, and a fill payload &lt;code&gt;fillU.bin&lt;/code&gt; that sprays that table into the freed block so the signed-&lt;code&gt;used_slots&lt;/code&gt; write lands on a crafted &lt;code&gt;HashTable&lt;/code&gt;. No leak, no second request to parse one. On the exact build it was tuned against, it drops a shell on the first try.&lt;/p&gt;
&lt;p&gt;It is also the only path I could not reproduce on a clean official image. Nothing about the bug changed; the heap did. On the freshly pulled &lt;code&gt;php:8.5.11-fpm&lt;/code&gt; the reclaimed &lt;code&gt;d&lt;/code&gt; came back &lt;code&gt;0x495000&lt;/code&gt; away from the address baked into &lt;code&gt;sess_T&lt;/code&gt;, so every &lt;code&gt;d&lt;/code&gt;-relative pointer in the payload aimed into unmapped memory. Re-deriving &lt;code&gt;d&lt;/code&gt; was not enough. The fill groom itself mis-landed: the spray reclaimed the &lt;code&gt;d&lt;/code&gt; allocation &lt;i&gt;as its own&lt;/i&gt; &lt;code&gt;zend_string&lt;/code&gt;, which put the string's length word (&lt;code&gt;0x1fea&lt;/code&gt;, 8170) exactly where &lt;code&gt;last_dtor&lt;/code&gt; lives at &lt;code&gt;d+0x10&lt;/code&gt;, and &lt;code&gt;var_tmp_var&lt;/code&gt; then dereferenced 8170 as an entry count and faulted. The fixed address was not stale, it was occupied by the wrong object.&lt;/p&gt;
&lt;p&gt;Fixing that is pure allocator choreography with no new capability at the end of it: re-derive &lt;code&gt;d&lt;/code&gt; under the probe, rebase every pointer in &lt;code&gt;sess_T&lt;/code&gt; and &lt;code&gt;fillU.bin&lt;/code&gt; onto the new base, then re-tune the fill part size and count until the reclaim lands on the freed &lt;code&gt;var_dtor_entries&lt;/code&gt; block instead of on &lt;code&gt;d&lt;/code&gt;, and repeat for the next image release. Path C reaches the same FPM worker with a live leak that reads the current base at run time, so it absorbs all of this for free and ported to ARM with constant edits only (3 shells in 3 runs). The fixed-address variant demonstrates nothing Path C doesn't and breaks on every rebuild, so I left it out of the paths I stand behind. I am keeping the artifacts only as a worked example of how a build-specific heap groom decays, which is the real lesson: an offset is a fact about one binary, not about the bug.&lt;/p&gt;
&lt;/details&gt;
&lt;h3 id=&quot;mitigations-mapped-to-what-they-actually-buy-you&quot;&gt;Mitigations, mapped to what they actually buy you&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Action &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;strong&gt;Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Removes both CVEs at the source: no more bailout corruption, no more dirty-state reuse. This is the fix; everything below is defense in depth.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Windows: the &lt;code&gt;-1&lt;/code&gt; rebuild&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Adds Brotli 1.2.1 for CVE-2026-111497, a separate cURL-reachable OOB write. Same PHP runtime, updated dependency.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Removes the pre-RINIT session consumer the FPM and mod_php session chains rely on. PHP's own stopgap. Does not touch path1.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;mod_php → php-fpm&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Removes the startup-failure class and isolates workers. Not a cure.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Disable unused extensions and settings&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;The habit that pays on the next unknown bug. Every decoder you don't load is a decoder that can't be confused, and the same goes for INI switches you do not need: turn off &lt;code&gt;cgi.fix_pathinfo&lt;/code&gt;, &lt;code&gt;expose_php&lt;/code&gt;, and any feature your application never uses.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Confine the worker (AppArmor / SELinux / seccomp)&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;A profile that stops the PHP worker from spawning a shell blocks the &lt;code&gt;system()&lt;/code&gt; payload, and it bites where &lt;code&gt;disable_functions&lt;/code&gt; cannot: this bug calls the internal handler by address, so &lt;code&gt;disable_functions&lt;/code&gt; is bypassed (I confirmed it fires with &lt;code&gt;system&lt;/code&gt; disabled). Docker makes a tailored profile cheap to apply, though the default Docker profile still allows shell exec. Defense in depth, not a cure: a worker running attacker code can still &lt;code&gt;readfile()&lt;/code&gt; your &lt;code&gt;.env&lt;/code&gt;, keys, and source through PHP and hand them back in the response, no shell needed. Only the upgrade removes the bug.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;how-this-was-done-honestly&quot;&gt;How this was done, honestly&lt;/h3&gt;
&lt;p&gt;A closing note on the effort, because the &quot;hard even with LLMs&quot; line up top deserves receipts. This is the story of actually building the thing, dead-ends and all.&lt;/p&gt;
&lt;p&gt;I did not find this bug, and my first swing at it missed. The night before the patch I heard about it and spent a few late hours trying to reason it out. I got as far as the two parts that matter, a &lt;code&gt;longjmp&lt;/code&gt; on OOM and &lt;code&gt;memory_limit&lt;/code&gt;, and then told myself the wrong story. I was certain it had to route through serialization and the internal flags around it, so that is where I dug, and that is where I stayed stuck. Some of the information floating around online pointed the same wrong way, and I took it at face value. I'll blame the late hour rather than look too hard at my own incompetence 😆.&lt;/p&gt;
&lt;p&gt;Then the patch landed, the shape of the thing got clearer, and the OCD kicked in. I wanted the full what, why, and how, so I set out to reproduce it end to end. The process was deliberate. I wrote up my own analysis first, a long guide and prompt laying out my theory of where the bug lived, how to reproduce it, and the path from there to a shell, then handed the exploration to &lt;strong&gt;DeepSeek V4.1 Flash 1017&lt;/strong&gt;, under one firm rule: when it got stuck or unsure, it stopped and asked me how to proceed instead of guessing. At the dead-ends I gave direction and, a few times, code. We iterated over several days. Whole approaches looked right and went nowhere. The model was a regular, non-abliterated release. No jailbreak, no uncensored fork. A mainstream aligned model, pointed at a patched vulnerability, with a human in the loop the whole way.&lt;/p&gt;
&lt;p&gt;The final validation and this write-up were mine, with Claude Opus. That last mile was the real test of the &quot;hard even with LLMs&quot; claim in the TLDR. Getting a shell on a known-vulnerable container is one thing. Getting the &lt;em&gt;same&lt;/em&gt; chain to fire reliably, re-deriving every offset by hand for a new build, porting it from x86-64 to an ARM machine, that is where the days went. The AI accelerated the work. It did not trivialize it.&lt;/p&gt;
&lt;p&gt;One more thing on the effort, because it matters. I am a PHP core developer. I have been release manager for over 20 PHP releases, I know my way around the engine internals, and I have spent years on the security side. I am not a neophyte poking at a black box, and it still took days of guided work, wrong turns, and hand re-derivation to reach a reliable shell. So when someone tells you they typed &quot;find me an exploit&quot; into a chatbot and out came a working php2shell chain, they are, quite frankly, full of shit. The prompt is not the hard part. The offsets are.&lt;/p&gt;
&lt;h3 id=&quot;what-this-bug-is-and-what-it-isn-t&quot;&gt;What this bug is, and what it isn't&lt;/h3&gt;
&lt;p&gt;php2shell is a genuine unauthenticated RCE, and if you run an unpatched PHP you should treat it as one and upgrade today. It is also a reminder that the distance between &quot;a bug exists&quot; and &quot;a stranger owns your box&quot; is measured in offsets, heap state, and days of patient grooming. The patch deletes the bug the whole path is built on, so there is no path left to walk. That is the only outcome that matters. The researchers who found it did the hard part; the PHP team shipped the fix fast; and the footnote is that even with a capable model doing the grinding, turning this into a reliable shell was work. That gap is not a reason to relax. It is why the upgrade is the whole answer.&lt;/p&gt;
&lt;h4 id=&quot;corrections-welcome&quot;&gt;Corrections welcome&lt;/h4&gt;
&lt;p&gt;This gets more accurate when people who know more poke holes in it. If I missed a path, botched an offset, or told the story wrong, tell me: email [*Emails are not allowed*](mailto:&lt;em&gt;Emails are not allowed&lt;/em&gt;), DM me on X at &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/iliaa&quot;&gt;@iliaa&lt;/a&gt;, or find me in the usual Discords :P.&lt;/p&gt;
&lt;p&gt;Reproduction harness, per-path notes, and the full offset tables live in the companion repository, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php2shell&quot;&gt;github.com/iliaal/php2shell&lt;/a&gt;, which targets the vulnerable 8.5.11 images only. Patched releases are the control in every test.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33099/php2shell-two-ways-php-corrupts-its-own-memory-and-why-turning-that-into-shell-is-still-hard</guid>
<pubDate>Fri, 30 Oct 2026 13:40:16 +0000</pubDate>
</item>
<item>
<title>Seventeen Seconds to Parse One Name: A Security Scan Across Eleven PHP Projects</title>
<link>https://coderlegion.com/32689/seventeen-seconds-to-parse-one-name-a-security-scan-across-eleven-php-projects</link>
<description>&lt;p&gt;This is a 16 KB name:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$row = 'Smith, ' . str_repeat('V ', 8188);
(new \Iliaal\NameParser\Parser())-&amp;gt;parse($row);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On nameparser 1.5.0, the last version before the fix, that call takes 17.4 seconds of CPU on my i9-13950HX. On 1.7.0 it takes 0.064 seconds. Each &lt;code&gt;V&lt;/code&gt; is a candidate roman-numeral suffix, and the old suffix scan walked back over every earlier token for each one, so the cost grew with the square of the input. Nothing about it is exotic. It is a plausible shape for a junk row in a CSV import, and it pins a PHP worker for as long as the row keeps coming.&lt;/p&gt;
&lt;p&gt;I didn't find it. A security scan did, and the same scan ran across all ten of my PHP extensions as well. In the first three weeks of October each project got a full pass: one coordinating session, a set of subagents reading code and writing proof-of-concept exploits, and a separate verifier that re-ran every finding before it counted, on its own ASan and release builds for the C code. Everything below ships in releases from the last few weeks.&lt;/p&gt;
&lt;p&gt;Two other things landed in the same window. PHP's October security releases changed what &lt;code&gt;memory_limit&lt;/code&gt; does, which forced code changes in three extensions and a same-day round of re-releases in five more. And DuckDB 2.0 went GA; pdo_duckdb bundles it as of today.&lt;/p&gt;
&lt;h3 id=&quot;what-the-scans-found-and-what-is-fixed&quot;&gt;What the scans found, and what is fixed&lt;/h3&gt;
&lt;p&gt;Every project in the table has a release that carries its fixes. The counts are findings confirmed by the verifier, fixed, and released; severities are the verifier's, not the scanner's first guess.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Project &lt;/th&gt;&lt;th&gt;Fixed in &lt;/th&gt;&lt;th&gt;Confirmed and fixed&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser/releases/tag/0.7.1&quot;&gt;phpser&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.7.1 &lt;/td&gt;&lt;td&gt;19: 1 high, 5 medium, 13 low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/mdparser/releases/tag/0.8.0&quot;&gt;mdparser&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.8.0 &lt;/td&gt;&lt;td&gt;12: 1 high, 5 medium, 5 low, 1 informational&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastchart/releases/tag/1.8.0&quot;&gt;fastchart&lt;/a&gt; &lt;/td&gt;&lt;td&gt;1.8.0 &lt;/td&gt;&lt;td&gt;15 reproduced, highest medium, including vendored plutovg and plutosvg; two UBSan sites in vendored code only partly addressed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse/releases/tag/0.13.0&quot;&gt;php_clickhouse&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.13.0 &lt;/td&gt;&lt;td&gt;1 high, 1 medium, plus low-severity binding fixes and 7 server-triggered crashes in clickhouse-cpp&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/pdo_duckdb/releases/tag/0.8.0&quot;&gt;pdo_duckdb&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.8.0 &lt;/td&gt;&lt;td&gt;6: 1 high, 1 medium, 1 low-medium, 2 low, 1 informational&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastjson/releases/tag/0.10.0&quot;&gt;fastjson&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.10.0 &lt;/td&gt;&lt;td&gt;4: 1 medium, 3 low, plus 1 documentation correction&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_excel/releases/tag/2.9.0&quot;&gt;php_excel&lt;/a&gt; &lt;/td&gt;&lt;td&gt;2.9.0 &lt;/td&gt;&lt;td&gt;5: 3 low, 2 informational&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/statgrab/releases/tag/2.3.0&quot;&gt;statgrab&lt;/a&gt; &lt;/td&gt;&lt;td&gt;2.3.0 &lt;/td&gt;&lt;td&gt;4: 2 medium, 2 low, plus a build bug&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fast_uuid/releases/tag/0.8.1&quot;&gt;fast_uuid&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.8.1 &lt;/td&gt;&lt;td&gt;5: 4 low, 1 by-design behaviour changed anyway&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phonetic/releases/tag/0.4.4&quot;&gt;phonetic&lt;/a&gt; &lt;/td&gt;&lt;td&gt;0.4.4 &lt;/td&gt;&lt;td&gt;3: 1 medium, 2 low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/nameparser/releases/tag/1.6.0&quot;&gt;nameparser&lt;/a&gt; &lt;/td&gt;&lt;td&gt;1.6.0 &lt;/td&gt;&lt;td&gt;5: 1 medium, 1 low, 3 informational&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A few findings in upstream libraries are reported privately to their maintainers and aren't described here until fixes ship.&lt;/p&gt;
&lt;p&gt;The coordinating model was Mythos 5.1, but most of the work ran on other models: on fastjson, 695 of the 702 assistant messages came from Opus 5.5 subagents doing the reading, fuzzing, and PoC writing. The phpser scan doubled as an A/B/C test of my own tooling against a bare prompt, which I &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/iliaa/status/2109324437501718830&quot;&gt;wrote up on X&lt;/a&gt; at the time. So the table measures the process more than any one model.&lt;/p&gt;
&lt;p&gt;fast_uuid and phonetic came back with zero memory-safety bugs, and php_excel's wrapper with nothing above low severity.&lt;/p&gt;
&lt;h4 id=&quot;bugs-that-needed-no-attacker&quot;&gt;Bugs that needed no attacker&lt;/h4&gt;
&lt;p&gt;The worst php_clickhouse finding in practice wasn't the high-severity one. It was B-4, which corrupted data on ordinary queries. Decimal and DateTime64 decoding cached each column's scale in a memo keyed on the raw address of the column's type object. clickhouse-cpp allocates a fresh type object per column per block and frees the block after each callback, so the next block's first column could land at the same address and inherit the previous column's scale. Select a scale-2 column next to a scale-6 one with &lt;code&gt;max_block_size=100&lt;/code&gt;, and a stored &lt;code&gt;12.34&lt;/code&gt; came back as &lt;code&gt;0.001234&lt;/code&gt;, with no error. With default settings the verifier still saw 1 wrong cell in 70,000 rows.&lt;/p&gt;
&lt;p&gt;The test suite never caught it because ASan builds hide it: the sanitizer's quarantine stops freed memory from being reused, so the address never repeats. It took a release build and a real server. 0.13.0 keys the memo correctly.&lt;/p&gt;
&lt;p&gt;0.13.0 also fixes a behaviour you should check for before upgrading. A whole-number insert into a Decimal column, &lt;code&gt;7&lt;/code&gt; into &lt;code&gt;Decimal(9,2)&lt;/code&gt;, now stores &lt;code&gt;7.00&lt;/code&gt; as the server does; 0.12 stored &lt;code&gt;0.07&lt;/code&gt;. Code that compensated by passing pre-scaled integers (&lt;code&gt;700&lt;/code&gt; meaning 7.00) will now store &lt;code&gt;700.00&lt;/code&gt; without complaint.&lt;/p&gt;
&lt;p&gt;fast_uuid's main finding has the same character. &lt;code&gt;uuid1()&lt;/code&gt; and &lt;code&gt;uuid6()&lt;/code&gt; with a fixed node and clock sequence kept no last-tick state, so &lt;code&gt;uuid1(1, 7)&lt;/code&gt; called 500,000 times produced 61,723 duplicates. The default arguments were fine. 0.8.1 fixes it.&lt;/p&gt;
&lt;h4 id=&quot;bugs-a-server-a-file-or-a-document-could-trigger&quot;&gt;Bugs a server, a file, or a document could trigger&lt;/h4&gt;
&lt;p&gt;php_clickhouse's high-severity finding was a heap overflow write. With compression enabled, a malicious or compromised server could send a compressed frame whose declared size was smaller than the 9-byte frame header, and the client wrote past its buffer with bytes the server chose. clickhouse-cpp had already fixed it on master in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/clickhouse-cpp/pull/566&quot;&gt;PR #566&lt;/a&gt;, but no clickhouse-cpp release contains the fix yet, and php_clickhouse's prebuilt binaries ship the library. 0.13.0 carries the patch. The same release rejects seven more server-triggered crash shapes, from &lt;code&gt;FixedString(0)&lt;/code&gt; columns to &lt;code&gt;Array&lt;/code&gt; offsets that run backwards, and stops a packet of a few dozen bytes from committing gigabytes of memory outside &lt;code&gt;memory_limit&lt;/code&gt;. I sent the fixes upstream as 15 &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/clickhouse-cpp/pulls?q=author%3Ailiaal&quot;&gt;pull requests&lt;/a&gt;; three have merged so far and twelve are open.&lt;/p&gt;
&lt;p&gt;fastchart's two medium findings were both resource bombs. A 44 KB SVG passed to &lt;code&gt;svgTo*()&lt;/code&gt; reached 2,121,264 KiB of resident memory through data URIs and &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; references that slipped past the input gate, all of it outside &lt;code&gt;memory_limit&lt;/code&gt;; a 30,000-deep &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; chain crashed the process. A 1 MB CgBI PNG with decoy dimensions decoded into a 16000 by 16000 buffer. 1.8.0 runs the gate on the parsed tree, bounds &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; depth and expansion, and caps decoded images at 4,096 pixels a side. Some heavy but legitimate SVG that used to render slowly now throws a &lt;code&gt;ValueError&lt;/code&gt; instead, so test your largest inputs.&lt;/p&gt;
&lt;p&gt;mdparser's high-severity finding was quadratic parsing: blank lines inside deeply nested lists took 1.7 seconds at 200 KB and 55 seconds at 1 MB. Seven of mdparser's thirteen findings had their root cause in md4c rather than in my code, and the fixes went upstream; md4c merged five of my pull requests for them on October 13. One remaining low-severity signed overflow is still waiting in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mity/md4c/pull/374&quot;&gt;md4c#374&lt;/a&gt;. 0.8.0 also changes generated heading IDs to start with &lt;code&gt;user-content-&lt;/code&gt;, which closes a DOM-clobbering finding and breaks existing links to heading fragments when &lt;code&gt;headingAnchors&lt;/code&gt; is on.&lt;/p&gt;
&lt;h4 id=&quot;the-other-high-and-medium-findings&quot;&gt;The other high and medium findings&lt;/h4&gt;
&lt;p&gt;pdo_duckdb's high finding was a use-after-free: an appender left open inside a reference cycle could outlive its PDO handle when &lt;code&gt;gc_collect_cycles()&lt;/code&gt; or request shutdown freed the handle first. Two of the medium-range findings were the same shape. A &lt;code&gt;memory_limit&lt;/code&gt; bailout at the wrong moment during column setup left uninitialised type pointers that were destroyed later; the PoC got the sanitizer to report a destructor running on &lt;code&gt;0x4141414141414141&lt;/code&gt;. 0.8.0 fixes all six.&lt;/p&gt;
&lt;p&gt;phpser's high finding was that a nested SPL &lt;code&gt;unserialize()&lt;/code&gt; ignored &lt;code&gt;allowed_classes&lt;/code&gt;. Its legacy handler path ran outside the decoder's context, so a class allowlist set on the outer call never reached the inner one. 0.7.1 fixes it along with eighteen others, most of them bounds on work an untrusted payload could demand.&lt;/p&gt;
&lt;p&gt;nameparser's quadratic case, from the top of this post, is fixed in 1.6.0. Its release notes say plainly that earlier notes describing hostile-input parsing as linear &quot;did not hold for these shapes&quot;. phonetic's medium finding was a 14-byte input, &lt;code&gt;bmpm(&quot;de akeaxhaeasz&quot;)&lt;/code&gt;, that caused an uncatchable fatal error. 0.4.4 turns budget overruns into a catchable &lt;code&gt;ValueError&lt;/code&gt; and caps BMPM output at 256 times the input size, after a 1,560-byte input had produced 3.9 MB.&lt;/p&gt;
&lt;h3 id=&quot;memory-limit-stopped-stopping-things&quot;&gt;memory_limit stopped stopping things&lt;/h3&gt;
&lt;p&gt;PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 shipped on October 21 with the fix for &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w&quot;&gt;CVE-2026-109618&lt;/a&gt;, rated critical. Part of that fix changes what happens when an allocation crosses &lt;code&gt;memory_limit&lt;/code&gt;. The engine used to bail out on the spot with a longjmp. Now it records the error, lets the allocation succeed, and raises the fatal at the next safe point in the VM.&lt;/p&gt;
&lt;p&gt;For PHP code that's invisible. For an extension it isn't. A C loop that decodes a payload or builds rows never reaches a VM safe point, so it keeps allocating past &lt;code&gt;memory_limit&lt;/code&gt; until it hits a hard limit, by default twice &lt;code&gt;memory_limit&lt;/code&gt; below 1 GiB. At the hard limit the engine calls &lt;code&gt;_exit()&lt;/code&gt;: no shutdown functions, no destructors, no session write, and php-fpm loses the worker instead of failing one request.&lt;/p&gt;
&lt;p&gt;So every loop whose length comes from input has to notice the pending error and stop on its own. This is how phpser does it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;/* PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 queue a memory_limit error instead of
 * bailing out; the allocation still succeeds until a hard limit (2x the soft
 * limit below 1G) that _exit()s the process. Loops that keep allocating must
 * stop on their own once the error is pending. */
#ifdef ZEND_HAS_OOM
# define PHPSER_HAS_OOM() ZEND_HAS_OOM()
#else
# define PHPSER_HAS_OOM() 0
#endif
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A loop check cannot help when a single allocation is sized from a count the payload declares. Those go through the new fallible allocators instead (&lt;code&gt;zend_hash_real_finit(..., true)&lt;/code&gt;, &lt;code&gt;safe_femalloc&lt;/code&gt;, &lt;code&gt;fecalloc&lt;/code&gt;), which queue the error and return &lt;code&gt;NULL&lt;/code&gt; rather than running into the hard limit. phpser 0.10.0 uses both. mdparser 0.8.1 and pdo_duckdb 0.9.0 do the same for their input-sized loops and presizes.&lt;/p&gt;
&lt;h4 id=&quot;the-re-release-day&quot;&gt;The re-release day&lt;/h4&gt;
&lt;p&gt;There is a catch. The October headers route &lt;code&gt;smart_str&lt;/code&gt; growth through a new engine function, &lt;code&gt;smart_str_ferealloc()&lt;/code&gt;, from inline header code. An extension compiled against 8.4.27 headers imports that symbol even if it never mentions it, and 8.4.26 doesn't export it. My release workflows built prebuilt binaries against the newest patch of each PHP minor, which had always been the safe choice.&lt;/p&gt;
&lt;p&gt;On October 26 that meant mdparser 0.8.1, phpser 0.10.0, fastjson 0.10.1, php_clickhouse 0.13.1, and phonetic 0.4.5 all shipped Linux and macOS binaries that died with &lt;code&gt;undefined symbol: smart_str_ferealloc&lt;/code&gt; on every PHP 8.4 before 8.4.27 and every 8.5 before 8.5.12. Source builds were fine, because they compile against the PHP they load into. All five were re-released the same day as mdparser 0.8.2, phpser 0.10.1, fastjson 0.10.2, php_clickhouse 0.13.2, and phonetic 0.4.6, and fastchart 1.8.1 picked up the same change before it could break. Their prebuilts now compile against the oldest patch of each minor (8.4.1, since 8.4.0 was never released, and 8.5.0), and each binary has to load on both the oldest and the newest patch before anything uploads.&lt;/p&gt;
&lt;p&gt;That fix has a cost, and the phpser changelog states it: a binary built against 8.4.1 headers doesn't have the &lt;code&gt;memory_limit&lt;/code&gt; stop. To get it, you build from source on a patched PHP.&lt;/p&gt;
&lt;h4 id=&quot;refusing-vulnerable-php-releases&quot;&gt;Refusing vulnerable PHP releases&lt;/h4&gt;
&lt;p&gt;The longer-term answer is the opposite trade. pdo_duckdb 0.9.0 makes it today: its prebuilt binaries are built against the patched headers and require PHP 8.2.35, 8.3.36, 8.4.27, or 8.5.12 or newer. Load one on 8.4.26 and the process stops at the first call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;php: symbol lookup error: /ext/pdo_duckdb.so: undefined symbol: smart_str_ferealloc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That message is honest but unfriendly, and it arrives at runtime rather than at startup. The next releases of pdo_duckdb, phpser, and mdparser fix that with a startup check. On PHP 8.3 and newer, the module compares the running patch with the one it was compiled against and refuses to start on an older one, naming both versions. This is mdparser's:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;if (php_version_id() &amp;lt; PHP_VERSION_ID) {
    zend_error(E_CORE_WARNING,
        &quot;mdparser was built for PHP %s and cannot run on PHP %s; &quot;
        &quot;install a build for this PHP version or build from source&quot;,
        PHP_VERSION, php_version());
    return FAILURE;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that check in place, pdo_duckdb keeps the binaries it already builds against the patched headers, and phpser and mdparser can move theirs back to the patched headers and regain the &lt;code&gt;memory_limit&lt;/code&gt; stop. A server still on 8.4.26 gets a clear startup warning instead of a crash on the first request, and with a critical CVE fixed in 8.4.27 it should move anyway.&lt;/p&gt;
&lt;h3 id=&quot;pdo-duckdb-0-9-0-duckdb-2-0&quot;&gt;pdo_duckdb 0.9.0: DuckDB 2.0&lt;/h3&gt;
&lt;p&gt;DuckDB 2.0.0 went GA on October 21, and pdo_duckdb 0.9.0, out today, bundles it in every prebuilt binary. 0.8.0 had already built against the 2.0 previews. Source builds still work against DuckDB 1.5.3 and newer; &lt;code&gt;configure&lt;/code&gt; detects 2.0 from &lt;code&gt;duckdb_v2.h&lt;/code&gt;, because the 2.0 headers still report C API version 1.5.6, and it refuses to build when 2.0 headers meet an older &lt;code&gt;libduckdb&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The upgrade is not transparent, and the README has a full section on it. The one most likely to bite is the file format. DuckDB 2.0 writes new files at storage version 69, and DuckDB 1.5 reads only 64 to 68. If a 1.5 process has to read the file too, ask for the older format when you open it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$db = new PDO('duckdb:/data/shared.duckdb;storage_compatibility_version=v1.5.6');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the 0.9.0 binary, a file opened that way reports &lt;code&gt;v1.5.0+&lt;/code&gt; in &lt;code&gt;duckdb_databases()&lt;/code&gt;, and a default one reports &lt;code&gt;v2.0.0+&lt;/code&gt;. Existing 1.5 files keep their format after 2.0 writes to them. The rest is DuckDB's own behaviour: a failed statement now invalidates the whole transaction, integer division by zero throws, single-arrow lambdas are rejected, and the new PEG parser is the default. That last one has a sharp edge for parameter binding: &lt;code&gt;$1_0&lt;/code&gt; means parameter 10 under PEG and parameter 1 under the legacy parser. Each change has a setting that restores 1.5 behaviour.&lt;/p&gt;
&lt;p&gt;0.9.0 also adds &lt;code&gt;PDO::DUCKDB_ATTR_NATIVE_STRUCTURES&lt;/code&gt;, an opt-in that fetches LIST, ARRAY, STRUCT, TUPLE, MAP, UNION, JSON, and VARIANT values as lossless PHP structures instead of canonical strings, with exact numerics, binary keys, and SQL &lt;code&gt;NULL&lt;/code&gt; kept distinct from VARIANT null. VARIANT works top-level and nested, and the appender accepts PHP arrays and objects for it.&lt;/p&gt;
&lt;p&gt;One fix in 0.9.0 matters even if you never touch 2.0: two PDO connections to the same database file could lose writes and see stale reads. Equivalent paths now share one DuckDB instance while transactions stay connection-local.&lt;/p&gt;
&lt;h3 id=&quot;performance-with-the-hardware-attached&quot;&gt;Performance, with the hardware attached&lt;/h3&gt;
&lt;p&gt;Most of the speed work came before the scans. Apart from the nameparser row, which I re-ran for this post, these are the numbers from each project's changelog with the conditions it states.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Project &lt;/th&gt;&lt;th&gt;Change &lt;/th&gt;&lt;th&gt;Measured on&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;nameparser 1.6.0 &lt;/td&gt;&lt;td&gt;16 KB hostile row: 17.4 s to 0.064 s &lt;/td&gt;&lt;td&gt;i9-13950HX, PHP 8.4.27 (my rerun of 1.5.0 vs 1.7.0)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;phpser 0.6.0 &lt;/td&gt;&lt;td&gt;&lt;code&gt;range(0, 9999)&lt;/code&gt; encodes to 7 bytes; encode 57% faster, decode 66% faster &lt;/td&gt;&lt;td&gt;aarch64&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;phpser 0.7.0 &lt;/td&gt;&lt;td&gt;Signed decode 53 to 85% faster; 1,000-row rowset decodes in 429 KB instead of 645 KB &lt;/td&gt;&lt;td&gt;aarch64&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;fastjson 0.9.0 &lt;/td&gt;&lt;td&gt;&lt;code&gt;fastjson_validate()&lt;/code&gt; costs 0.47 to 0.90x a full parse, down from 1.1 to 1.9x &lt;/td&gt;&lt;td&gt;Neoverse-N1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;fastjson 0.9.0 to 0.10.0 &lt;/td&gt;&lt;td&gt;Key-heavy decode peak memory about 15% lower; file decode peak 9 to 33% lower &lt;/td&gt;&lt;td&gt;Neoverse-N1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;fast_uuid 0.8.0 &lt;/td&gt;&lt;td&gt;&lt;code&gt;uuid_v4()&lt;/code&gt; 76 to 42 ns; &lt;code&gt;uuid_v4_bin_batch(100)&lt;/code&gt; 61 to 27 ns per UUID; &lt;code&gt;uuid_v7_bin_batch()&lt;/code&gt; 25.9 to 16.6 ns &lt;/td&gt;&lt;td&gt;Neoverse-N1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;mdparser 0.6.2 &lt;/td&gt;&lt;td&gt;&lt;code&gt;toAst()&lt;/code&gt; 5 to 9% faster; retained AST memory 47% lower on softbreak-dense input; code-heavy &lt;code&gt;toXml()&lt;/code&gt; output 49% smaller in memory &lt;/td&gt;&lt;td&gt;aarch64, PHP 8.4.25&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;php_clickhouse 0.12.0 &lt;/td&gt;&lt;td&gt;Temporal, string, and IP inserts about 19% faster; IPv4 selects about 12% &lt;/td&gt;&lt;td&gt;200,000-row probes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The phpser integer-run encoding is the one I'd point at. A run with a constant step is stored as a start, a step, and a count, so &lt;code&gt;range(0, 9999)&lt;/code&gt; encodes to 7 bytes and &lt;code&gt;range(0, 999999)&lt;/code&gt; to 8. Native &lt;code&gt;serialize()&lt;/code&gt; needs 137,790 bytes for the first one.&lt;/p&gt;
&lt;p&gt;fast_uuid's speedup comes from refilling its random buffer with a userspace AES-256-CTR generator with fast key erasure, instead of a &lt;code&gt;getrandom()&lt;/code&gt; call per 8 KiB, on CPUs with AES instructions. It has one caveat, and the changelog states it: clones of one VM snapshot can repeat up to 64 KiB of output per thread.&lt;/p&gt;
&lt;h3 id=&quot;breaking-changes-worth-reading-before-you-upgrade&quot;&gt;Breaking changes worth reading before you upgrade&lt;/h3&gt;
&lt;p&gt;Most of these releases are drop-in. These are not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;php_clickhouse 0.13.0: whole-number Decimal inserts scale like the server's (above).&lt;/li&gt;
&lt;li&gt;mdparser 0.8.0: heading IDs carry a &lt;code&gt;user-content-&lt;/code&gt; prefix.&lt;/li&gt;
&lt;li&gt;phpser 0.10.0: Closures and other unserializable objects throw instead of quietly encoding &lt;code&gt;NULL&lt;/code&gt;. Check what you put in &lt;code&gt;$_SESSION&lt;/code&gt; before upgrading. New wire tags since 0.7.0 mean readers must upgrade before writers.&lt;/li&gt;
&lt;li&gt;php_excel 2.9.0 and 2.9.1: dates use PHP's timezone rather than the OS one, and text is UTF-8 regardless of the process locale. Apps that relied on the locale to pass non-UTF-8 bytes through will see different output. 2.7.0 also rejects a font from a different &lt;code&gt;ExcelBook&lt;/code&gt; in &lt;code&gt;addText()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;fastchart 1.8.0: heavy SVG that used to render slowly may now throw &lt;code&gt;ValueError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;phonetic 0.4.4: oversized BMPM and Daitch-Mokotoff work throws a catchable &lt;code&gt;ValueError&lt;/code&gt; rather than ending the request.&lt;/li&gt;
&lt;li&gt;statgrab 2.2.2: &lt;code&gt;Statgrab::ERROR_UNSUPPORTED&lt;/code&gt; is 44, matching libstatgrab; it was 41.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On the feature side, phpser 0.8.0 and 0.9.0 added signed sessions (&lt;code&gt;phpser.session_key&lt;/code&gt;, HMAC-SHA256), an APCu serializer (&lt;code&gt;apc.serializer=phpser&lt;/code&gt;), keyed payloads, and an exported C API. nameparser 1.4.0 understands joint salutations, so &lt;code&gt;Mr. and Mrs. Brad Smith&lt;/code&gt; keeps &lt;code&gt;Brad&lt;/code&gt; as the first name, with new &lt;code&gt;isJoint()&lt;/code&gt;, &lt;code&gt;getSalutations()&lt;/code&gt;, and &lt;code&gt;getPartner()&lt;/code&gt;, and 1.4.1 raised first-and-last accuracy on 30,000 NPPES provider rows from 95.94% to 97.18%. statgrab 2.3.1 fixes a heap overflow in the bundled libstatgrab's Linux command-line handling that the scan didn't cover.&lt;/p&gt;
&lt;h3 id=&quot;what-a-scan-is-worth&quot;&gt;What a scan is worth&lt;/h3&gt;
&lt;p&gt;None of these projects were neglected before October. Each had a test suite and documentation that said where its limits were. The scans still found an allowlist that didn't reach a nested call, a Decimal that came back ten thousand times too small, and a 16 KB name that took 17 seconds. Two of the most consequential findings sat in code I had already called linear or already tested under ASan, and for the Decimal bug the ASan testing is exactly what hid it.&lt;/p&gt;
&lt;p&gt;The fixes are released and listed above. Upgrade pdo_duckdb if you are moving to DuckDB 2.0, php_clickhouse if you talk to a server you don't control, and PHP itself to 8.4.27 or 8.5.12 whatever else you do.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32689/seventeen-seconds-to-parse-one-name-a-security-scan-across-eleven-php-projects</guid>
<pubDate>Tue, 27 Oct 2026 13:32:42 +0000</pubDate>
</item>
<item>
<title>What SafeLine's PHP Core Rule Actually Watches in the Request</title>
<link>https://coderlegion.com/32462/what-safelines-php-core-rule-actually-watches-in-the-request</link>
<description>&lt;p&gt;The PHP Core rule SafeLine shipped on 21 October is not a generic &quot;block all uploads&quot; switch. It is aimed at request features from the known chain behind CVE-2026-109618 and CVE-2026-109619.&lt;/p&gt;
&lt;p&gt;Four signs are called out in the rule note. A request that carries &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;. A &lt;code&gt;PHPSESSID&lt;/code&gt; that fits the characteristic pattern. A content type of &lt;code&gt;multipart/form-data&lt;/code&gt;. A body that combines abnormal form fields with suspicious content. Any one of those can appear in legitimate software. The rule is about the combination associated with the chain, which is why the first setting is observe, not drop.&lt;/p&gt;
&lt;p&gt;That focus follows from CVE-2026-109618, which is about multipart parsing and memory state. Upload and form posts are the traffic you should be able to name in a log: time, source address, site, path, and the rule name PHPCoreRCECVE-2026-109618/109619. The write-up is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://rivers.chaitin.cn/discussion/dbc7qkj1s5rlia466a30&quot;&gt;the rule discussion&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;CVE-2026-109619 is the other half. Worker global state can linger between requests. The WAF rule will not print the worker's memory. It will tell you which HTTP client was sending the chain's request shape at the time a pool misbehaved. Use both logs.&lt;/p&gt;
&lt;p&gt;Import the rule from the SafeLine rule library. Set the action to blacklist. Enable observe mode. Review a sample of hits against a known-good upload from your own application. If your application itself sends &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; on a normal photo upload, you need that fact before you block, not after.&lt;/p&gt;
&lt;p&gt;When the hits are probes rather than product traffic, move the rule to an intercept action and keep the event record for the follow-up on disk and credentials.&lt;/p&gt;
&lt;h4 id=&quot;does-the-rule-fire-on-every-multipart-post-&quot;&gt;Does the rule fire on every multipart post?&lt;/h4&gt;
&lt;p&gt;It is built around the known chain's features, not every form on the internet.&lt;/p&gt;
&lt;h4 id=&quot;what-should-a-useful-hit-contain-&quot;&gt;What should a useful hit contain?&lt;/h4&gt;
&lt;p&gt;Time, source IP, site, path, and the rule name. Those are the fields worth capturing in the event view.&lt;/p&gt;
&lt;h4 id=&quot;can-i-skip-observe-mode-if-i-trust-the-signature-&quot;&gt;Can I skip observe mode if I trust the signature?&lt;/h4&gt;
&lt;p&gt;You can, but the published guidance is to observe first so a real upload path is not the thing you discover by outage.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32462/what-safelines-php-core-rule-actually-watches-in-the-request</guid>
<pubDate>Mon, 26 Oct 2026 04:17:15 +0000</pubDate>
</item>
<item>
<title>What to Inspect After a PHP Core Rule Hit</title>
<link>https://coderlegion.com/32463/what-to-inspect-after-a-php-core-rule-hit</link>
<description>&lt;p&gt;A SafeLine hit on the PHP Core rule is a starting point, not a closed case. CVE-2026-109618 and CVE-2026-109619 are serious because a crafted request can disturb the runtime or leave worker state behind. If the rule fires, or if the server looks wrong without a clean explanation, widen the search the same day.&lt;/p&gt;
&lt;p&gt;Start with the event SafeLine already stored: attack time, source address, site, path, and the rule name. The 21 October rule is documented at &lt;a rel=&quot;nofollow&quot; href=&quot;https://rivers.chaitin.cn/discussion/dbc7qkj1s5rlia466a30&quot;&gt;the rule page&lt;/a&gt;. Match that minute to the web access log and the PHP error log. You are looking for the same client, the same path, and a runtime fault.&lt;/p&gt;
&lt;p&gt;Then look at files. Check the web root, the upload directory, and the temporary directory PHP uses for multipart bodies. Look for PHP files that appeared or changed recently and that no deploy can explain. A new file next to an upload endpoint is more interesting than a hit with no file change, but both deserve a note.&lt;/p&gt;
&lt;p&gt;Process and network checks come next. Unknown processes, unexpected outbound connections, and cron entries you did not add are reasons to treat the host as suspect. If that bar is met, rotate high-privilege accounts, database accounts, and API keys. Do not rotate everything on the first observe-mode hit from a scanner. Do rotate when logs and files agree that something landed.&lt;/p&gt;
&lt;p&gt;Keep the rule in observe until you know your own upload traffic, then move it to block. Upgrade the PHP pool that served the hit. Restart that pool. Confirm the site still logs in and still accepts a normal file. The rule watches the chain. The upgrade removes the bug the chain is aimed at.&lt;/p&gt;
&lt;h4 id=&quot;what-is-the-minimum-record-to-keep-&quot;&gt;What is the minimum record to keep?&lt;/h4&gt;
&lt;p&gt;Time, source IP, site, path, rule name, and whether a file changed.&lt;/p&gt;
&lt;h4 id=&quot;should-every-hit-trigger-a-credential-rotation-&quot;&gt;Should every hit trigger a credential rotation?&lt;/h4&gt;
&lt;p&gt;No. Rotate when logs or files show more than a probe.&lt;/p&gt;
&lt;h4 id=&quot;does-blocking-replace-the-php-upgrade-&quot;&gt;Does blocking replace the PHP upgrade?&lt;/h4&gt;
&lt;p&gt;No. Blocking is a control at the proxy. The runtime still has to be patched.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32463/what-to-inspect-after-a-php-core-rule-hit</guid>
<pubDate>Mon, 26 Oct 2026 04:17:14 +0000</pubDate>
</item>
<item>
<title>How to Inventory Every PHP Version Before You Call the Estate Patched</title>
<link>https://coderlegion.com/32461/how-to-inventory-every-php-version-before-you-call-the-estate-patched</link>
<description>&lt;p&gt;The failure mode in this PHP advisory is not &quot;we upgraded PHP.&quot; It is &quot;we upgraded one PHP and left three others running.&quot; CVE-2026-109618 and CVE-2026-109619 are in PHP Core, so every branch on the host is its own decision.&lt;/p&gt;
&lt;p&gt;Build a table before you patch. Columns that earn their keep: site name, public host, PHP-FPM pool, binary path, version, owner, and status. Status should be one of upgraded, waiting, or cannot upgrade, with a reason in the last case. A shared server that still has an old pool for a forgotten landing page is still exposed.&lt;/p&gt;
&lt;p&gt;Do the upgrade in a quiet window. Back up site files, the database, and the pool config first. After the package change, restart PHP or PHP-FPM and prove the new version with the same binary the pool uses, not with a &lt;code&gt;php&lt;/code&gt; that happens to be first on a login shell.&lt;/p&gt;
&lt;p&gt;Then test the paths that exercise the bugs: home page, login, upload, and a normal form post. Read the PHP error log for a new exception. Walk the process list and confirm no pool is still bound to the old build.&lt;/p&gt;
&lt;p&gt;SafeLine does not replace that inventory. The 21 October rule gives you a place to watch suspicious multipart uploads while the list is incomplete. Set the action to blacklist, enable observe mode, and read hits instead of blocking the estate on hour one. The rule page is &lt;a rel=&quot;nofollow&quot; href=&quot;https://rivers.chaitin.cn/discussion/dbc7qkj1s5rlia466a30&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;When the inventory shows a site that cannot move yet, keep that host's rule in observe, restrict who can reach the origin, and do not treat the WAF note as a permanent patch.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-know-which-version-a-vhost-uses-&quot;&gt;How do I know which version a vhost uses?&lt;/h4&gt;
&lt;p&gt;Read the pool config and the binary that pool executes, not the default CLI PHP.&lt;/p&gt;
&lt;h4 id=&quot;can-one-safeline-rule-cover-every-old-branch-&quot;&gt;Can one SafeLine rule cover every old branch?&lt;/h4&gt;
&lt;p&gt;It can watch the request pattern. It does not upgrade the branch.&lt;/p&gt;
&lt;h4 id=&quot;what-belongs-in-the-cannot-upgrade-note-&quot;&gt;What belongs in the &quot;cannot upgrade&quot; note?&lt;/h4&gt;
&lt;p&gt;The business reason and the compensating control, such as observe mode plus a closed origin.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32461/how-to-inventory-every-php-version-before-you-call-the-estate-patched</guid>
<pubDate>Mon, 26 Oct 2026 03:47:14 +0000</pubDate>
</item>
<item>
<title>Why Multipart Uploads Need a Separate Look After CVE-2026-109618</title>
<link>https://coderlegion.com/32460/why-multipart-uploads-need-a-separate-look-after-cve-2026-109618</link>
<description>&lt;p&gt;File upload is the practical entry for CVE-2026-109618. The weakness is in PHP's handling of &lt;code&gt;multipart/form-data&lt;/code&gt; and the memory state around that parse, not in a single shopping-cart plugin.&lt;/p&gt;
&lt;p&gt;An attacker does not need a valid image. They need a request that looks like a form upload and stresses the parser: odd boundaries, unexpected fields, or a progress token the application never meant to honor. The result called out in the advisory is service disruption or a runtime fault, which is enough to create a window for a follow-on web attack.&lt;/p&gt;
&lt;p&gt;SafeLine's 21 October rule looks for that shape on the wire. The signals called out in the rule note are a &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; parameter, a &lt;code&gt;PHPSESSID&lt;/code&gt; that matches the known pattern, a &lt;code&gt;multipart/form-data&lt;/code&gt; content type, and a body that pairs abnormal fields with suspicious content. Details are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://rivers.chaitin.cn/discussion/dbc7qkj1s5rlia466a30&quot;&gt;rule discussion&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Turn the rule action to blacklist and leave observe mode on. Observe mode writes the event and keeps the source and request. It does not drop the request. That matters on checkout, profile photo, and ticket-attachment routes, where a hard block on day one will page the wrong team.&lt;/p&gt;
&lt;p&gt;While the rule watches, do the origin work. Back up files, the database, and the PHP pool config. Upgrade the PHP branch that pool actually uses. Restart PHP-FPM. Then click through home, login, upload, and form submit. Read the PHP error log for a new fault, and confirm no second vhost is still pinned to the old binary.&lt;/p&gt;
&lt;p&gt;If the same address keeps changing the body of upload posts, treat that as probing even when the rule is still in observe. Only after that picture is stable should you switch the rule from record-only to block.&lt;/p&gt;
&lt;h4 id=&quot;is-every-upload-form-affected-&quot;&gt;Is every upload form affected?&lt;/h4&gt;
&lt;p&gt;Any PHP path that accepts multipart bodies is in scope until that site's runtime is confirmed current.&lt;/p&gt;
&lt;h4 id=&quot;will-observe-mode-stop-the-exploit-&quot;&gt;Will observe mode stop the exploit?&lt;/h4&gt;
&lt;p&gt;No. It records. Blocking is a later, separate change.&lt;/p&gt;
&lt;h4 id=&quot;what-should-be-upgraded-first-&quot;&gt;What should be upgraded first?&lt;/h4&gt;
&lt;p&gt;The PHP version serving the upload vhost, not the CMS theme.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32460/why-multipart-uploads-need-a-separate-look-after-cve-2026-109618</guid>
<pubDate>Mon, 26 Oct 2026 03:44:13 +0000</pubDate>
</item>
<item>
<title>Finding the slow part of a WooCommerce page with Query Monitor</title>
<link>https://coderlegion.com/32348/finding-the-slow-part-of-a-woocommerce-page-with-query-monitor</link>
<description>&lt;p&gt;Query Monitor is a free WordPress plugin by John Blackbourn. It adds a panel to the admin toolbar that shows what one page load did: database queries, hooks, HTTP calls, PHP errors and the template that rendered the page. On a WooCommerce store it answers the usual question, &quot;which plugin makes the product page slow&quot;, with numbers instead of guesses.&lt;/p&gt;
&lt;h3 id=&quot;install-it-on-staging-first&quot;&gt;Install it on staging first&lt;/h3&gt;
&lt;p&gt;Query Monitor shows its panel to administrators only, so customers on a live store never see it. It still does its work on every request you make while logged in, which is why staging is the better home for it. Install it from the plugin directory and activate it. The toolbar then shows four figures for the current page: generation time, peak memory, database query time and the number of queries.&lt;/p&gt;
&lt;h3 id=&quot;read-the-queries-by-component&quot;&gt;Read the queries by component&lt;/h3&gt;
&lt;p&gt;Open the panel and go to Queries by Component. Every query is attributed to the plugin, theme or core file that made it, with a count and a total time. Sorting by time usually points at one or two plugins.&lt;/p&gt;
&lt;p&gt;Duplicate Queries is the next view to open. The same query repeated dozens of times in one request often comes from a loop that reads product meta one product at a time.&lt;/p&gt;
&lt;p&gt;Slow Queries lists every query that took longer than 0.05 seconds. That threshold is the QM_DB_EXPENSIVE constant, and you can lower it in wp-config.php when the whole page is slow but no single query stands out.&lt;/p&gt;
&lt;h3 id=&quot;check-outgoing-http-calls&quot;&gt;Check outgoing HTTP calls&lt;/h3&gt;
&lt;p&gt;The HTTP API Calls panel lists the requests a page sent to other servers while it was being built. Licence checks, currency rates and shipping quotes are the usual suspects. A plugin that calls an external API on every product page adds that server's response time to yours. For each call the panel shows the URL, the response code and how long it took.&lt;/p&gt;
&lt;h3 id=&quot;time-your-own-code&quot;&gt;Time your own code&lt;/h3&gt;
&lt;p&gt;Query Monitor also has a small profiling API. Wrap a block of code in two actions and it shows up in the Timings panel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;do_action( 'qm/start', 'shipping_estimates' );
// the code you want to measure
do_action( 'qm/stop', 'shipping_estimates' );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a value you want to inspect, &lt;code&gt;do_action( 'qm/debug', $variable );&lt;/code&gt; sends it to the Logs panel instead of the page output.&lt;/p&gt;
&lt;h3 id=&quot;look-at-a-page-as-a-guest&quot;&gt;Look at a page as a guest&lt;/h3&gt;
&lt;p&gt;Some slowness only appears for logged-out visitors, for example when a cache plugin treats them differently. The Query Monitor settings can set an authentication cookie, and with it the panel keeps working in the same browser after you log out.&lt;/p&gt;
&lt;h3 id=&quot;when-you-are-done&quot;&gt;When you are done&lt;/h3&gt;
&lt;p&gt;Deactivate the plugin once the slow part is found. It is light, but there is no reason to keep it running on a store that nobody is debugging.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/32348/finding-the-slow-part-of-a-woocommerce-page-with-query-monitor</guid>
<pubDate>Sat, 24 Oct 2026 22:32:40 +0000</pubDate>
</item>
<item>
<title>You patched PHP. The old image is still in your cluster’s future.</title>
<link>https://coderlegion.com/32217/you-patched-php-the-old-image-is-still-in-your-clusters-future</link>
<description>&lt;hr&gt;
&lt;p&gt;title: &quot;You patched PHP. The old image is still in your cluster's future.&quot;&lt;br&gt;
published: false&lt;br&gt;
description: &quot;PHP2Shell (CVE-2026-109618, CVE-2026-109619) as seen from a Kubernetes cluster: how fast the scanners caught up, where they stay blind, and why rebuilding the image is only half of remediation.&quot;&lt;/p&gt;
&lt;h3 id=&quot;tags-kubernetes-security-php-devops&quot;&gt;tags: kubernetes, security, php, devops&lt;/h3&gt;
&lt;p&gt;On 21 October PHP shipped fixes for two critical bugs in the engine itself, CVE-2026-109618 and CVE-2026-109619, both CVSS 9.2, both reachable without authentication, both already being exploited by the time most people read the advisory. The community name is PHP2Shell. Fixed versions are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. CISA and the UK NCSC both published &quot;patch now&quot; notices the same day.&lt;/p&gt;
&lt;p&gt;I am not going to explain the bugs; Wiz, watchTowr and Qualys have done that well. I want to write about what happened in Kubernetes teams in the forty-eight hours after, because I watched it happen and because the second half of it is the part nobody schedules.&lt;/p&gt;
&lt;h3 id=&quot;how-most-teams-found-out&quot;&gt;How most teams found out&lt;/h3&gt;
&lt;p&gt;From the news. Then someone opened a spreadsheet and typed &quot;PHP&quot; into the first column.&lt;/p&gt;
&lt;p&gt;This is not a criticism. It is what a platform team does when the only inventory of what runs in production is the memory of the people who deployed it. The questions that followed were always the same three:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which clusters run PHP at all?&lt;/li&gt;
&lt;li&gt;Which images, which versions?&lt;/li&gt;
&lt;li&gt;Who gets to say &quot;we are done&quot;?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The third question is the one that comes back in March, when an auditor asks what you knew on 22 October and what you did about it.&lt;/p&gt;
&lt;h3 id=&quot;how-fast-the-scanners-caught-up&quot;&gt;How fast the scanners caught up&lt;/h3&gt;
&lt;p&gt;I measured this on 23 October, two days after the advisory, with Trivy 0.72.0 and whatever vulnerability database it pulled that afternoon. You can repeat every step.&lt;/p&gt;
&lt;p&gt;The database build was stamped 13:13 UTC on 23 October. Both CVEs were in it, with the Debian and Alpine advisories attached:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ grep -a -o CVE-2026-109618 ~/.cache/trivy/db/trivy.db | wc -l
1247
$ grep -a -o CVE-2026-109619 ~/.cache/trivy/db/trivy.db | wc -l
1253
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Debian's security tracker already listed &lt;code&gt;php8.2&lt;/code&gt; as resolved in &lt;code&gt;8.2.35-1~deb12u1&lt;/code&gt; for bookworm and &lt;code&gt;php8.4&lt;/code&gt; in &lt;code&gt;8.4.27-1~deb13u1&lt;/code&gt; for trixie. Alpine's secdb for 3.22 carried both CVEs. A &lt;code&gt;debian:bookworm-slim&lt;/code&gt; image with &lt;code&gt;php8.2-cli&lt;/code&gt; installed from the archive that day came out as 8.2.35, already fixed; a &lt;code&gt;alpine:3.22&lt;/code&gt; image with &lt;code&gt;php84&lt;/code&gt; came out as 8.4.27, also fixed.&lt;/p&gt;
&lt;p&gt;So the honest answer to &quot;did the open-source scanners keep up&quot; is yes, within two days, for PHP installed as a distribution package. That is good news and it is the ordinary case for a lot of internal images.&lt;/p&gt;
&lt;h3 id=&quot;where-the-scanners-stay-blind&quot;&gt;Where the scanners stay blind&lt;/h3&gt;
&lt;p&gt;Now the uncomfortable part.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ trivy image --scanners vuln --severity CRITICAL,HIGH php:8.3-fpm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;126 CRITICAL and HIGH findings. Not one of them about PHP.&lt;/p&gt;
&lt;p&gt;The official &lt;code&gt;php:*&lt;/code&gt; images build PHP from source. There is no &lt;code&gt;php8.3&lt;/code&gt; package in the image for a package scanner to look at, so Trivy, and every scanner that works from the package database, has nothing to match the advisory against. The 126 findings are the Debian base. The thing you actually care about is invisible.&lt;/p&gt;
&lt;p&gt;This is not a Trivy bug. It is what package-based scanning is. But it means that if your PHP workloads run on the official image, or on any image that compiles PHP, then a quiet scanner is not a clean bill. It is silence.&lt;/p&gt;
&lt;p&gt;What you can do about it this week, in order of effort:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ask the binary. &lt;code&gt;docker run --rm &amp;lt;your-image&amp;gt; php -v&lt;/code&gt; tells you the truth in one line. Do it for every PHP image tag you run, not the ones you think you run.&lt;/li&gt;
&lt;li&gt;Rebuild on a base that was pushed after 21 October, pin the digest, and write the digest down somewhere an auditor can read.&lt;/li&gt;
&lt;li&gt;If you can, move to distribution packages for the runtime. It costs you a newer PHP, it buys you a scanner that can see it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;patching-is-half-the-job&quot;&gt;Patching is half the job&lt;/h3&gt;
&lt;p&gt;Here is the part that gets skipped. You rebuild the image, you roll the Deployment, the pods come up on 8.3.36, everyone exhales.&lt;/p&gt;
&lt;p&gt;The old image did not go anywhere. It is still in the registry. It is still in &lt;code&gt;helm history&lt;/code&gt;. It is the image someone will roll back to at 03:00 when the new build has a problem that has nothing to do with PHP. It is in the CI cache, in the staging cluster nobody looks at, in the Job that runs once a month.&lt;/p&gt;
&lt;p&gt;Remediation has two halves:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Run the fixed image.&lt;/li&gt;
&lt;li&gt;Make it impossible for the vulnerable image to run again, and keep a record that proves both.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most teams do the first half and call it done. The second half is what an admission controller is for.&lt;/p&gt;
&lt;h3 id=&quot;what-this-looks-like-with-an-admission-controller-in-the-path&quot;&gt;What this looks like with an admission controller in the path&lt;/h3&gt;
&lt;p&gt;This is where I talk about what we built, so take the next paragraphs for what they are. Attestkeep is an admission controller for Kubernetes that scans, verifies signatures, decides at admission, and keeps the record. Here is exactly what it did, and did not do, for PHP2Shell.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The database refreshes itself.&lt;/strong&gt; The scanner pulls a fresh vulnerability database every six hours. Trivy publishes one every six hours too, so the lag between an advisory landing upstream and the cluster knowing about it is measured in hours, not in someone's calendar.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every admitted image is rescanned.&lt;/strong&gt; Once a day by default (&lt;code&gt;scan.rescanInterval&lt;/code&gt; in the chart, if you want it shorter), every image the cluster has ever admitted is scanned again against the current database. An image that was clean on Monday and carries a CVE on Thursday shows up as not compliant on Thursday, with the CVE named. You do not open a spreadsheet; the inventory is the admission ledger.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It tells you.&lt;/strong&gt; A scan that finds critical vulnerabilities raises a &lt;code&gt;critical_finding&lt;/code&gt; event, which goes to whichever channel you configured: email, Slack, Teams, a webhook, Jira, GitLab. When a running image flips from &quot;would be admitted&quot; to &quot;would be denied&quot;, that is a second, separate message: &quot;Running image now fails policy&quot;. That is the message an operator actually needs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;New pods with the image are refused.&lt;/strong&gt; If your policy blocks critical findings, the vulnerable image cannot be admitted again. The rollback at 03:00 fails with a reason, the Job that runs monthly fails with a reason, the staging cluster with the same policy refuses it too. The old image is still in the registry. It is no longer in your cluster's future.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Running pods are not killed.&lt;/strong&gt; This is deliberate. Attestkeep does not evict a workload because a database update changed its verdict; that is an operator's decision, with the blast radius in front of them. What it does is make sure the next decision, any next decision, goes the right way, and that the running population is reconciled against the ledger on a schedule so drift is visible.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If the exploit lands first, there is a second line.&lt;/strong&gt; PHP2Shell ends in a shell. If Falco is connected, a shell spawned inside a container is a runtime event, and a runtime policy can turn that event into a notification, an issue, or stopping the pod. That is not prevention, it is containment, and it is worth having when the window between advisory and rebuild is days.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every decision is signed.&lt;/strong&gt; The admission ledger records the image digest, the policy revision that ran, the verdict and the reason. When the auditor asks in March what you knew on 22 October, the answer is a record with a signature, not a recollection.&lt;/p&gt;
&lt;p&gt;And the blind spot above applies to us exactly as it applies to Trivy, because Trivy is the scanner inside. If you run the official &lt;code&gt;php:*&lt;/code&gt; image, Attestkeep's rescan will not name CVE-2026-109618 either. It will show you the digest you admitted, when, and under which policy. The &lt;code&gt;php -v&lt;/code&gt; step is still yours. I would rather tell you that here than have you find it out from a pentest report.&lt;/p&gt;
&lt;h3 id=&quot;the-checklist-tool-agnostic&quot;&gt;The checklist, tool-agnostic&lt;/h3&gt;
&lt;p&gt;For this week, whatever you run:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;List every image whose name or Dockerfile mentions PHP. Not from memory; from the cluster.&lt;/li&gt;
&lt;li&gt;For each, run &lt;code&gt;php -v&lt;/code&gt; inside the image. Trust that over the scanner.&lt;/li&gt;
&lt;li&gt;Rebuild on a post-21-October base, pin digests.&lt;/li&gt;
&lt;li&gt;Add a rule that refuses the old digests at admission, so a rollback cannot resurrect them.&lt;/li&gt;
&lt;li&gt;Write down what you found, what you changed, and when, in a form that survives until the audit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If step 4 is a policy in an admission controller and step 5 is produced automatically, this is a Tuesday. If step 4 is a Slack message and step 5 is a Confluence page, it is a project, and it will be a project again at the next CVE.&lt;/p&gt;
&lt;h3 id=&quot;closing&quot;&gt;Closing&lt;/h3&gt;
&lt;p&gt;The lesson of PHP2Shell for platform teams is not &quot;patch faster&quot;. Everyone patched. The lesson is that the news is a bad way to learn what is in your cluster, and that a patched Deployment with the old image still admissible is not a remediated cluster.&lt;/p&gt;
&lt;p&gt;Let the cluster do the checking. Keep the record. Make the old image a thing that cannot come back.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Sources: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching&quot;&gt;CISA alert, 21 October 2026&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.ncsc.gov.uk/news/critical-security-issue-affecting-php&quot;&gt;NCSC advisory&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://watchtowr.com/intelligence/php-cve-2026-109618-faq/&quot;&gt;watchTowr FAQ&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.qualys.com/vulnerabilities-threat-research/2026/10/20/php-announces-critical-security-release-for-two-vulnerabilities&quot;&gt;Qualys&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://cloud.google.com/blog/products/identity-security/responding-to-core-php-vulnerabilities-cve-2026-109618-cve-2026-109619&quot;&gt;Google Cloud response&lt;/a&gt;. Scanner measurements are mine, 23 October 2026, Trivy 0.72.0; the commands are in the text.&lt;/p&gt;
&lt;p&gt;Attestkeep runs inside the cluster it protects; every security feature is in the free edition. &lt;a rel=&quot;nofollow&quot; href=&quot;https://attestkeep.com&quot;&gt;attestkeep.com&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.attestkeep.com&quot;&gt;docs.attestkeep.com&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32217/you-patched-php-the-old-image-is-still-in-your-clusters-future</guid>
<pubDate>Fri, 23 Oct 2026 17:56:15 +0000</pubDate>
</item>
<item>
<title>PHP2Shell: Two 9.2-Rated Flaws Reach Back to PHP 5. Here's the Plan for EOL Teams</title>
<link>https://coderlegion.com/32048/php2shell-two-9-2-rated-flaws-reach-back-to-php-5-heres-the-plan-for-eol-teams</link>
<description>&lt;p&gt;PHP shipped emergency patches on October 21 for two critical flaws now known as PHP2Shell. Both are rated 9.2 under CVSS v4.0. Both can let an unauthenticated attacker run code on your server.&lt;/p&gt;
&lt;p&gt;The fixes are in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. If you run a supported branch, update and restart your PHP workers. That part is simple.&lt;/p&gt;
&lt;p&gt;The harder question is what to do if you run a version that no longer gets community patches. A lot of teams are in that spot.&lt;/p&gt;
&lt;p&gt;I asked Matthew Weier O'Phinney about it. He's principal product manager for Perforce OpenLogic and Zend, and he sits on the PHP Foundation board. Perforce sells extended support for end-of-life (EOL) open source software, so keep that in mind when he talks about extended support. But he also had a close view of how this disclosure played out.&lt;/p&gt;
&lt;h3 id=&quot;what-the-flaws-do&quot;&gt;What the flaws do&lt;/h3&gt;
&lt;p&gt;CVE-2026-109618 corrupts memory when PHP hits an out-of-memory error in the middle of a request. CVE-2026-109619 involves stale global state. PHP keeps pointers to request data after that memory has been freed.&lt;/p&gt;
&lt;p&gt;Weier O'Phinney put it in plain terms. &quot;An attacker can create one or more payloads that simulate large file uploads, or multi-file uploads, that hit configured memory limits for the PHP application,&quot; he said. Because of how PHP handled memory and errors at that moment, the attacker could end up running system commands on the server.&lt;/p&gt;
&lt;p&gt;The attack doesn't need a vulnerable application or a public upload form. And the timeline was tight. Details leaked on October 20, a day before the coordinated release. Security firm Sansec reports that Cloudflare saw attacks hours before the patches shipped.&lt;/p&gt;
&lt;h3 id=&quot;it-s-not-just-mod-php&quot;&gt;It's not just mod_php&lt;/h3&gt;
&lt;p&gt;PHP's advisory named Apache's mod_php as the highest-risk configuration. Some early coverage stopped there. Weier O'Phinney says that's too narrow.&lt;/p&gt;
&lt;p&gt;&quot;These vulnerabilities affect every single Server API (SAPI) provided by the language, because their roots are deep within how the engine manages memory and handles errors,&quot; he said. &quot;If you run PHP, your application is affected, plain and simple.&quot;&lt;/p&gt;
&lt;p&gt;mod_php may be the easiest path in. But PHP has also confirmed remote code execution against PHP-FPM under the right application conditions. If you run FPM behind Nginx, you're not off the hook.&lt;/p&gt;
&lt;h3 id=&quot;workarounds-buy-time-they-don-t-fix-the-problem-&quot;&gt;Workarounds buy time. They don't fix the problem.&lt;/h3&gt;
&lt;p&gt;The early advice was to turn off session upload progress and remove memory limits. Weier O'Phinney says those steps help, but only so much. &quot;They just change the size of the file upload payloads the attacker needs to use to exploit the system,&quot; he said.&lt;/p&gt;
&lt;p&gt;He's more positive about two other measures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A web application firewall (WAF) that limits which paths accept uploads, or any user submission&lt;/li&gt;
&lt;li&gt;AppArmor or SELinux to restrict PHP's access to system binaries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both are good defense-in-depth. But he warns that creative attackers can still find ways around them if the PHP runtime underneath is vulnerable.&lt;/p&gt;
&lt;p&gt;PHP says the same thing. Updating is the only complete fix. If you removed the memory limit as a stopgap, put a container or cgroup limit in place, and restore a finite limit once you've patched.&lt;/p&gt;
&lt;h3 id=&quot;the-end-of-life-problem&quot;&gt;The end-of-life problem&lt;/h3&gt;
&lt;p&gt;Here's where it gets uncomfortable. According to Weier O'Phinney, every PHP version before 8.2 is past community support. And 8.2 reaches end of life at the end of this year.&lt;/p&gt;
&lt;p&gt;These flaws trace back to the PHP 5 series. &quot;If you're running PHP 5, PHP 7, or early PHP 8 versions, you will need to audit your applications and deployments,&quot; he said.&lt;/p&gt;
&lt;p&gt;He sees two paths:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use a commercial long-term support (LTS) provider.&lt;/strong&gt; Several have patched EOL versions. Perforce Zend patched PHP 7.2, 7.3, 7.4 and 8.1 in coordination with the PHP Security team. If you already have a provider, confirm it shipped patches for your version.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start migrating to a community-supported version.&lt;/strong&gt; That means 8.2 or later. Since 8.2 is nearly at end of life itself, a migration that stops there only buys you a couple of months.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you have neither, his advice is to pick one today.&lt;/p&gt;
&lt;h3 id=&quot;ai-changed-the-clock&quot;&gt;AI changed the clock&lt;/h3&gt;
&lt;p&gt;The backstory matters here. A security audit sponsored by the Sovereign Tech Agency and the PHP Foundation found these attack vectors last year. But the security team couldn't find a practical way to trigger them. They treated the flaws as largely theoretical and kept working on them off and on.&lt;/p&gt;
&lt;p&gt;Then, this past August, two independent researchers found the same issues within days of each other. Each was using a frontier AI model. Each built a way to exploit them. The PHP Security team had to move them to the top of the list.&lt;/p&gt;
&lt;p&gt;That's the real shift. A bug that sat in the &quot;theoretical&quot; pile for a year became practical in a matter of days.&lt;/p&gt;
&lt;p&gt;Weier O'Phinney sees the same pattern across open source. The cURL project has been handling a flood of AI-discovered reports for two years. Spring has seen a big jump in reports this year. And those reports still need people to review them. Maintainers have to confirm that each patch works, doesn't break existing behavior and doesn't add new flaws. Many AI-generated reports also overstate severity, which takes more time to sort out.&lt;/p&gt;
&lt;p&gt;For EOL software, this is the core risk. Discovery is speeding up. Patches for unsupported versions aren't.&lt;/p&gt;
&lt;h3 id=&quot;migration-or-extended-support-&quot;&gt;Migration or extended support?&lt;/h3&gt;
&lt;p&gt;Weier O'Phinney is clear that migration is the goal. Current versions get new features and security fixes first, and the rest of the ecosystem tends to follow the latest releases.&lt;/p&gt;
&lt;p&gt;But he's also realistic. Continuous upgrades take discipline, solid testing and QA, and a DevOps team that can roll releases out and roll them back. Some companies also face regulatory reviews every time they move to a new feature release. That costs time and money, so migration gets pushed back.&lt;/p&gt;
&lt;p&gt;&quot;So adopt those new versions if you can, but have a plan for when you cannot,&quot; he said.&lt;/p&gt;
&lt;p&gt;That's fair advice, and it's worth taking at face value. Extended support is a bridge, not a destination. If you're evaluating a provider, ask one simple question: how fast did it ship PHP2Shell patches for your version? The answer tells you a lot.&lt;/p&gt;
&lt;h3 id=&quot;how-the-disclosure-worked&quot;&gt;How the disclosure worked&lt;/h3&gt;
&lt;p&gt;Weier O'Phinney knew for a couple of months that big security patches were coming. He didn't know the details. &quot;That's a good thing,&quot; he said. Fewer people in the loop means less chance of a leak.&lt;/p&gt;
&lt;p&gt;The PHP Foundation now employs a small team that coordinates security work between researchers and the PHP project. One of them contacted Weier O'Phinney in his role at a commercial LTS provider to line up releases. In more than 20 years at Zend, that was a first for him. The PHP Security team worked with Zend's packaging team and reviewed its patches so both were ready on release day.&lt;/p&gt;
&lt;p&gt;His bigger concern is capacity. The PHP Security team is small, and the volume of incoming reports keeps growing. He'd like to see it staffed well enough to coordinate like this more often. His ask: if your company depends on PHP, consider donating to the PHP Foundation.&lt;/p&gt;
&lt;h3 id=&quot;what-to-do-this-week&quot;&gt;What to do this week&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Find every PHP runtime you have.&lt;/strong&gt; Check servers, containers, base images and hosting accounts. Trust &lt;code&gt;php -v&lt;/code&gt;, not your documentation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Patch supported branches now.&lt;/strong&gt; Move to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, then restart PHP workers so the new code is actually running.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check EOL versions separately.&lt;/strong&gt; If you use an LTS provider, confirm it has shipped fixes. If you don't, choose between getting one and starting a migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat workarounds as temporary.&lt;/strong&gt; Undo emergency config changes once you've patched.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the extra layers.&lt;/strong&gt; A WAF and AppArmor or SELinux are worth having after the patch, too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plan for more of this.&lt;/strong&gt; AI-assisted discovery isn't slowing down. Budget time for triage and upgrades, not just for incidents.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PHP2Shell will get patched on most supported systems this week. The teams still exposed next month will be the ones running PHP that nobody owns. Now is a good time to find out if that's you.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32048/php2shell-two-9-2-rated-flaws-reach-back-to-php-5-heres-the-plan-for-eol-teams</guid>
<pubDate>Thu, 22 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>A Clean Scan Is a Data Point, Not a Verdict: Lessons from PHP2Shell Week</title>
<link>https://coderlegion.com/32029/a-clean-scan-is-a-data-point-not-a-verdict-lessons-from-php2shell-week</link>
<description>&lt;p&gt;PHP's two critical flaws, CVE-2026-109618 and CVE-2026-109619 (PHP2Shell), were patched in 8.5.12, 8.4.27, 8.3.36 and 8.2.35. The advice everywhere was to update. I want to write about something less covered: how much you can honestly claim afterwards.&lt;/p&gt;
&lt;h4 id=&quot;the-scan-that-found-nothing&quot;&gt;The scan that found nothing&lt;/h4&gt;
&lt;p&gt;I ran &lt;a rel=&quot;nofollow&quot; href=&quot;http://github.com/hryagstn/laravel-scalpel&quot;&gt;Laravel Scalpel&lt;/a&gt;, an open source filesystem intrusion scanner, against three Laravel applications after updating. No backdoor files. My first instinct was relief. My second was to ask what that result actually rules out.&lt;/p&gt;
&lt;h4 id=&quot;why-a-negative-result-is-weak-evidence&quot;&gt;Why a negative result is weak evidence&lt;/h4&gt;
&lt;p&gt;A scan can only report on what it can see, and it can only recognise what it knows how to recognise. Scalpel looks at five things: PHP files in places they should not be, known obfuscation patterns, risky directives in &lt;code&gt;.htaccess&lt;/code&gt; and &lt;code&gt;.user.ini&lt;/code&gt;, the integrity of &lt;code&gt;.env&lt;/code&gt;, and differences from a saved baseline. Each is a question it answers well. Anything outside them is a question it never asks. Coverage and sensitivity multiply, and if either is low, 'found nothing' tells you little.&lt;/p&gt;
&lt;p&gt;There are at least two explanations for my result. My applications are small, which lowers the odds that anything needed to hide. Or something sits outside the questions above. A scanner inside the application also shares the web server's user, so a flaw that grants code execution in that process gives an attacker the means to tamper with the tool. I ran Scalpel from inside the application and through an external runner for that reason, which narrows the gap without closing it. My strongest evidence is not the scan but a routine that predates it: since April, a scheduled external diff against my baseline has reported to a Telegram bot. I refreshed the baseline myself after the update, so any unexpected change before that should already have alerted me, and none did. That evidence has conditions too: the runs must actually have happened in the window, and the alert level must cover what an attacker would change. Scalpel's documentation states the first limit plainly, and I would rather state all of them than have a reader discover them. From the output alone I cannot separate these cases.&lt;/p&gt;
&lt;h4 id=&quot;a-ladder-of-claims&quot;&gt;A ladder of claims&lt;/h4&gt;
&lt;p&gt;It helped me to treat each check as supporting a different, specific statement:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Version verified:&lt;/strong&gt; the PHP that serves web traffic is a fixed version, containers included. Supports: 'this flaw can no longer be used against us.'&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logs reviewed from 20 October:&lt;/strong&gt; no repeated 500s or memory limit errors that look like probing. Supports: 'we saw no sign of attempts in these logs.'&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filesystem scan clean:&lt;/strong&gt; no known backdoor patterns in the application files. Supports: 'we found no evidence of intrusion in these files.'&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secrets rotated:&lt;/strong&gt; exposed credentials no longer work. Supports: 'even if memory was read, those secrets are dead.'&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rebuilt from a known good image:&lt;/strong&gt; supports the only claim close to 'clean.'&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most of us stop at rungs one to three and announce we are fine. Only the last rung justifies that word.&lt;/p&gt;
&lt;h4 id=&quot;why-the-timing-makes-this-sharper&quot;&gt;Why the timing makes this sharper&lt;/h4&gt;
&lt;p&gt;The identifiers leaked about a day before the fix, and a security firm reported exploitation attempts shortly after release. For teams that patched hours or days later, the unprotected window was real, and a patch does nothing about what happened inside it.&lt;/p&gt;
&lt;h4 id=&quot;what-i-would-write-in-a-post-patch-note&quot;&gt;What I would write in a post-patch note&lt;/h4&gt;
&lt;p&gt;Something like: 'Updated PHP on this date. Reviewed logs from 20 October. Scanned the application filesystem. Found no evidence of intrusion in those places. Rotated these secrets.' Each sentence is a claim you can defend, and the places you did not check are visible by omission.&lt;/p&gt;
&lt;p&gt;I would be glad to hear what steps other teams include in their own post-patch verification.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32029/a-clean-scan-is-a-data-point-not-a-verdict-lessons-from-php2shell-week</guid>
<pubDate>Thu, 22 Oct 2026 05:13:41 +0000</pubDate>
</item>
<item>
<title>php2shell: how are you confirming which PHP your web traffic actually runs on?</title>
<link>https://coderlegion.com/32010/php2shell-how-are-you-confirming-which-php-your-web-traffic-actually-runs-on</link>
<description>&lt;p&gt;PHP's 21 October fixes (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35) cover two critical runtime flaws. Because they sit below the application, updating a CMS or framework doesn't help — the runtime has to change and workers have to restart.&lt;/p&gt;
&lt;p&gt;The step I'd expect to trip teams up is inventory. &lt;code&gt;php -v&lt;/code&gt; on a shell tells you about the CLI, not necessarily what PHP-FPM, &lt;code&gt;mod_php&lt;/code&gt; or a bundled appliance is serving.&lt;/p&gt;
&lt;p&gt;What are you using to answer &quot;where does PHP run and is it patched?&quot; — package queries, &lt;code&gt;phpinfo()&lt;/code&gt; behind auth, image scanning, something else? And has anyone found a clean way to confirm old FPM workers are gone after the upgrade?&lt;/p&gt;
&lt;p&gt;Write-up with versions, interim settings and detection signals: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/_firelinks/php2shell-a-request-that-fails-halfway-still-leaves-state-behind-5877&quot;&gt;https://dev.to/_firelinks/php2shell-a-request-that-fails-halfway-still-leaves-state-behind-5877&lt;/a&gt;&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/32010/php2shell-how-are-you-confirming-which-php-your-web-traffic-actually-runs-on</guid>
<pubDate>Thu, 22 Oct 2026 01:34:44 +0000</pubDate>
</item>
<item>
<title>Stripe Webhooks in Laravel: Verify, Dedupe and Retry Safely</title>
<link>https://coderlegion.com/31895/stripe-webhooks-in-laravel-verify-dedupe-and-retry-safely</link>
<description>&lt;p&gt;If you take payments with Stripe, the money moving is only half the story. The other half arrives later as &lt;strong&gt;Stripe webhooks&lt;/strong&gt;: messages that tell your app a payment succeeded, failed, was refunded or disputed. Handle them badly and you get customers who paid but have no access, double-processed orders, or a billing status that quietly drifts away from reality.&lt;/p&gt;
&lt;p&gt;I build these integrations as part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/services/api-development-integrations&quot;&gt;API development and integrations&lt;/a&gt; work. This guide covers how to receive Stripe webhooks safely in Laravel: verifying them, ignoring duplicates, responding fast and recovering when something fails.&lt;/p&gt;
&lt;h3 id=&quot;why-webhooks-not-just-the-redirect-after-checkout&quot;&gt;Why webhooks, not just the redirect after checkout&lt;/h3&gt;
&lt;p&gt;When a customer pays, Stripe sends them back to your site. It is tempting to mark the order paid at that moment. Don't rely on it. The customer can close the tab, lose connection or never reach your success page, and payments can also change state later (failed renewals, refunds, disputes).&lt;/p&gt;
&lt;p&gt;Webhooks are the reliable channel. Your app should treat the webhook as the source of truth for payment state and the redirect only as a friendly &quot;thanks&quot; page.&lt;/p&gt;
&lt;h3 id=&quot;the-six-rules-for-a-safe-webhook-endpoint&quot;&gt;The six rules for a safe webhook endpoint&lt;/h3&gt;
&lt;h4 id=&quot;1-verify-the-signature&quot;&gt;1. Verify the signature&lt;/h4&gt;
&lt;p&gt;Anyone can send a request to your public URL. Stripe signs every event with a &lt;code&gt;Stripe-Signature&lt;/code&gt; header, and you must verify it against your endpoint's signing secret before trusting the payload. Stripe's &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.stripe.com/webhooks&quot;&gt;webhook documentation&lt;/a&gt; explains the scheme. Verification needs the raw request body, so read it before any middleware changes it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

public function handle(Request $request)
{
    $payload = $request-&amp;gt;getContent();           // raw body
    $signature = $request-&amp;gt;header('Stripe-Signature');

    try {
        $event = Webhook::constructEvent(
            $payload,
            $signature,
            config('services.stripe.webhook_secret')
        );
    } catch (\UnexpectedValueException | SignatureVerificationException $e) {
        return response('Invalid', 400);
    }

    // ...store and queue the event
    return response('OK', 200);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep the signing secret in configuration, never in code, and use a separate secret per environment.&lt;/p&gt;
&lt;h4 id=&quot;2-exempt-the-route-from-csrf-and-only-that-route&quot;&gt;2. Exempt the route from CSRF, and only that route&lt;/h4&gt;
&lt;p&gt;Stripe is not a browser, so it cannot send a CSRF token. Exclude the webhook route from CSRF protection (in Laravel 11 and later this is done in &lt;code&gt;bootstrap/app.php&lt;/code&gt; with &lt;code&gt;validateCsrfTokens(except: [...])&lt;/code&gt;). Signature verification replaces CSRF protection for this one endpoint.&lt;/p&gt;
&lt;h4 id=&quot;3-respond-fast-process-later&quot;&gt;3. Respond fast, process later&lt;/h4&gt;
&lt;p&gt;Stripe expects a quick success response. If your handler does slow work, such as sending email, calling other APIs or updating several tables, the request may time out and Stripe will treat it as failed and retry. Do the minimum inside the request: verify, store, queue, return &lt;code&gt;200&lt;/code&gt;. Do the real work in a queued job, as described in my guide to &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-queue-architecture-saas&quot;&gt;Laravel queue architecture&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;4-deduplicate-by-event-id&quot;&gt;4. Deduplicate by event ID&lt;/h4&gt;
&lt;p&gt;Webhooks are delivered at least once, which means duplicates will happen. Every Stripe event has a unique &lt;code&gt;id&lt;/code&gt;. Store it in a table with a unique index. If an insert fails because the ID already exists, you have already seen the event, so return &lt;code&gt;200&lt;/code&gt; and do nothing. The same thinking applies to your own API endpoints, covered in &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-api-idempotency-keys&quot;&gt;API idempotency keys in Laravel&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;5-don-t-assume-order&quot;&gt;5. Don't assume order&lt;/h4&gt;
&lt;p&gt;Events can arrive out of order. A &quot;payment failed&quot; event might land after a later &quot;payment succeeded&quot; event for the same invoice. Your handler should look at the current state in your database, and where the order matters, fetch the latest object from Stripe's API instead of trusting the sequence of events.&lt;/p&gt;
&lt;h4 id=&quot;6-make-handlers-safe-to-run-twice&quot;&gt;6. Make handlers safe to run twice&lt;/h4&gt;
&lt;p&gt;Even with deduplication, jobs can be retried. Write each handler so that running it twice leaves the same result: update by a stable key, use &lt;code&gt;updateOrCreate&lt;/code&gt;, and check state before sending an email or granting access.&lt;/p&gt;
&lt;h3 id=&quot;a-reliable-architecture&quot;&gt;A reliable architecture&lt;/h3&gt;
&lt;p&gt;This is the shape I use:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Endpoint:&lt;/strong&gt; verify the signature, insert the event into a &lt;code&gt;stripe_events&lt;/code&gt; table (ID unique, payload, status), dispatch a job, return &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Job:&lt;/strong&gt; load the stored event, run the handler for its type, and mark it processed. On failure, let the queue retry with backoff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handlers:&lt;/strong&gt; one small class per event type you care about, such as &lt;code&gt;invoice.paid&lt;/code&gt;, &lt;code&gt;invoice.payment_failed&lt;/code&gt;, &lt;code&gt;charge.refunded&lt;/code&gt; and &lt;code&gt;checkout.session.completed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dead-letter view:&lt;/strong&gt; a simple admin list of events that failed repeatedly, so a human can inspect and replay them.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Storing the raw event gives you an audit trail, and it lets you replay an event after fixing a bug instead of asking Stripe to resend it.&lt;/p&gt;
&lt;h3 id=&quot;which-events-to-handle&quot;&gt;Which events to handle&lt;/h3&gt;
&lt;p&gt;Don't subscribe to everything. Start with the events that change what the customer can do or what you owe:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A checkout or payment completing successfully&lt;/li&gt;
&lt;li&gt;A payment failing, so you can notify the customer and limit access if appropriate&lt;/li&gt;
&lt;li&gt;Subscription updates and cancellations&lt;/li&gt;
&lt;li&gt;Refunds and disputes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Fewer subscriptions mean less code to get wrong. Add more only when a feature needs them.&lt;/p&gt;
&lt;h3 id=&quot;when-stripe-can-t-reach-you&quot;&gt;When Stripe can't reach you&lt;/h3&gt;
&lt;p&gt;If your endpoint returns an error or times out, Stripe retries the delivery over a period of time, with increasing gaps between attempts. You can also resend events from the Stripe dashboard. That safety net only helps if your endpoint is idempotent and your stored events show what happened. Add monitoring so you hear about a failing endpoint before customers do. My notes on &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-observability-architecture&quot;&gt;observability for Laravel SaaS&lt;/a&gt; cover the alerts I set up.&lt;/p&gt;
&lt;h3 id=&quot;testing-webhooks-locally&quot;&gt;Testing webhooks locally&lt;/h3&gt;
&lt;p&gt;Use the Stripe CLI to forward real test events to your local app and to trigger specific events on demand. Test these cases on purpose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A valid event, an event with a bad signature and an event with a tampered body&lt;/li&gt;
&lt;li&gt;The same event delivered twice&lt;/li&gt;
&lt;li&gt;Events arriving out of order&lt;/li&gt;
&lt;li&gt;A handler that throws an error halfway through&lt;/li&gt;
&lt;li&gt;Your queue worker being stopped while events arrive&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If all of these behave well in test mode, production will rarely surprise you.&lt;/p&gt;
&lt;h3 id=&quot;should-you-use-laravel-cashier-&quot;&gt;Should you use Laravel Cashier?&lt;/h3&gt;
&lt;p&gt;If you are building standard subscriptions, &lt;a rel=&quot;nofollow&quot; href=&quot;https://laravel.com/docs/billing#handling-stripe-webhooks&quot;&gt;Laravel Cashier&lt;/a&gt; already ships a webhook controller that verifies signatures and handles common subscription events. It is a good starting point, and you can extend it for your own events. For more custom flows, such as saving a card now and charging later, a hand-built handler gives you more control. My article on &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/subscription-billing-architecture-laravel-saas&quot;&gt;subscription billing architecture&lt;/a&gt; covers when each fits.&lt;/p&gt;
&lt;h3 id=&quot;a-real-world-example&quot;&gt;A real-world example&lt;/h3&gt;
&lt;p&gt;On the Stripe lead-billing project, I built a payment workflow that lets lead-generation clients save a payment method without an immediate charge, then automatically charges it when qualified leads are delivered. The status of successful and failed payments was kept in sync through webhook-driven updates. That is exactly the situation where trusting the webhook, not the browser, matters. Read the full &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/work/stripe-lead-billing-gohighlevel&quot;&gt;Stripe lead billing case study&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;common-mistakes-to-avoid&quot;&gt;Common mistakes to avoid&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Parsing the JSON body before verifying the signature&lt;/li&gt;
&lt;li&gt;Doing heavy work inside the webhook request&lt;/li&gt;
&lt;li&gt;Treating the checkout redirect as proof of payment&lt;/li&gt;
&lt;li&gt;Using one signing secret for test and live modes&lt;/li&gt;
&lt;li&gt;Returning &lt;code&gt;500&lt;/code&gt; for events you simply don't handle (return &lt;code&gt;200&lt;/code&gt; and ignore them)&lt;/li&gt;
&lt;li&gt;Having no way to see or replay failed events&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;key-takeaways&quot;&gt;Key takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Webhooks, not redirects, are the source of truth for payment state.&lt;/li&gt;
&lt;li&gt;Verify the signature, respond fast, then process in a queue.&lt;/li&gt;
&lt;li&gt;Deduplicate on the event ID and make every handler safe to run twice.&lt;/li&gt;
&lt;li&gt;Store raw events so you can audit and replay.&lt;/li&gt;
&lt;li&gt;Monitor the endpoint and test failure cases before launch.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want a payment integration that stays correct when things go wrong, &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/book&quot;&gt;book a call&lt;/a&gt; and we can review your flow together.&lt;/p&gt;
&lt;p&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/stripe-webhooks-laravel-verify-dedupe-retry&quot;&gt;aqibjavaid.site&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31895/stripe-webhooks-in-laravel-verify-dedupe-and-retry-safely</guid>
<pubDate>Wed, 21 Oct 2026 00:02:45 +0000</pubDate>
</item>
<item>
<title>Rewrite or Refactor a Legacy Application? How to Decide</title>
<link>https://coderlegion.com/31357/rewrite-or-refactor-a-legacy-application-how-to-decide</link>
<description>&lt;p&gt;Every business with an ageing application eventually faces the same question: do we &lt;strong&gt;rewrite or refactor the legacy application&lt;/strong&gt;? Get it wrong and you either burn a year rebuilding something that already worked, or you pour money into patching a system that cannot be saved.&lt;/p&gt;
&lt;p&gt;I handle this decision as part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/services/legacy-application-modernization&quot;&gt;legacy application modernization&lt;/a&gt; work, and the honest answer is almost never &quot;rewrite everything&quot; or &quot;leave it alone&quot;. This guide gives you a practical way to decide, the questions I ask, and the middle path that usually wins.&lt;/p&gt;
&lt;h3 id=&quot;what-legacy-actually-means&quot;&gt;What &quot;legacy&quot; actually means&lt;/h3&gt;
&lt;p&gt;Old code is not automatically legacy code. An application is legacy when it is &lt;strong&gt;hard to change safely&lt;/strong&gt;. Typical signs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Nobody dares to deploy on a Friday, or at all&lt;/li&gt;
&lt;li&gt;Only one person understands how it works&lt;/li&gt;
&lt;li&gt;The framework or language version no longer gets security updates&lt;/li&gt;
&lt;li&gt;Simple changes take weeks because everything is tangled together&lt;/li&gt;
&lt;li&gt;There are no tests, so every change is a gamble&lt;/li&gt;
&lt;li&gt;Hosting and dependencies are frozen because upgrading might break things&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the app is old but stable, tested and easy to change, it is not your problem. If it is new but a mess, it might be.&lt;/p&gt;
&lt;h3 id=&quot;the-three-options-in-plain-terms&quot;&gt;The three options, in plain terms&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Refactor in place.&lt;/strong&gt; Improve the existing code step by step without changing what the system does: add tests, clean up the worst areas, upgrade dependencies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rewrite from scratch.&lt;/strong&gt; Build a new system and switch over when it is ready.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modernise incrementally.&lt;/strong&gt; Replace the system piece by piece while the old one keeps running. This is often called the strangler pattern, described well in &lt;a rel=&quot;nofollow&quot; href=&quot;https://martinfowler.com/bliki/StranglerFigApplication.html&quot;&gt;Martin Fowler's write-up of the Strangler Fig approach&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most teams think they have two choices. The third is what usually works.&lt;/p&gt;
&lt;h3 id=&quot;why-big-bang-rewrites-fail-so-often&quot;&gt;Why big-bang rewrites fail so often&lt;/h3&gt;
&lt;p&gt;A full rewrite looks attractive: clean code, modern tools, no baggage. The problem is that the old system contains years of quiet business knowledge: edge cases, workarounds, rules nobody wrote down. Joel Spolsky made this argument in his classic essay &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/&quot;&gt;Things You Should Never Do, Part I&lt;/a&gt;, and it still holds up.&lt;/p&gt;
&lt;p&gt;In practice, rewrites go wrong for predictable reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The old system keeps changing&lt;/strong&gt; while you build the new one, so you are chasing a moving target.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You ship nothing for months&lt;/strong&gt;, so the business pays but sees no value.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hidden behaviour is lost&lt;/strong&gt; because nobody remembers why it worked that way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scope grows&lt;/strong&gt;, because a rewrite invites &quot;while we're at it&quot; features.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That does not mean never rewrite. It means be sure you must, and limit the blast radius.&lt;/p&gt;
&lt;h3 id=&quot;a-decision-framework-you-can-use-this-week&quot;&gt;A decision framework you can use this week&lt;/h3&gt;
&lt;p&gt;Score your system on these questions. I use them in audits.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Can the system still do its job?&lt;/strong&gt; If it works and the business is not blocked, you have time to move carefully. If it is failing daily, you may need to stabilise first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Is the technology end-of-life?&lt;/strong&gt; An unsupported framework or language version is a security and hiring risk. That pushes you toward upgrading or replacing, not ignoring.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Is the data model sound?&lt;/strong&gt; A good database under a messy application is a great position. A broken data model is a much bigger problem than ugly code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Can you test it?&lt;/strong&gt; If you can add tests around key flows, refactoring becomes safe. If the code cannot be tested at all, that is a strong signal to replace parts of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. How tangled are the pieces?&lt;/strong&gt; Systems with clear boundaries can be replaced one module at a time. A single ball of mud is harder.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. What does the business need next?&lt;/strong&gt; If the next year's roadmap is mostly new features, spend effort where those features land, not on parts nobody touches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;7. Who knows the system?&lt;/strong&gt; If that knowledge is walking out the door, document and test first, whatever you choose.&lt;/p&gt;
&lt;h3 id=&quot;when-refactoring-is-the-right-call&quot;&gt;When refactoring is the right call&lt;/h3&gt;
&lt;p&gt;Choose refactoring when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The framework is still supported or has a clear upgrade path&lt;/li&gt;
&lt;li&gt;The data model is sound&lt;/li&gt;
&lt;li&gt;The pain is concentrated in a few areas&lt;/li&gt;
&lt;li&gt;You can put tests around the critical flows&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I often start with exactly this: map the dependencies, add tests around the riskiest workflows, fix the worst hotspots, and upgrade in steps. My &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-upgrade-checklist&quot;&gt;Laravel upgrade checklist&lt;/a&gt; walks through that approach for older Laravel apps.&lt;/p&gt;
&lt;h3 id=&quot;when-a-rewrite-is-justified&quot;&gt;When a rewrite is justified&lt;/h3&gt;
&lt;p&gt;A rewrite makes sense when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The platform is end-of-life with no sensible upgrade path&lt;/li&gt;
&lt;li&gt;The architecture fundamentally cannot support what the business needs&lt;/li&gt;
&lt;li&gt;The system is small enough to rebuild in a short, fixed window&lt;/li&gt;
&lt;li&gt;The code is so untestable that incremental change is not possible&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even then, I prefer to rebuild in slices, with the old system running beside the new one.&lt;/p&gt;
&lt;h3 id=&quot;the-incremental-path-most-businesses-should-take&quot;&gt;The incremental path most businesses should take&lt;/h3&gt;
&lt;p&gt;The approach I recommend most often has five steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Audit first.&lt;/strong&gt; Understand the workflows, the database, the dependencies and the risks before deciding anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stabilise.&lt;/strong&gt; Fix security gaps, add monitoring and backups, and make deployments repeatable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add tests&lt;/strong&gt; around the flows that matter most.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replace one slice at a time.&lt;/strong&gt; Pick a self-contained module, rebuild it in the new stack and route traffic to it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retire the old parts&lt;/strong&gt; once the new ones have proven themselves.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each step delivers something the business can see, and each one can be paused or reversed.&lt;/p&gt;
&lt;h3 id=&quot;what-drives-the-cost-either-way&quot;&gt;What drives the cost either way&lt;/h3&gt;
&lt;p&gt;I will not quote a number without seeing the system, but these factors move cost and timeline the most:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Size and complexity&lt;/strong&gt; of the application, especially the number of distinct workflows&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test coverage&lt;/strong&gt;, because untested code costs more to change safely&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data migration&lt;/strong&gt;, since moving and validating years of records is often the riskiest step&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integrations&lt;/strong&gt; with payment, shipping, CRM and other third-party services&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How much downtime the business can tolerate&lt;/strong&gt; during the switch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Documentation and access&lt;/strong&gt;, because missing knowledge slows everything down&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An audit before the main work is usually the cheapest way to turn these unknowns into a plan you can budget.&lt;/p&gt;
&lt;h3 id=&quot;a-real-world-example-1&quot;&gt;A real-world example&lt;/h3&gt;
&lt;p&gt;On the &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/work/jamaica-freight-forwarding-core-php-audit&quot;&gt;PPCWA freight-forwarding audit&lt;/a&gt;, I was brought in to audit and stabilise a nearly completed Core PHP and MySQL platform. The work focused on understanding the existing system first: workflows, database structure, frontend maintainability, dependencies and configuration. I also assessed the architectural path toward a future Laravel migration. The point was to decide with evidence instead of opinion. I describe a related migration in &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/migrating-legacy-core-php-platform-to-laravel&quot;&gt;migrating a legacy Core PHP platform to Laravel&lt;/a&gt; and a Laravel-specific version in &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/laravel-5-migration-legacy-application-modernization&quot;&gt;Laravel 5 migration strategy&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;questions-to-ask-any-developer-or-agency&quot;&gt;Questions to ask any developer or agency&lt;/h3&gt;
&lt;p&gt;If someone recommends a rewrite, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What did you find in the existing system that cannot be improved?&lt;/li&gt;
&lt;li&gt;How will we keep the business running during the work?&lt;/li&gt;
&lt;li&gt;Which parts will go live first, and when?&lt;/li&gt;
&lt;li&gt;How will you protect the data and the undocumented business rules?&lt;/li&gt;
&lt;li&gt;What is the plan if the project overruns?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A good partner answers with specifics. A weak one answers with enthusiasm for new technology.&lt;/p&gt;
&lt;h3 id=&quot;key-takeaways-1&quot;&gt;Key takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&quot;Legacy&quot; means hard to change safely, not just old.&lt;/li&gt;
&lt;li&gt;Big-bang rewrites fail because they lose hidden business knowledge and deliver late.&lt;/li&gt;
&lt;li&gt;Audit first, then choose refactor, rewrite or incremental replacement with evidence.&lt;/li&gt;
&lt;li&gt;Incremental modernisation is usually the lowest-risk option.&lt;/li&gt;
&lt;li&gt;A sound data model and testable flows tilt the decision toward refactoring.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are weighing this decision for your own application, &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/book&quot;&gt;book a call&lt;/a&gt; and I will tell you which path I would take and why.&lt;/p&gt;
&lt;p&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/rewrite-vs-refactor-legacy-application&quot;&gt;aqibjavaid.site&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31357/rewrite-or-refactor-a-legacy-application-how-to-decide</guid>
<pubDate>Fri, 16 Oct 2026 14:55:27 +0000</pubDate>
</item>
<item>
<title>My Journey as a PHP Developer: Building Solutions Through Code</title>
<link>https://coderlegion.com/30470/my-journey-as-a-php-developer-building-solutions-through-code</link>
<description>&lt;p&gt;As a &lt;strong&gt;PHP developer&lt;/strong&gt;, I enjoy turning ideas into functional websites and building backend solutions that solve real-world problems. For me, programming is not just about writing code; it’s about understanding challenges, finding practical solutions, and creating applications that make everyday tasks easier.&lt;/p&gt;
&lt;p&gt;I enjoy working on dynamic websites, developing backend functionality, and exploring ways to make applications more efficient, reliable, and user friendly. I believe that &lt;strong&gt;clean, maintainable code is the foundation of good software development&lt;/strong&gt;, and I always try to improve the way I approach problems and build solutions.&lt;/p&gt;
&lt;p&gt;Technology is constantly evolving, which makes software development an exciting journey. I enjoy learning new tools, exploring different approaches, debugging problems, and discovering better ways to write code. Every challenge is an opportunity to learn something new and become a better developer.&lt;/p&gt;
&lt;p&gt;Beyond coding, I value sharing knowledge, exchanging ideas, and connecting with other developers. I believe we can grow faster when we learn from each other's experiences, discuss different perspectives, and support the developer community.&lt;/p&gt;
&lt;p&gt;My goal is to keep improving my PHP development skills, build meaningful projects, and create solutions that deliver real value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I’m always learning, always building, and always looking for the next problem to solve.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I'd love to connect with fellow developers, hear about your projects, and learn from your experiences. Let's keep building, sharing, and growing together!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30470/my-journey-as-a-php-developer-building-solutions-through-code</guid>
<pubDate>Fri, 09 Oct 2026 12:40:54 +0000</pubDate>
</item>
<item>
<title>AI Email Classifier Cost per 1,000 Messages: Real Math</title>
<link>https://coderlegion.com/25669/ai-email-classifier-cost-per-1-000-messages-real-math</link>
<description>
&lt;p&gt;
        For 1,000 typical text emails, the model API can cost roughly &lt;strong&gt;$0.05 to $1.08&lt;/strong&gt;, or about &lt;strong&gt;PHP 3 to PHP 66&lt;/strong&gt; at a rounded PHP 61.55 per US dollar. That estimate assumes 900 input tokens and 30 output tokens per message, no attachments, no retries, and one classification call per email.
      &lt;/p&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Partner disclosure.&lt;/strong&gt; This guide includes a partner link to &lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener nofollow&quot;&gt;n8n Cloud&lt;/a&gt;. If you sign up through it, I may earn a commission at no extra cost to you.&lt;/p&gt;
      &lt;/div&gt;
      &lt;p&gt;
        The low token bill is real, but it is not the whole operating cost. Your automation platform, monitoring, human review, and the consequences of a wrong route can cost much more than inference. Prices and exchange rates may change, so keep the formula and replace the rates instead of trusting a static total.
      &lt;/p&gt;

      &lt;div class=&quot;article-callout&quot;&gt;
        &lt;p&gt;&lt;strong&gt;Practical answer:&lt;/strong&gt; budget about &lt;strong&gt;$0.10 to $0.25 per 1,000 ordinary emails&lt;/strong&gt; for a small classification model, then evaluate accuracy on your own inbox. A model that costs five cents but misroutes valuable leads is not the cheaper option.&lt;/p&gt;
      &lt;/div&gt;

      &lt;h2 id=&quot;the-workload-behind-the-estimate&quot;&gt;The workload behind the estimate&lt;/h2&gt;
      &lt;p&gt;
        A useful comparison needs the same input and output size for every model. This article uses a typical business email workload rather than each provider's most flattering example.
      &lt;/p&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Component per email&lt;/th&gt;
              &lt;th&gt;Assumed tokens&lt;/th&gt;
              &lt;th&gt;What it includes&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Classification instructions&lt;/td&gt;
              &lt;td&gt;250 input&lt;/td&gt;
              &lt;td&gt;Category names, definitions, edge-case rules, and output instructions&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Email content&lt;/td&gt;
              &lt;td&gt;650 input&lt;/td&gt;
              &lt;td&gt;Subject, cleaned body, and a small amount of sender context&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Classification result&lt;/td&gt;
              &lt;td&gt;30 output&lt;/td&gt;
              &lt;td&gt;One label plus compact structured fields such as reason and review flag&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;&lt;strong&gt;Total for 1,000&lt;/strong&gt;&lt;/td&gt;
              &lt;td&gt;&lt;strong&gt;900,000 input&lt;br&gt;30,000 output&lt;/strong&gt;&lt;/td&gt;
              &lt;td&gt;No attachment extraction, second model call, retry, or human-written reply&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        Token counts vary by provider and tokenizer. Treat 900 and 30 as planning inputs, then replace them with usage data from the API response or provider dashboard. Long quoted threads, HTML, signatures, disclaimers, and pasted documents can multiply the input without improving the decision.
      &lt;/p&gt;

      &lt;h2 id=&quot;the-cost-formula&quot;&gt;The cost formula&lt;/h2&gt;
      &lt;p&gt;Provider pages normally quote text prices per one million tokens. The calculation is:&lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;cost = (input_tokens / 1,000,000 &amp;times; input_rate)
     + (output_tokens / 1,000,000 &amp;times; output_rate)

Typical 1,000-email workload:
cost = (900,000 / 1,000,000 &amp;times; input_rate)
     + (30,000 / 1,000,000 &amp;times; output_rate)&lt;/pre&gt;
      &lt;p&gt;
        Example for OpenAI GPT-5.4 nano at the rates checked on July 20, 2026:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;input  = 0.9 &amp;times; $0.20 = $0.1800
output = 0.03 &amp;times; $1.25 = $0.0375
total  = $0.2175, rounded to $0.22 per 1,000 emails&lt;/pre&gt;

      &lt;h2 id=&quot;current-model-api-comparison&quot;&gt;Current model API comparison&lt;/h2&gt;
      &lt;p&gt;
        These totals use standard synchronous API pricing, without free tiers, batch discounts, cached-input discounts, regional uplifts, or taxes. The selected models are small or cost-focused options that can plausibly handle classification. Model quality is not equivalent, so the last column is a cost comparison, not a ranking.
      &lt;/p&gt;
      &lt;p&gt;
        &lt;strong&gt;Rate check:&lt;/strong&gt; every provider price in this table was checked against the linked official source on July 20, 2026. The PHP estimates use a BSP rate of PHP 61.548 per US dollar checked on the same date. To update any peso estimate, use &lt;code&gt;PHP estimate = USD total &amp;times; current PHP per USD&lt;/code&gt;. For example, &lt;code&gt;$0.2175 &amp;times; 61.548 = PHP 13.39&lt;/code&gt;.
      &lt;/p&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Model&lt;/th&gt;
              &lt;th&gt;Input / 1M&lt;/th&gt;
              &lt;th&gt;Output / 1M&lt;/th&gt;
              &lt;th&gt;Typical 1,000 emails&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Groq Llama 3.1 8B Instant&lt;/td&gt;
              &lt;td&gt;$0.05&lt;/td&gt;
              &lt;td&gt;$0.08&lt;/td&gt;
              &lt;td&gt;&lt;strong&gt;$0.047&lt;/strong&gt; / about PHP 3&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Google Gemini 2.5 Flash-Lite&lt;/td&gt;
              &lt;td&gt;$0.10&lt;/td&gt;
              &lt;td&gt;$0.40&lt;/td&gt;
              &lt;td&gt;&lt;strong&gt;$0.102&lt;/strong&gt; / about PHP 6&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;OpenAI GPT-5.4 nano&lt;/td&gt;
              &lt;td&gt;$0.20&lt;/td&gt;
              &lt;td&gt;$1.25&lt;/td&gt;
              &lt;td&gt;&lt;strong&gt;$0.218&lt;/strong&gt; / about PHP 13&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Anthropic Claude Haiku 4.5&lt;/td&gt;
              &lt;td&gt;$1.00&lt;/td&gt;
              &lt;td&gt;$5.00&lt;/td&gt;
              &lt;td&gt;&lt;strong&gt;$1.05&lt;/strong&gt; / about PHP 65&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;OpenAI GPT-5.6 Luna&lt;/td&gt;
              &lt;td&gt;$1.00&lt;/td&gt;
              &lt;td&gt;$6.00&lt;/td&gt;
              &lt;td&gt;&lt;strong&gt;$1.08&lt;/strong&gt; / about PHP 66&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        OpenAI describes GPT-5.4 nano as suitable for classification and other simple high-volume tasks. Its newer GPT-5.6 guidance points cost-sensitive new workloads toward Luna, but Luna costs more in this narrow token calculation. Test both only if the quality gain matters for your categories. Google calls Gemini 2.5 Flash-Lite its smallest and most cost-effective model for at-scale use. Anthropic's current Haiku tier costs more per token, while Groq's Llama 3.1 8B rate is the lowest in this selected set.
      &lt;/p&gt;

      &lt;h2 id=&quot;email-length-changes-the-bill-but-not-equally&quot;&gt;Email length changes the bill, but not equally&lt;/h2&gt;
      &lt;p&gt;
        Output is tiny for a classifier, so input length usually drives the total. This sensitivity table keeps output compact while changing the amount of text sent to the model.
      &lt;/p&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;1,000-email workload&lt;/th&gt;
              &lt;th&gt;Input / output&lt;/th&gt;
              &lt;th&gt;Gemini 2.5 Flash-Lite&lt;/th&gt;
              &lt;th&gt;GPT-5.4 nano&lt;/th&gt;
              &lt;th&gt;Claude Haiku 4.5&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Short&lt;/td&gt;
              &lt;td&gt;400k / 25k&lt;/td&gt;
              &lt;td&gt;$0.05&lt;/td&gt;
              &lt;td&gt;$0.11&lt;/td&gt;
              &lt;td&gt;$0.53&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Typical&lt;/td&gt;
              &lt;td&gt;900k / 30k&lt;/td&gt;
              &lt;td&gt;$0.10&lt;/td&gt;
              &lt;td&gt;$0.22&lt;/td&gt;
              &lt;td&gt;$1.05&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Long&lt;/td&gt;
              &lt;td&gt;2M / 40k&lt;/td&gt;
              &lt;td&gt;$0.22&lt;/td&gt;
              &lt;td&gt;$0.45&lt;/td&gt;
              &lt;td&gt;$2.20&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        The long scenario is still inexpensive as a token bill. It is also a warning: paying to resend an entire quoted conversation may expose unnecessary customer data and make the classification noisier. Clean the message before the model call.
      &lt;/p&gt;

      &lt;h2 id=&quot;what-the-token-estimate-leaves-out&quot;&gt;What the token estimate leaves out&lt;/h2&gt;
      &lt;h3&gt;Automation platform cost&lt;/h3&gt;
      &lt;p&gt;
        n8n Cloud is priced by workflow executions, not by node or step. If one incoming email starts one production workflow, 1,000 emails normally consume 1,000 executions. The model API remains a separate provider charge when you connect your own credential. If you already have unused execution capacity, the incremental platform cost may be zero. If the classifier forces a plan upgrade, the subscription difference can dwarf a 22-cent model bill.
      &lt;/p&gt;
      &lt;p&gt;
        One n8n execution can contain several items, but the Text Classifier documentation says each item is passed to the model. Grouping emails into one scheduled execution can reduce execution usage, while the token charge still follows the number and size of classified items.
      &lt;/p&gt;

      &lt;h3&gt;Retries and output repair&lt;/h3&gt;
      &lt;p&gt;
        A failed request, provider retry, or second-pass validation adds another call. n8n's Text Classifier has an &lt;strong&gt;Enable Auto-Fixing&lt;/strong&gt; option that sends a schema parsing error back to the model and asks it to repair the output. That is useful, but it means the estimate above is no longer one call per email when repair is triggered. Measure the repair rate before assuming it is free.
      &lt;/p&gt;

      &lt;h3&gt;Human review and mistakes&lt;/h3&gt;
      &lt;p&gt;
        Suppose a reviewer spends 30 seconds on 10% of 1,000 emails. That is 50 minutes of work. Even at a modest internal hourly rate, review costs far more than the API tokens. The review may still be a good investment if it catches invoices routed to sales, urgent complaints marked as routine, or valuable leads sent to the wrong queue.
      &lt;/p&gt;
      &lt;p&gt;
        The correct metric is not only cost per 1,000 messages. Track &lt;strong&gt;cost per correctly classified message&lt;/strong&gt;, manual-review rate, false-negative rate for urgent categories, and the operational cost of each error.
      &lt;/p&gt;

      &lt;h2 id=&quot;a-simple-n8n-cost-measurement-workflow&quot;&gt;A simple n8n cost-measurement workflow&lt;/h2&gt;
      &lt;p&gt;
        n8n already has a Text Classifier root node. It accepts an input prompt, category names and descriptions, optional multiple classes, an Other branch for unclear matches, a custom system prompt, and optional auto-fixing. For cost measurement, keep the workflow smaller than a full email router:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;Email trigger or scheduled inbox fetch
  &amp;rarr; Edit Fields: subject + cleaned body + minimal sender context
  &amp;rarr; Text Classifier: named categories and descriptions
     &amp;#8627; connected small chat model
  &amp;rarr; Log: provider, model, input tokens, output tokens, category
  &amp;rarr; Route Other and selected high-risk categories to review&lt;/pre&gt;
      &lt;p&gt;
        The separate &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/n8n-ai-email-routing-philippines&quot;&gt;AI email routing guide&lt;/a&gt; covers the downstream workflow. This article stays focused on cost measurement and model choice.
      &lt;/p&gt;

      &lt;h3&gt;Use categories that are testable&lt;/h3&gt;
      &lt;p&gt;
        Define categories by the action they trigger. For example: &lt;code&gt;sales_lead&lt;/code&gt;, &lt;code&gt;support&lt;/code&gt;, &lt;code&gt;billing&lt;/code&gt;, &lt;code&gt;urgent_complaint&lt;/code&gt;, and &lt;code&gt;other_review&lt;/code&gt;. Give each category a short description and two or three edge rules. Do not ask the model for a paragraph when one label and a compact reason are enough.
      &lt;/p&gt;
      &lt;p&gt;
        Keep &lt;strong&gt;Allow Multiple Classes To Be True&lt;/strong&gt; off when the business requires one owner. Use the documented Other branch instead of discarding an unclear item. Discarding may look efficient, but it can hide the messages most in need of review.
      &lt;/p&gt;

      &lt;h3&gt;Log actual usage&lt;/h3&gt;
      &lt;p&gt;
        Provider response fields differ, so inspect the output from your selected n8n chat-model node or the provider dashboard. Record input tokens, output tokens, model ID, retry count, latency, chosen category, and the reviewer's corrected category. Recalculate weekly:
      &lt;/p&gt;
      &lt;pre class=&quot;article-code&quot;&gt;observed cost per 1,000 =
  total provider cost / classified messages &amp;times; 1,000

cost per correct classification =
  (provider + platform allocation + review labor) / correct decisions&lt;/pre&gt;

      &lt;h2 id=&quot;reduce-cost-without-weakening-the-classifier&quot;&gt;Reduce cost without weakening the classifier&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;strong&gt;Use deterministic rules first.&lt;/strong&gt; Known senders, exact aliases, and clear invoice mailbox rules do not need an LLM.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Remove quoted history.&lt;/strong&gt; Classify the newest message plus only the minimum thread context required for meaning.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Strip HTML and signatures.&lt;/strong&gt; Boilerplate consumes input and can introduce misleading terms.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Return a compact schema.&lt;/strong&gt; A label, short reason, and review flag are enough for routing.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Do not add search or tools.&lt;/strong&gt; An inbox classifier should not browse the web or call unrelated tools.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Measure auto-fixes and retries.&lt;/strong&gt; A low base price can double if malformed output repeatedly triggers a second call.&lt;/li&gt;
        &lt;li&gt;&lt;strong&gt;Test a small model first.&lt;/strong&gt; Upgrade only when a measured accuracy gap is worth the higher rate.&lt;/li&gt;
      &lt;/ul&gt;

      &lt;h2 id=&quot;choose-the-model-by-evaluation-not-the-price-table&quot;&gt;Choose the model by evaluation, not the price table&lt;/h2&gt;
      &lt;p&gt;
        Start with at least 100 to 200 representative emails that a person has already labeled. Include short replies, forwarded threads, vague subjects, multilingual messages, promotions, complaints, and categories that are easy to confuse. This is an operational starting point, not a universal statistical guarantee.
      &lt;/p&gt;
      &lt;div class=&quot;article-table-wrap&quot;&gt;
        &lt;table class=&quot;article-table&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Test&lt;/th&gt;
              &lt;th&gt;What to measure&lt;/th&gt;
              &lt;th&gt;Decision&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;Overall accuracy&lt;/td&gt;
              &lt;td&gt;Correct labels divided by all tested messages&lt;/td&gt;
              &lt;td&gt;Useful for orientation, not enough for rare urgent classes&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Per-category recall&lt;/td&gt;
              &lt;td&gt;How many real messages in a category the model found&lt;/td&gt;
              &lt;td&gt;Prioritize billing, complaints, security, and other costly misses&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Other or review rate&lt;/td&gt;
              &lt;td&gt;Share of messages that need a person&lt;/td&gt;
              &lt;td&gt;Convert the review share into minutes and labor cost&lt;/td&gt;
            &lt;/tr&gt;
            &lt;tr&gt;
              &lt;td&gt;Correction stability&lt;/td&gt;
              &lt;td&gt;Whether prompt or model changes improve the same held-out set&lt;/td&gt;
              &lt;td&gt;Use a versioned test set before changing production&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
      &lt;p&gt;
        If two models are close, choose the simpler operational fit: supported credential, stable model ID, acceptable latency, data-processing requirements, and clear usage reporting. A few extra cents per 1,000 emails can be a sensible trade for fewer manual reviews.
      &lt;/p&gt;

      &lt;h2 id=&quot;questions-small-teams-usually-ask&quot;&gt;Questions small teams usually ask&lt;/h2&gt;
      &lt;h3&gt;Can the classifier really cost less than one dollar?&lt;/h3&gt;
      &lt;p&gt;
        Yes. A classification output is short, and small models have low token rates. The estimate stops being valid when you send long histories, extract attachments, ask for generated replies, use a larger model, or trigger repair and retry calls.
      &lt;/p&gt;
      &lt;h3&gt;Does n8n include the model tokens?&lt;/h3&gt;
      &lt;p&gt;
        Do not confuse n8n's AI Assistant credits with the third-party model used inside your production workflow. With your own OpenAI, Gemini, Anthropic, or Groq credential, the model provider meters the API usage. n8n Cloud separately counts workflow executions according to its current plan rules.
      &lt;/p&gt;
      &lt;h3&gt;Should I use the cheapest model?&lt;/h3&gt;
      &lt;p&gt;
        Use the cheapest model that passes your labeled evaluation and operational requirements. Price alone cannot tell you whether a model understands your category boundary, language mix, or unusual messages.
      &lt;/p&gt;
      &lt;h3&gt;Should low-cost classifications send automatic replies?&lt;/h3&gt;
      &lt;p&gt;
        Classification and outbound sending are separate risk decisions. Route first. If AI also drafts a customer-facing response, use a &lt;a rel=&quot;nofollow&quot; href=&quot;https://floxolab.com/blog/human-approval-ai-email-workflow&quot;&gt;duplicate-safe human approval gate&lt;/a&gt; until measured evidence supports narrower exceptions.
      &lt;/p&gt;

      &lt;h2 id=&quot;sources-checked&quot;&gt;Sources checked&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;&lt;a href=&quot;https://developers.openai.com/api/docs/models/gpt-5.4-nano&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;OpenAI GPT-5.4 nano model purpose, features, and token pricing&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://developers.openai.com/api/docs/models/gpt-5.6-luna&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;OpenAI GPT-5.6 Luna model purpose and token pricing&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/pricing&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;Google Gemini API pricing for Gemini 2.5 Flash-Lite&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/about-claude/pricing&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;Anthropic Claude Platform pricing for Claude Haiku 4.5&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://groq.com/pricing&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;Groq on-demand token pricing for Llama 3.1 8B Instant&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.text-classifier/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Text Classifier parameters, Other branch, and auto-fixing behavior&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://n8n.io/pricing/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;n8n Cloud execution-based pricing and current plan rules&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a href=&quot;https://www.bsp.gov.ph/statistics/external/day99_data.aspx&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;Bangko Sentral ng Pilipinas daily peso per US dollar data&lt;/a&gt;&lt;/li&gt;
      &lt;/ul&gt;

      &lt;div class=&quot;article-referral-card&quot;&gt;
        &lt;div&gt;
          &lt;p class=&quot;article-referral-kicker mono&quot;&gt;n8n Cloud for small teams&lt;/p&gt;
          &lt;p class=&quot;cta-h&quot;&gt;Measure the classifier before you scale it&lt;/p&gt;
          &lt;p&gt;n8n Cloud is the simplest starting point if you want to connect an inbox, test categories, and log real usage without maintaining a server. Affiliate disclosure: I may earn a commission if you sign up through this link, at no extra cost to you.&lt;/p&gt;
        &lt;/div&gt;
        &amp;lt;a href=&quot;&lt;a href=&quot;https://n8n.partnerlinks.io/6mg2xng7s1m6&quot; rel=&quot;nofollow&quot;&gt;https://n8n.partnerlinks.io/6mg2xng7s1m6&lt;/a&gt;&quot; target=&quot;_blank&quot; rel=&quot;sponsored noopener&quot; class=&quot;btn b&lt;/div&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/25669/ai-email-classifier-cost-per-1-000-messages-real-math</guid>
<pubDate>Thu, 08 Oct 2026 15:50:00 +0000</pubDate>
</item>
<item>
<title>How Much Can You Trust a Filesystem Baseline? Design Notes from Laravel Scalpel</title>
<link>https://coderlegion.com/30211/how-much-can-you-trust-a-filesystem-baseline-design-notes-from-laravel-scalpel</link>
<description>&lt;p&gt;A file hash only means something if you trust the moment it was recorded. That sounds obvious, but it is the part of integrity checking that people most often skip. Here are the design decisions behind the baseline feature in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hryagstn/laravel-scalpel&quot;&gt;Laravel Scalpel&lt;/a&gt;, an open-source intrusion evidence scanner for Laravel. Scalpel is now at v1.11.0, and the trade-offs below came up repeatedly in issues and in an independent review by Laravel News.&lt;/p&gt;
&lt;h3 id=&quot;what-a-baseline-records&quot;&gt;What a baseline records&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;scalpel:baseline&lt;/code&gt; stores three things for each included file: its SHA-256 hash, its size, and its modification time. Later, &lt;code&gt;scalpel:diff&lt;/code&gt; compares the live files against that record and reports files that were added, modified, or deleted.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;php artisan scalpel:baseline
php artisan scalpel:diff
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The idea is simple. Content scanners tell you whether a file looks suspicious. A baseline tells you whether a file &lt;em&gt;changed&lt;/em&gt;, even if it looks perfectly normal. A backdoor hidden in a legitimate file will not match any pattern, but it will not match the old hash either.&lt;/p&gt;
&lt;h3 id=&quot;decision-1-what-to-leave-out&quot;&gt;Decision 1: what to leave out&lt;/h3&gt;
&lt;p&gt;A baseline that reports every log rotation is a baseline you will stop reading. By default, frequently changing paths such as logs, sessions, compiled views, and &lt;code&gt;storage/app&lt;/code&gt; are excluded.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;vendor/&lt;/code&gt; gets different treatment. It is skipped by the content scanners, because scanning thousands of third-party files produces noise. But it stays in the baseline comparison. If someone drops a file into an installed package, you still see it in the diff.&lt;/p&gt;
&lt;h3 id=&quot;decision-2-strict-or-fast-hashing&quot;&gt;Decision 2: strict or fast hashing&lt;/h3&gt;
&lt;p&gt;By default, Scalpel runs in strict mode and re-hashes every file on each comparison. That is the most reliable option and also the slowest.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--fast&lt;/code&gt; option first compares size and modification time, and reuses the old hash when both match. It saves work, but it has a known blind spot: an attacker who edits a file and restores its original size and modification time can slip past the baseline comparison. Other scanners may still catch the file by its content or location, but the baseline alone will not.&lt;/p&gt;
&lt;p&gt;A practical rule: use strict mode where the result matters, such as scheduled integrity checks, and &lt;code&gt;--fast&lt;/code&gt; only where speed matters more, such as frequent local runs.&lt;/p&gt;
&lt;h3 id=&quot;decision-3-sign-the-baseline&quot;&gt;Decision 3: sign the baseline&lt;/h3&gt;
&lt;p&gt;If an attacker can rewrite the baseline, the diff becomes meaningless. Scalpel can HMAC-sign baselines and JSON reports when &lt;code&gt;SCALPEL_SIGNING_ENABLED&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. The key goes in &lt;code&gt;SCALPEL_SIGNING_KEY&lt;/code&gt;, and it should be a dedicated key, not your &lt;code&gt;APP_KEY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;When signing is on, &lt;code&gt;scalpel:diff&lt;/code&gt; verifies the baseline before using it. A missing or invalid signature is reported as &lt;code&gt;CRITICAL&lt;/code&gt;. For saved JSON reports, &lt;code&gt;scalpel:verify&lt;/code&gt; checks the signature.&lt;/p&gt;
&lt;p&gt;Two details matter in practice. First, turn on signing before you create the first baseline, otherwise you have an unsigned record to start from. Second, signing has a clear limit: it detects a baseline regenerated without the key, but it cannot help if the attacker can read the key or modify the scanner itself.&lt;/p&gt;
&lt;h3 id=&quot;decision-4-when-to-refresh&quot;&gt;Decision 4: when to refresh&lt;/h3&gt;
&lt;p&gt;A baseline is a record of a state you trust, so it has to be refreshed deliberately. The suggested flow after each deployment is to run &lt;code&gt;php artisan optimize&lt;/code&gt;, then &lt;code&gt;scalpel:baseline --force&lt;/code&gt; to replace the old record. Until a baseline exists, both &lt;code&gt;scan&lt;/code&gt; and &lt;code&gt;diff&lt;/code&gt; report a &lt;code&gt;MEDIUM&lt;/code&gt; finding telling you to create one.&lt;/p&gt;
&lt;p&gt;Never create a baseline on a server you suspect is already compromised. You would be recording the attacker's files as normal.&lt;/p&gt;
&lt;h3 id=&quot;the-trust-boundary&quot;&gt;The trust boundary&lt;/h3&gt;
&lt;p&gt;All of this runs in the same process, with the same permissions, as your application. Anyone who can change your code can also change the scanner. So the strongest setup moves the trust boundary outside the app:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Trigger scans from outside the server, for example from CI or a scheduler on another machine.&lt;/li&gt;
&lt;li&gt;Keep code directories read-only where possible.&lt;/li&gt;
&lt;li&gt;Send reports and baselines to storage the server cannot write to.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A scanner like this detects evidence of an intrusion. It does not block or contain one.&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require hryagstn/laravel-scalpel
php artisan vendor:publish --tag=scalpel-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Requires PHP 8.2 or later and supports Laravel 10 to 13.&lt;/p&gt;
&lt;p&gt;If you have a case where the baseline gives noisy or misleading results, please open an issue. I covered what the individual scanners look for in a separate post on DEV: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/harryagustiana/what-a-filesystem-scan-can-and-cannot-tell-you-after-a-laravel-intrusion-1hn5&quot;&gt;https://dev.to/harryagustiana/what-a-filesystem-scan-can-and-cannot-tell-you-after-a-laravel-intrusion-1hn5&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hryagstn/laravel-scalpel&quot;&gt;https://github.com/hryagstn/laravel-scalpel&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30211/how-much-can-you-trust-a-filesystem-baseline-design-notes-from-laravel-scalpel</guid>
<pubDate>Thu, 08 Oct 2026 04:44:43 +0000</pubDate>
</item>
<item>
<title>Write Your Game Lore Like an Encyclopedia, Not a Novel</title>
<link>https://coderlegion.com/29425/write-your-game-lore-like-an-encyclopedia-not-a-novel</link>
<description>&lt;p&gt;Plenty of us here have tried giving a game character an LLM brain, and the first version usually starts inventing its own world within ten minutes. This post is about a fix that is mostly a writing problem rather than a code problem: how you structure lore so the model gets the right facts at the right moment.&lt;/p&gt;
&lt;h4 id=&quot;how-retrieval-picks-what-the-model-sees&quot;&gt;How Retrieval Picks What the Model Sees&lt;/h4&gt;
&lt;p&gt;Pasting your whole world into a system prompt stops working once the world gets big. The usual answer is retrieval. Lore is split into chunks, each chunk gets an embedding, and on every player message the server finds the chunks closest in meaning and passes only those to the model alongside the conversation.&lt;/p&gt;
&lt;p&gt;The catch is that retrieval can only return what your chunks contain, and a chunk's embedding is an average of everything in it. A chunk about one tavern has a sharp fingerprint. A chunk that covers the tavern, the war, three characters and a currency system has a blurry one, and it either matches everything a little or nothing well.&lt;/p&gt;
&lt;h4 id=&quot;one-topic-per-chunk&quot;&gt;One Topic per Chunk&lt;/h4&gt;
&lt;p&gt;So write lore the way an encyclopedia is written. One entry for each place, one for each character, one for each rule of the world, each short enough to stand on its own. Name the subject in the entry itself, not just in a heading, so the chunk still makes sense when it is pulled out alone.&lt;/p&gt;
&lt;p&gt;Novel style writing works against you here. A beautiful chapter that introduces five characters through a conversation is hard for retrieval to use, because the facts about any one character are scattered across it. Keep the prose for the narrator to produce, and keep the source material plain.&lt;/p&gt;
&lt;p&gt;Tags help too. Put the world bible in one collection, item and rules references in another, and point each bot only at what it needs. When a character fails to know something, search the tag directly with the same prompt and you will usually see the problem in seconds: the fact was never chunked on its own.&lt;/p&gt;
&lt;h4 id=&quot;keep-temporary-state-out-of-the-lore&quot;&gt;Keep Temporary State Out of the Lore&lt;/h4&gt;
&lt;p&gt;Lore should hold what is always true. What is true for this player, or true only tonight, belongs somewhere else. Per player memories handle the first, and a per request rule string like &quot;the player is wounded and cannot fight&quot; handles the second without rewriting the world.&lt;/p&gt;
&lt;p&gt;Mixing them is how lore rots. Once the knowledge base contains &quot;the bridge is burned&quot; from one player's session, every other player inherits it.&lt;/p&gt;
&lt;h4 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h4&gt;
&lt;p&gt;If you want to try this without building the plumbing, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.abratabia.com/AbraTabia-AI-Storyteller/&quot;&gt;AbraTabia AI Storyteller&lt;/a&gt; is a free open source server, one PHP app and one SQLite file, that handles chunking, retrieval, per player memory and turn based play for up to 10 players behind one chat endpoint. Write the lore as entries, let retrieval do the selecting, and your narrator will stay inside your world.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29425/write-your-game-lore-like-an-encyclopedia-not-a-novel</guid>
<pubDate>Thu, 01 Oct 2026 15:52:21 +0000</pubDate>
</item>
<item>
<title>JSON vs MessagePack in Laravel: A Practical API Benchmark</title>
<link>https://coderlegion.com/28486/json-vs-messagepack-in-laravel-a-practical-api-benchmark</link>
<description>&lt;p&gt;JSON is the right default for most Laravel APIs. It is readable, supported everywhere, easy to debug, and familiar to every client.&lt;/p&gt;
&lt;p&gt;MessagePack is worth considering when payload size, bandwidth, or a controlled client ecosystem matters. It is a compact binary format that keeps a schema-free data model, but it is not automatically faster or smaller for every workload.&lt;/p&gt;
&lt;p&gt;This article compares both formats in a Laravel API and shows how to adopt MessagePack without breaking existing JSON clients.&lt;/p&gt;
&lt;p&gt;The examples use &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/smmehdisharifi/laravel-msgpack&quot;&gt;Laravel Msgpack&lt;/a&gt;, an open-source package that adds opt-in MessagePack content negotiation, request decoding, safety limits, and a repeatable benchmark command.&lt;/p&gt;
&lt;h3 id=&quot;the-short-answer&quot;&gt;The short answer&lt;/h3&gt;
&lt;p&gt;Use JSON when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your clients include browsers, third-party integrations, or unknown consumers.&lt;/li&gt;
&lt;li&gt;Human-readable responses and simple debugging are important.&lt;/li&gt;
&lt;li&gt;Your payloads are already small or compressed effectively.&lt;/li&gt;
&lt;li&gt;You do not control the client decoder and deployment environment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use MessagePack when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You control the clients and can ship a binary decoder.&lt;/li&gt;
&lt;li&gt;Network transfer size is important for mobile, edge, or internal services.&lt;/li&gt;
&lt;li&gt;Your payloads are large enough for serialization and decoding trade-offs to matter.&lt;/li&gt;
&lt;li&gt;You want to keep JSON compatibility while allowing capable clients to opt in.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For many Laravel applications, the best answer is not choosing one format globally. Keep JSON as the default and negotiate MessagePack per request.&lt;/p&gt;
&lt;h3 id=&quot;what-are-we-comparing-&quot;&gt;What are we comparing?&lt;/h3&gt;
&lt;p&gt;JSON is a text representation. It is self-describing, easy to inspect, and supported by the web platform.&lt;/p&gt;
&lt;p&gt;MessagePack is a binary representation. It encodes the same basic data model using compact type markers and lengths instead of textual punctuation and names repeated in a text format.&lt;/p&gt;
&lt;p&gt;The comparison should use the same data, the same PHP runtime, and the same operation count. Otherwise, the result is a benchmark of different workloads rather than a comparison of formats.&lt;/p&gt;
&lt;h3 id=&quot;add-messagepack-to-laravel&quot;&gt;Add MessagePack to Laravel&lt;/h3&gt;
&lt;p&gt;Install the package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require smmehdisharifi/laravel-msgpack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the middleware to a route or route group:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Illuminate\Support\Facades\Route;

Route::middleware('msgpack')-&amp;gt;get('/api/profile', function () {
    return [
        'id' =&amp;gt; 1042,
        'name' =&amp;gt; 'Laravel MessagePack',
        'roles' =&amp;gt; ['maintainer', 'developer'],
        'features' =&amp;gt; [
            'negotiation' =&amp;gt; true,
            'fallback' =&amp;gt; 'json',
        ],
    ];
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The route does not need separate JSON and MessagePack controllers. Existing clients continue to receive JSON:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -i http://localhost/api/profile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A capable client opts into MessagePack with the Accept header. Save the binary response instead of printing it as terminal text:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -sS -D - \
  -H 'Accept: application/msgpack' \
  -o profile.msgpack \
  http://localhost/api/profile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The negotiated response includes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-http&quot;&gt;Content-Type: application/msgpack
Vary: Accept
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vary: Accept is important because a cache must not serve a MessagePack response to a client that asked for JSON, or the other way around.&lt;/p&gt;
&lt;h3 id=&quot;how-content-negotiation-works&quot;&gt;How content negotiation works&lt;/h3&gt;
&lt;p&gt;The middleware keeps the migration opt-in:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A client that does not request MessagePack receives JSON.&lt;/li&gt;
&lt;li&gt;A client that sends Accept: application/msgpack receives a binary response when the route supports the middleware.&lt;/li&gt;
&lt;li&gt;If both formats are accepted, the higher quality factor wins.&lt;/li&gt;
&lt;li&gt;Wildcards are evaluated by media-type specificity.&lt;/li&gt;
&lt;li&gt;A request that rejects every supported representation receives 406 Not Acceptable.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The same negotiation applies to relevant error responses, including unmatched routes and route exceptions. Incoming requests can use application/msgpack or the legacy application/x-msgpack media type.&lt;/p&gt;
&lt;h3 id=&quot;run-a-repeatable-benchmark&quot;&gt;Run a repeatable benchmark&lt;/h3&gt;
&lt;p&gt;Laravel Msgpack includes an Artisan command so the comparison is easy to reproduce:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;php artisan msgpack:benchmark --iterations=1000 --json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command uses a deterministic API-shaped fixture with nested values and Unicode text. It measures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Raw JSON and MessagePack byte size.&lt;/li&gt;
&lt;li&gt;Encode time per iteration.&lt;/li&gt;
&lt;li&gt;Decode time per iteration.&lt;/li&gt;
&lt;li&gt;Optional gzip size and compression timings when ext-zlib is available.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The command warms each operation once before timing it. It reports per-iteration timings so runs with different iteration counts remain comparable.&lt;/p&gt;
&lt;h3 id=&quot;results-from-this-package&quot;&gt;Results from this package&lt;/h3&gt;
&lt;p&gt;The following result was produced by the package benchmark on PHP 8.4 with rybakit/msgpack 0.7.2 and 1,000 iterations:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Measurement &lt;/th&gt;&lt;th&gt;JSON &lt;/th&gt;&lt;th&gt;MessagePack &lt;/th&gt;&lt;th&gt;Difference&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Raw payload &lt;/td&gt;&lt;td&gt;591 B &lt;/td&gt;&lt;td&gt;435 B &lt;/td&gt;&lt;td&gt;26.40% smaller&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Encode time &lt;/td&gt;&lt;td&gt;0.002 ms &lt;/td&gt;&lt;td&gt;0.009 ms &lt;/td&gt;&lt;td&gt;JSON faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Decode time &lt;/td&gt;&lt;td&gt;0.003 ms &lt;/td&gt;&lt;td&gt;0.008 ms &lt;/td&gt;&lt;td&gt;JSON faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gzip payload &lt;/td&gt;&lt;td&gt;377 B &lt;/td&gt;&lt;td&gt;371 B &lt;/td&gt;&lt;td&gt;1.59% smaller&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gzip compress time &lt;/td&gt;&lt;td&gt;0.009 ms &lt;/td&gt;&lt;td&gt;0.010 ms &lt;/td&gt;&lt;td&gt;Nearly equal&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gzip decompress time &lt;/td&gt;&lt;td&gt;0.004 ms &lt;/td&gt;&lt;td&gt;0.003 ms &lt;/td&gt;&lt;td&gt;Nearly equal&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There are three useful conclusions here.&lt;/p&gt;
&lt;p&gt;First, MessagePack is noticeably smaller before compression for this fixture. That can matter when transfer size is the bottleneck.&lt;/p&gt;
&lt;p&gt;Second, the smallest representation is not automatically the fastest representation. For this small payload, JSON serialization is faster in the measured run.&lt;/p&gt;
&lt;p&gt;Third, compression changes the size comparison. Once gzip is applied, the gap is much smaller because gzip already removes a lot of JSON's repetitive syntax.&lt;/p&gt;
&lt;p&gt;These are indicative measurements, not universal promises. A real decision should use payloads, client runtimes, compression settings, and network conditions that match your application.&lt;/p&gt;
&lt;h3 id=&quot;decode-messagepack-requests&quot;&gt;Decode MessagePack requests&lt;/h3&gt;
&lt;p&gt;MessagePack request bodies are decoded automatically when the middleware is applied:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Illuminate\Http\Request;

Route::middleware('msgpack')-&amp;gt;post('/api/profile', function (Request $request) {
    return [
        'received' =&amp;gt; $request-&amp;gt;msgpack(),
        'name' =&amp;gt; $request-&amp;gt;input('name'),
    ];
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Map payloads are also merged into Laravel's normal request input. The original decoded value remains available through request()-&amp;gt;msgpack(), including scalar and list payloads.&lt;/p&gt;
&lt;p&gt;The package rejects malformed payloads before they reach the route handler. Configurable request size, nesting-depth, and value-count guards provide additional protection against oversized or unexpectedly deep input.&lt;/p&gt;
&lt;h3 id=&quot;when-a-route-must-always-return-messagepack&quot;&gt;When a route must always return MessagePack&lt;/h3&gt;
&lt;p&gt;Negotiation is useful when the same endpoint serves different clients. For a route that should always return MessagePack, use the response macro:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;return response()-&amp;gt;msgpack([
    'message' =&amp;gt; 'Created',
], 201, [
    'X-Request-Id' =&amp;gt; $requestId,
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Direct serialization is also available through the facade:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Msgpack;

$packed = Msgpack::encode($data);
$unpacked = Msgpack::decode($packed);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;a-practical-decision-guide&quot;&gt;A practical decision guide&lt;/h3&gt;
&lt;p&gt;Do not change every endpoint to MessagePack because a synthetic benchmark produced a smaller payload. Instead, measure the part of the system that matters.&lt;/p&gt;
&lt;p&gt;Choose JSON first when interoperability, browser support, observability, or simple debugging is the priority.&lt;/p&gt;
&lt;p&gt;Test MessagePack when bandwidth, payload volume, or mobile transfer cost is important and the clients are controlled.&lt;/p&gt;
&lt;p&gt;Keep both formats when you serve a mixed ecosystem. JSON remains the safe default, while clients that understand MessagePack can opt into it without a second controller implementation.&lt;/p&gt;
&lt;h3 id=&quot;limitations-to-keep-in-mind&quot;&gt;Limitations to keep in mind&lt;/h3&gt;
&lt;p&gt;MessagePack is not encryption. Sensitive data still needs transport security and application-level authorization.&lt;/p&gt;
&lt;p&gt;Binary responses are less convenient to inspect in logs and command-line terminals. Save them to a file or decode them with a client library.&lt;/p&gt;
&lt;p&gt;Compression may reduce the practical size advantage. Measure compressed and uncompressed responses for the transport configuration you actually deploy.&lt;/p&gt;
&lt;p&gt;The benchmark fixture is intentionally stable, not universal. A list-heavy payload, a text-heavy payload, a large nested object, or a response with repeated keys can produce different results.&lt;/p&gt;
&lt;h3 id=&quot;try-it-in-your-project&quot;&gt;Try it in your project&lt;/h3&gt;
&lt;p&gt;Install &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/smmehdisharifi/laravel-msgpack&quot;&gt;Laravel Msgpack&lt;/a&gt;, apply the msgpack middleware to one endpoint, and compare both representations with your real data.&lt;/p&gt;
&lt;p&gt;The repository contains the full implementation, compatibility matrix, configuration reference, safety limits, and benchmark command:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/smmehdisharifi/laravel-msgpack&quot;&gt;https://github.com/smmehdisharifi/laravel-msgpack&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If the package helps your API, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/smmehdisharifi/laravel-msgpack/stargazers&quot;&gt;a GitHub star&lt;/a&gt; helps other Laravel developers discover it.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/28486/json-vs-messagepack-in-laravel-a-practical-api-benchmark</guid>
<pubDate>Thu, 24 Sep 2026 13:39:50 +0000</pubDate>
</item>
<item>
<title>Multiple Image Uploads in WPForms Lite: How I Built Multimage</title>
<link>https://coderlegion.com/28388/multiple-image-uploads-in-wpforms-lite-how-i-built-multimage</link>
<description>&lt;p&gt;WPForms Lite is one of the most popular free form builders in WordPress, but it has one gap that keeps coming up: there's no file upload field. If you want visitors to attach photos to a contact form, a support request, or a portfolio submission, the usual answer is &quot;upgrade to Pro.&quot;&lt;/p&gt;
&lt;p&gt;I wanted a lighter answer for that one use case, so I built &lt;strong&gt;Multimage – Image Uploads for WPForms&lt;/strong&gt;, a free plugin on WordPress.org that adds a Multi Image Upload field to the WPForms builder, Lite included.&lt;/p&gt;
&lt;h3 id=&quot;what-it-does&quot;&gt;What it does&lt;/h3&gt;
&lt;p&gt;Once activated, a new &lt;strong&gt;Multi Image Upload&lt;/strong&gt; field appears in the WPForms Standard Fields panel. Drag it into any form and configure it per field: maximum number of uploads, allowed file extensions, and maximum file size in MB.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ps.w.org/multimage-image-uploads-for-wpforms/assets/screenshot-2.png&quot; alt=&quot;Multi Image Upload field in the WPForms builder&quot;&gt;&lt;/p&gt;
&lt;p&gt;On the front end, visitors can pick several images at once, see them listed with individual remove buttons, and submit. On the admin side, image URLs show up in the &lt;code&gt;{all_fields}&lt;/code&gt; email template, and the images themselves are attached to notification emails.&lt;/p&gt;
&lt;h3 id=&quot;the-tricky-part-ajax-submission&quot;&gt;The tricky part: AJAX submission&lt;/h3&gt;
&lt;p&gt;WPForms can submit forms with or without AJAX, and files don't travel nicely through form serialization. Building an upload field that only works in one mode would have been fragile.&lt;/p&gt;
&lt;p&gt;So Multimage doesn't wait for the form submit. The flow looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When a visitor selects images, each file is uploaded &lt;strong&gt;immediately&lt;/strong&gt; to a temporary folder through a secured AJAX endpoint.&lt;/li&gt;
&lt;li&gt;The endpoint returns a lightweight token for each file.&lt;/li&gt;
&lt;li&gt;Only those tokens travel with the form submission, not the files.&lt;/li&gt;
&lt;li&gt;When the submission succeeds, the files are moved from the temp folder into the standard WordPress uploads directory (&lt;code&gt;wp-content/uploads/year/month&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because the heavy lifting happens before submit, it doesn't matter whether AJAX submission is on or off. Nothing gets lost.&lt;/p&gt;
&lt;h3 id=&quot;security-decisions&quot;&gt;Security decisions&lt;/h3&gt;
&lt;p&gt;Accepting files from anonymous visitors is where most upload plugins get into trouble, so validation happens server-side regardless of what the field settings say:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Content-based MIME detection&lt;/strong&gt;, not just trusting the file extension&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A hard image-only whitelist&lt;/strong&gt;: jpg, jpeg, png, gif, and webp. Even if someone misconfigures the field, non-image files are rejected&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Size limits&lt;/strong&gt; enforced on the server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic cleanup&lt;/strong&gt;: temporary uploads that were never attached to a successful submission are deleted after 24 hours, so abandoned forms don't fill up your disk&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-images-don-t-go-into-the-media-library&quot;&gt;Why images don't go into the Media Library&lt;/h3&gt;
&lt;p&gt;A deliberate choice: submitted images are saved to the uploads directory but are &lt;strong&gt;not&lt;/strong&gt; registered as Media Library attachments. Visitor submissions are form data, not site content, and a busy form shouldn't bury your actual media under hundreds of random photos.&lt;/p&gt;
&lt;h3 id=&quot;where-it-fits&quot;&gt;Where it fits&lt;/h3&gt;
&lt;p&gt;Any form where visitors need to send more than one photo: gallery or portfolio submissions, job or program applications, product damage and support requests, property or event photo forms. Need a single profile photo and a larger gallery in one form? Add two Multi Image Upload fields with different limits.&lt;/p&gt;
&lt;h3 id=&quot;privacy&quot;&gt;Privacy&lt;/h3&gt;
&lt;p&gt;Everything stays on your server. Images live in your uploads directory and are only emailed where you've configured WPForms notifications. No external services are involved.&lt;/p&gt;
&lt;h3 id=&quot;try-it-1&quot;&gt;Try it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Plugin:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://wordpress.org/plugins/multimage-image-uploads-for-wpforms/&quot;&gt;Multimage – Image Uploads for WPForms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; WordPress 5.8+, PHP 7.4+, WPForms Lite or Pro&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's a 1.0 release, so feedback, bug reports, and feature requests on the support forum are very welcome.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Multimage is an independent, third-party add-on and is not affiliated with, endorsed by, or sponsored by WPForms or Awesome Motive, Inc. &quot;WPForms&quot; is used only to describe compatibility.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28388/multiple-image-uploads-in-wpforms-lite-how-i-built-multimage</guid>
<pubDate>Wed, 23 Sep 2026 19:24:10 +0000</pubDate>
</item>
<item>
<title>Building explainable duplicate detection in Laravel (no ML, no packages)</title>
<link>https://coderlegion.com/27941/building-explainable-duplicate-detection-in-laravel-no-ml-no-packages</link>
<description>&lt;p&gt;I built a rental listing platform with &lt;strong&gt;Laravel 13, PHP 8.4, PostgreSQL, Redis, and Sanctum&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;One problem quickly became obvious:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Agents could post the same property multiple times.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In Nigeria, property listings often don't have precise street addresses. A typical listing might look like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;2-bedroom flat, Yaba. Near Yaba Tech.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There may be no house number and sometimes no street name.&lt;/p&gt;
&lt;p&gt;Instead, people identify properties using a combination of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Area&lt;/li&gt;
&lt;li&gt;Landmark&lt;/li&gt;
&lt;li&gt;Property type&lt;/li&gt;
&lt;li&gt;Number of bedrooms&lt;/li&gt;
&lt;li&gt;Rent&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That makes duplicate detection a little different from what you might build for a marketplace with structured addresses.&lt;/p&gt;
&lt;p&gt;I wanted a solution that was:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Explainable&lt;/li&gt;
&lt;li&gt;Easy to tune&lt;/li&gt;
&lt;li&gt;Easy for reviewers to understand&lt;/li&gt;
&lt;li&gt;Independent of machine learning&lt;/li&gt;
&lt;li&gt;Cheap enough to run whenever a listing changes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So I built a &lt;strong&gt;rule-based duplicate detection system using Laravel and PHP&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-architecture&quot;&gt;The Architecture&lt;/h3&gt;
&lt;p&gt;The basic workflow is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Property created/updated
        ↓
Find candidate properties
        ↓
Calculate similarity score
        ↓
Store score + explanation
        ↓
Classify match
        ↓
Reviewer confirms/rejects
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important distinction is that I don't compare every property against every other property.&lt;/p&gt;
&lt;p&gt;Instead, I first narrow the candidate set using database queries and then perform the more expensive comparison in PHP.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-creating-a-property-fingerprint&quot;&gt;1. Creating a Property Fingerprint&lt;/h2&gt;
&lt;p&gt;Because Nigerian rental listings don't always have reliable addresses, I needed another way to represent a property's identity.&lt;/p&gt;
&lt;p&gt;I started with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Area + Landmark + Bedrooms + Property Type
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These fields are normalized and hashed into a fingerprint.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class PropertyFingerprintService
{
    public static function generate(array $data): string
    {
        $parts = [
            strtolower(trim($data['area'] ?? '')),
            strtolower(trim($data['landmark'] ?? '')),
            (string) ($data['bedrooms'] ?? ''),
            strtolower(trim($data['property_type'] ?? '')),
        ];

        return hash('sha256', implode('|', $parts));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Yaba | Near Yaba Tech | 2 | Flat
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;becomes a deterministic SHA-256 hash.&lt;/p&gt;
&lt;p&gt;This gives us a fast way to identify listings that have the same core identity fields.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;an-important-database-lesson&quot;&gt;An Important Database Lesson&lt;/h3&gt;
&lt;p&gt;Initially, I made the fingerprint column unique:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$table-&amp;gt;string('fingerprint')-&amp;gt;unique();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That turned out to be a mistake.&lt;/p&gt;
&lt;p&gt;If an agent tried to create the same property again, the database rejected it.&lt;/p&gt;
&lt;p&gt;That sounds useful until you remember the purpose of the feature.&lt;/p&gt;
&lt;p&gt;I don't want the database to prevent the duplicate.&lt;/p&gt;
&lt;p&gt;I want the duplicate to exist so that the platform can &lt;strong&gt;surface it for human review&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;So I changed the migration to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$table-&amp;gt;dropUnique(['fingerprint']);
$table-&amp;gt;index('fingerprint');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now multiple properties can have the same fingerprint.&lt;/p&gt;
&lt;p&gt;The database helps us &lt;strong&gt;find duplicates instead of blocking them&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That distinction is important for any system where duplicates are supposed to be reviewed rather than automatically rejected.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-candidate-selection&quot;&gt;2. Candidate Selection&lt;/h2&gt;
&lt;p&gt;The next problem is scale.&lt;/p&gt;
&lt;p&gt;Imagine the platform has 100,000 properties.&lt;/p&gt;
&lt;p&gt;Comparing one new property against all 100,000 would obviously be wasteful.&lt;/p&gt;
&lt;p&gt;Instead, I narrow the candidates using fields that are already indexed.&lt;/p&gt;
&lt;p&gt;The query looks roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;public function findCandidates(Property $property): EloquentCollection
{
    return Property::query()
        -&amp;gt;where('id', '!=', $property-&amp;gt;id)
        -&amp;gt;where(function ($query) use ($property) {
            $query-&amp;gt;where('fingerprint', $property-&amp;gt;fingerprint)
                -&amp;gt;orWhere(function ($inner) use ($property) {
                    $inner-&amp;gt;where('area', $property-&amp;gt;area)
                        -&amp;gt;where('bedrooms', $property-&amp;gt;bedrooms);
                });

            if ($property-&amp;gt;landmark) {
                $query-&amp;gt;orWhere('landmark', $property-&amp;gt;landmark);
            }
        })
        -&amp;gt;get();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The query looks simple, but there is an important Laravel detail here.&lt;/p&gt;
&lt;h3 id=&quot;group-your-orwhere-clauses&quot;&gt;Group your &lt;code&gt;orWhere&lt;/code&gt; clauses&lt;/h3&gt;
&lt;p&gt;The exclusion:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;-&amp;gt;where('id', '!=', $property-&amp;gt;id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;needs to apply to every branch of the candidate query.&lt;/p&gt;
&lt;p&gt;That's why the &lt;code&gt;orWhere&lt;/code&gt; conditions are wrapped in a closure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;-&amp;gt;where(function ($query) {
    // OR conditions
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without that grouping, you can accidentally generate logic equivalent to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;id != ?
AND fingerprint = ?
OR area = ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;OR&lt;/code&gt; can escape the intended grouping and return the current property itself.&lt;/p&gt;
&lt;p&gt;For queries with multiple &lt;code&gt;OR&lt;/code&gt; conditions, explicit grouping is worth paying attention to.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-scoring-the-candidates&quot;&gt;3. Scoring the Candidates&lt;/h2&gt;
&lt;p&gt;Once I have a relatively small candidate set, I score each property against the new listing.&lt;/p&gt;
&lt;p&gt;The scoring model is intentionally simple.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Signal &lt;/th&gt;&lt;th&gt;Points&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Same area &lt;/td&gt;&lt;td&gt;25&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same landmark &lt;/td&gt;&lt;td&gt;20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same bedrooms &lt;/td&gt;&lt;td&gt;15&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Rent within 10% &lt;/td&gt;&lt;td&gt;20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Description similarity &lt;/td&gt;&lt;td&gt;20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Maximum&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The goal isn't to produce a statistically perfect probability.&lt;/p&gt;
&lt;p&gt;The goal is to produce a number that a reviewer can understand.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;keeping-the-explanation&quot;&gt;Keeping the Explanation&lt;/h3&gt;
&lt;p&gt;Before calculating the score, I create a details structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$details = [
    'area_match' =&amp;gt; false,
    'landmark_match' =&amp;gt; false,
    'bedrooms_match' =&amp;gt; false,
    'rent_match' =&amp;gt; null,
    'rent_difference_percent' =&amp;gt; null,
    'description_similarity' =&amp;gt; 0.0,
    'fingerprint_match' =&amp;gt; false,
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is important because storing only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;confidence_score = 87
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;doesn't tell us much.&lt;/p&gt;
&lt;p&gt;Instead, we can store:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;area_match&quot;: true,
    &quot;landmark_match&quot;: true,
    &quot;bedrooms_match&quot;: true,
    &quot;rent_match&quot;: &quot;within_10_percent&quot;,
    &quot;rent_difference_percent&quot;: 4.2,
    &quot;description_similarity&quot;: 87.4,
    &quot;fingerprint_match&quot;: false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the reviewer can understand why the system flagged the properties.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-comparing-the-area&quot;&gt;4. Comparing the Area&lt;/h2&gt;
&lt;p&gt;The area comparison is straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;if (strcasecmp(trim($a-&amp;gt;area), trim($b-&amp;gt;area)) === 0) {
    $details['area_match'] = true;

    $points += self::WEIGHT_AREA;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;private const WEIGHT_AREA = 25;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-comparing-rent&quot;&gt;5. Comparing Rent&lt;/h2&gt;
&lt;p&gt;Rent shouldn't need to be exactly the same.&lt;/p&gt;
&lt;p&gt;Different agents might advertise the same property at slightly different prices.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Agent A: ₦1,500,000
Agent B: ₦1,550,000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These listings could still represent the same property.&lt;/p&gt;
&lt;p&gt;So I calculate the percentage difference:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;if ((float) $a-&amp;gt;rent &amp;gt; 0 &amp;amp;&amp;amp; (float) $b-&amp;gt;rent &amp;gt; 0) {
    $diff = abs((float) $a-&amp;gt;rent - (float) $b-&amp;gt;rent)
        / max((float) $a-&amp;gt;rent, (float) $b-&amp;gt;rent);

    $details['rent_difference_percent'] = round($diff * 100, 2);

    if ($diff &amp;lt;= 0.10) {
        $details['rent_match'] = 'within_10_percent';

        $points += self::WEIGHT_RENT;
    } elseif ($diff &amp;lt;= 0.20) {
        $details['rent_match'] = 'within_20_percent';

        $points += self::WEIGHT_RENT / 2;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Within 10% → 20 points&lt;/li&gt;
&lt;li&gt;Within 20% → 10 points&lt;/li&gt;
&lt;li&gt;More than 20% → 0 points&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This gives us a little tolerance without treating every price as equivalent.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6-comparing-descriptions&quot;&gt;6. Comparing Descriptions&lt;/h2&gt;
&lt;p&gt;For descriptions, I used PHP's built-in &lt;code&gt;similar_text()&lt;/code&gt; rather than introducing another package.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;similar_text(
    strtolower($a-&amp;gt;description),
    strtolower($b-&amp;gt;description),
    $sim
);

if ($sim &amp;gt;= 80) {
    $points += self::WEIGHT_DESCRIPTION;
} elseif ($sim &amp;gt;= 60) {
    $points += self::WEIGHT_DESCRIPTION / 2;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The thresholds are:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;80%+ → 20 points
60–79% → 10 points
&amp;lt;60% → 0 points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works reasonably well for short rental descriptions.&lt;/p&gt;
&lt;p&gt;It is not intended to be a sophisticated natural-language similarity model.&lt;/p&gt;
&lt;p&gt;And that's okay for this stage.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;7-the-fingerprint-rule&quot;&gt;7. The Fingerprint Rule&lt;/h2&gt;
&lt;p&gt;A matching fingerprint is a strong signal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Area
+
Landmark
+
Bedrooms
+
Property type
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If those fields all match, I want the listing to be reviewed even if some other signals differ.&lt;/p&gt;
&lt;p&gt;The final score is calculated like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$confidenceScore = round(min($points, 100), 2);

if ($details['fingerprint_match']) {
    $confidenceScore = max($confidenceScore, 90.0);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So a fingerprint match effectively creates a minimum score of 90.&lt;/p&gt;
&lt;p&gt;That means the system classifies it as a very likely duplicate.&lt;/p&gt;
&lt;p&gt;This is a deliberate product decision rather than a mathematical requirement.&lt;/p&gt;
&lt;p&gt;Later, this should probably become configurable because two properties can theoretically share the same area, landmark, bedrooms and type while still being different properties.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;8-turning-the-score-into-a-match-level&quot;&gt;8. Turning the Score Into a Match Level&lt;/h2&gt;
&lt;p&gt;I use an enum to map the score into a meaningful state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;public static function fromScore(float $score): self
{
    return match (true) {
        $score &amp;gt;= 90 =&amp;gt; self::VERY_LIKELY,
        $score &amp;gt;= 70 =&amp;gt; self::POSSIBLE,
        default      =&amp;gt; self::UNIQUE,
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting categories are:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Score &lt;/th&gt;&lt;th&gt;Level&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;90–100 &lt;/td&gt;&lt;td&gt;Very likely&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;70–89 &lt;/td&gt;&lt;td&gt;Possible&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&amp;lt;70 &lt;/td&gt;&lt;td&gt;Unique&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The UI doesn't need to know how the score was calculated.&lt;/p&gt;
&lt;p&gt;It simply displays the appropriate match level and the underlying explanation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;9-persisting-the-match-details&quot;&gt;9. Persisting the Match Details&lt;/h2&gt;
&lt;p&gt;The score and explanation are stored on a &lt;code&gt;duplicate_matches&lt;/code&gt; record.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;property_id
matched_property_id
confidence_score
match_details
status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;match_details&lt;/code&gt; column is JSON.&lt;/p&gt;
&lt;p&gt;That means I can store the complete scoring breakdown without creating a separate database column for every signal.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;area_match&quot;: true,
    &quot;landmark_match&quot;: true,
    &quot;bedrooms_match&quot;: true,
    &quot;rent_match&quot;: &quot;within_10_percent&quot;,
    &quot;rent_difference_percent&quot;: 4.2,
    &quot;description_similarity&quot;: 87.4,
    &quot;fingerprint_match&quot;: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reviewer UI can then display something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Very likely duplicate — 94

✓ Same area
✓ Same landmark
✓ Same bedrooms
✓ Rent within 10%
✓ Similar description
✓ Matching property fingerprint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's much more useful than simply saying:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Duplicate confidence: 94%&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;10-reviewer-decisions-must-survive-edits&quot;&gt;10. Reviewer Decisions Must Survive Edits&lt;/h2&gt;
&lt;p&gt;There is another problem with running detection every time a property changes.&lt;/p&gt;
&lt;p&gt;Suppose a reviewer sees:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Property A ↔ Property B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and decides:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;These are different properties.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The reviewer rejects the match.&lt;/p&gt;
&lt;p&gt;Then the agent edits the listing.&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;detect()&lt;/code&gt; simply runs again and recreates the match, the reviewer gets the same warning again.&lt;/p&gt;
&lt;p&gt;That's frustrating.&lt;/p&gt;
&lt;p&gt;So duplicate matches have state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pending
rejected
confirmed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When checking an existing match:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$existing = DuplicateMatch::query()
    -&amp;gt;where('property_id', $property-&amp;gt;id)
    -&amp;gt;where('matched_property_id', $candidate-&amp;gt;id)
    -&amp;gt;first();

if ($existing &amp;amp;&amp;amp; $existing-&amp;gt;status === 'rejected' &amp;amp;&amp;amp; ! $rescan) {
    continue;
}

if ($existing &amp;amp;&amp;amp; $existing-&amp;gt;status === 'confirmed') {
    continue;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A rejected match stays rejected during normal edits.&lt;/p&gt;
&lt;p&gt;A manual rescan can explicitly override that decision.&lt;/p&gt;
&lt;p&gt;This makes the system behave more like a workflow rather than just a calculation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;11-avoiding-timeline-noise&quot;&gt;11. Avoiding Timeline Noise&lt;/h2&gt;
&lt;p&gt;Duplicate detection can run frequently.&lt;/p&gt;
&lt;p&gt;If every score recalculation creates a timeline event, the property history becomes noisy.&lt;/p&gt;
&lt;p&gt;So I only log meaningful changes.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$scoreChanged = $existing
    &amp;amp;&amp;amp; abs(
        (float) $existing-&amp;gt;confidence_score
        - $score-&amp;gt;confidenceScore
    ) &amp;gt;= 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A timeline event is created when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A new match is detected&lt;/li&gt;
&lt;li&gt;A manual rescan happens&lt;/li&gt;
&lt;li&gt;The score changes significantly&lt;/li&gt;
&lt;li&gt;A reviewer confirms/rejects a match&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This keeps the audit trail useful.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;12-where-detection-runs&quot;&gt;12. Where Detection Runs&lt;/h2&gt;
&lt;p&gt;The detector runs after the property has been saved.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// PropertyCreator

$this-&amp;gt;duplicateDetection-&amp;gt;detect(
    $property,
    $user
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And after an update:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// PropertyController@update

$this-&amp;gt;duplicateDetection-&amp;gt;detect(
    $property-&amp;gt;fresh(),
    $user
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is also useful for bulk imports.&lt;/p&gt;
&lt;p&gt;My &lt;code&gt;.xlsx&lt;/code&gt; importer reuses the same property creation workflow.&lt;/p&gt;
&lt;p&gt;That means imported properties automatically go through duplicate detection without implementing another version of the algorithm.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;13-testing-the-detector&quot;&gt;13. Testing the Detector&lt;/h2&gt;
&lt;p&gt;I didn't just test properties that I expected to be duplicates.&lt;/p&gt;
&lt;p&gt;I created test spreadsheets containing both duplicates and controls.&lt;/p&gt;
&lt;p&gt;Some examples:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Scenario &lt;/th&gt;&lt;th&gt;Score&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Same fingerprint + cosmetic differences &lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same area + different landmark + similar rent/description &lt;/td&gt;&lt;td&gt;80&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Duplicate properties in one import &lt;/td&gt;&lt;td&gt;100&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same area but different size &lt;/td&gt;&lt;td&gt;25&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same area + bedrooms but different rent/description &lt;/td&gt;&lt;td&gt;40&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The control cases are extremely important.&lt;/p&gt;
&lt;p&gt;Without them, it's easy to keep increasing the number of signals until almost every property gets flagged.&lt;/p&gt;
&lt;p&gt;A duplicate detector isn't useful if reviewers don't trust its results.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;14-what-i-d-fix-next&quot;&gt;14. What I'd Fix Next&lt;/h2&gt;
&lt;p&gt;The current implementation works, but there are several areas I would improve.&lt;/p&gt;
&lt;h3 id=&quot;canonical-areas-and-landmarks&quot;&gt;Canonical Areas and Landmarks&lt;/h3&gt;
&lt;p&gt;Exact string matching creates problems:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Yaba
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Yaba, Lagos
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;are treated as different.&lt;/p&gt;
&lt;p&gt;Similarly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Opp. Yaba Tech
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Opposite Yaba Tech
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;are different strings.&lt;/p&gt;
&lt;p&gt;A better approach would be a canonical area/landmark system with aliases.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Yaba
├── Yaba, Lagos
├── Yaba Lagos
└── Yaba Mainland
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;could all resolve to the same canonical identifier.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;case-sensitive-candidate-queries&quot;&gt;Case-Sensitive Candidate Queries&lt;/h3&gt;
&lt;p&gt;The scoring logic uses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;strcasecmp()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;but the candidate query uses normal SQL equality.&lt;/p&gt;
&lt;p&gt;That creates an inconsistency.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Yaba
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;yaba
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;may score as a match but never become candidates in the first place.&lt;/p&gt;
&lt;p&gt;With PostgreSQL, normalized columns or &lt;code&gt;LOWER()&lt;/code&gt; expression indexes would make this more consistent.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;bedroom-type-casting&quot;&gt;Bedroom Type Casting&lt;/h3&gt;
&lt;p&gt;The database may return:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;while another value might be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&quot;2&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If strict comparisons are used, those can behave differently.&lt;/p&gt;
&lt;p&gt;Casting the model attribute is safer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;protected $casts = [
    'bedrooms' =&amp;gt; 'integer',
];
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;normalizing-match-pairs&quot;&gt;Normalizing Match Pairs&lt;/h3&gt;
&lt;p&gt;Currently:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A → B
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;B → A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;can become separate records.&lt;/p&gt;
&lt;p&gt;That means reviewers could potentially see the same duplicate pair twice.&lt;/p&gt;
&lt;p&gt;A better approach is to normalize the IDs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$firstId = min($property-&amp;gt;id, $candidate-&amp;gt;id);
$secondId = max($property-&amp;gt;id, $candidate-&amp;gt;id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then always store:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;first_id
second_id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;in a consistent order.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;similar-text-performance&quot;&gt;&lt;code&gt;similar_text()&lt;/code&gt; Performance&lt;/h3&gt;
&lt;p&gt;PHP's &lt;code&gt;similar_text()&lt;/code&gt; is convenient, but it isn't something I'd want to run against large amounts of text indefinitely.&lt;/p&gt;
&lt;p&gt;For short rental descriptions, it's acceptable.&lt;/p&gt;
&lt;p&gt;If descriptions become long or the candidate set grows significantly, I'd add a cheaper prefilter before running the similarity calculation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-fingerprint-floor&quot;&gt;The Fingerprint Floor&lt;/h3&gt;
&lt;p&gt;Currently:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;if ($details['fingerprint_match']) {
    $confidenceScore = max($confidenceScore, 90);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means matching identity fields can result in a duplicate score of 90 even when the rent is very different.&lt;/p&gt;
&lt;p&gt;Sometimes that's correct.&lt;/p&gt;
&lt;p&gt;Sometimes it isn't.&lt;/p&gt;
&lt;p&gt;I'd eventually make the fingerprint floor configurable so product/operations can tune the behavior based on real reviewer decisions.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;15-why-i-didn-t-start-with-machine-learning&quot;&gt;15. Why I Didn't Start With Machine Learning&lt;/h2&gt;
&lt;p&gt;There are three main reasons.&lt;/p&gt;
&lt;h4 id=&quot;1-there-wasn-t-enough-training-data&quot;&gt;1. There wasn't enough training data&lt;/h4&gt;
&lt;p&gt;A new marketplace doesn't have thousands of confirmed duplicate/non-duplicate examples.&lt;/p&gt;
&lt;p&gt;Without good labelled data, an ML model isn't automatically going to solve the problem.&lt;/p&gt;
&lt;h4 id=&quot;2-reviewers-need-explanations&quot;&gt;2. Reviewers need explanations&lt;/h4&gt;
&lt;p&gt;If a reviewer asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Why did you flag these two properties?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I can answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Same area, same landmark, same bedrooms, rent is 4.2% apart, and the descriptions are 87% similar.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's actionable.&lt;/p&gt;
&lt;p&gt;A black-box prediction isn't as useful for an operational workflow.&lt;/p&gt;
&lt;h4 id=&quot;3-the-weights-are-easy-to-change&quot;&gt;3. The weights are easy to change&lt;/h4&gt;
&lt;p&gt;The scoring weights are centralized:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;private const WEIGHT_AREA = 25;
private const WEIGHT_LANDMARK = 20;
private const WEIGHT_BEDROOMS = 15;
private const WEIGHT_RENT = 20;
private const WEIGHT_DESCRIPTION = 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If reviewer feedback shows that one signal is too aggressive, I can tune it without introducing a new model-training pipeline.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-bigger-lesson&quot;&gt;The Bigger Lesson&lt;/h2&gt;
&lt;p&gt;The interesting part of this project wasn't really the scoring formula.&lt;/p&gt;
&lt;p&gt;It was the workflow around it.&lt;/p&gt;
&lt;p&gt;A useful duplicate detection system needs to answer more than:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Are these two properties similar?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It also needs to answer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Why were they flagged?&lt;/li&gt;
&lt;li&gt;What happens when a reviewer disagrees?&lt;/li&gt;
&lt;li&gt;Does that decision survive future edits?&lt;/li&gt;
&lt;li&gt;Can the reviewer rescan?&lt;/li&gt;
&lt;li&gt;Can the match be confirmed?&lt;/li&gt;
&lt;li&gt;Can the listings eventually be merged?&lt;/li&gt;
&lt;li&gt;Can we audit what happened?&lt;/li&gt;
&lt;li&gt;Can the scoring rules be changed later?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The detection algorithm is only one part of the feature.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;review workflow is what makes the detection useful in production.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;If you're building duplicate detection for a marketplace, especially in a market without clean addresses:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't put a unique constraint on something humans are supposed to review.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Narrow candidates in SQL, then perform detailed scoring in application code.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Group your &lt;code&gt;orWhere&lt;/code&gt; clauses carefully.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Store the explanation, not just the score.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat reviewer decisions as persistent state.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test controls, not only the duplicates you want to catch.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with deterministic rules you can explain.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use real reviewer decisions to improve the rules over time.&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You don't always need machine learning to build useful marketplace intelligence.&lt;/p&gt;
&lt;p&gt;Sometimes the best first version is a simple system that understands the signals your market actually has, produces a result people can explain, and gives humans the final say.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27941/building-explainable-duplicate-detection-in-laravel-no-ml-no-packages</guid>
<pubDate>Mon, 21 Sep 2026 11:53:07 +0000</pubDate>
</item>
<item>
<title>Difference between session and cookies in php</title>
<link>https://coderlegion.com/27805/difference-between-session-and-cookies-in-php</link>
<description>&lt;p&gt;&lt;strong&gt;
    Today, we're going to discuss about two common things, that are &lt;em&gt;Session&lt;/em&gt; and &lt;em&gt;Cookies&lt;/em&gt;, which are used in the website to keep track of the information provided by the users, but for different purposes. A lot of beginners are confused about their characteristics, functionalities, and differences. In this study blog, we'll explore both of them in a more comprehensive way.
&lt;/strong&gt;&lt;/p&gt;
&lt;div id=&quot;toc-div&quot;&gt;
  &lt;h4&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;&lt;strong&gt; Table of Contents: &lt;a id=&quot;toc-toogle&quot; class=&quot;open&quot;&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/span&gt;&lt;/h4&gt;
  &lt;div id=&quot;toc-list&quot;&gt;
    &lt;ol&gt;
      &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774610&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Introduction to Session and Cookies&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774611&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Difference between Session and Cookies&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
      
        &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774612&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Storage Location&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774613&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Security&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774614&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Lifetime&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774615&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Stored Data Type&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
        &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774616&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Purpose of Usage&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
      
      &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774617&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;The Conclusion&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#_Toc123774618&quot;&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;The Reference&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;section&gt;
  &lt;h2&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774610&quot; id=&quot;_Toc123774610&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Introduction to Session and Cookies&lt;/span&gt; &lt;a id=&quot;TypeError-str-object&quot; style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774610&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h2&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Session&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;p&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;&lt;em&gt;Session&lt;/em&gt; is used to &lt;strong&gt;store information across the multiple pages of a website momentarily on the server-side files&lt;/strong&gt;. It's useful when the website needs to track who is the current user across multiple pages of the same website. Let's look at the example:&lt;/span&gt;&lt;/p&gt;
      &lt;ol&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A user is using a POST method to send a request to the server for logging in the website.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;The information provided is sent to the server for validation via HTTP context.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;However, the HTTP context doesn't maintain the state of a user. As a result, the server is able to validate the current user at the moment only when the request is sent. The other pages are not informed about who is the current user.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;To overcome this limitation, the server will generate a unique session ID for that particular user and sent back to be stored in the browser in form of &lt;em&gt;Cookie&lt;/em&gt;.&lt;/span&gt;
        &lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Whenever the user navigates across different pages, as long as the user is showing the consistent and valid session ID, different pages may recognize the same user.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Finally, a session will end and the cookie is deleted (if expiration date is not set) when a user log out or the application is shut down.&lt;/span&gt;&lt;/li&gt;
      &lt;/ol&gt;
    &lt;/li&gt;
    &lt;li style=&quot;margin-top: 10px;&quot;&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;p&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt; is used to &lt;strong&gt;remember the user information such as browsing history, user preferences and login credentials on the client-site computer&lt;/strong&gt;. It can only store maximum of &lt;em&gt;&lt;strong&gt;4KB&lt;/strong&gt;&lt;/em&gt; data in the browser. The server will forget everything about the user such as his/her credentials and activities after the connection breaks. Hence, &lt;em&gt;Cookies&lt;/em&gt; are very useful when you want a browser to &quot;remember&quot; the user. Let's look at the example:&lt;/span&gt;&lt;/p&gt;
      &lt;ol&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A user, called &quot;Alex&quot; has chosen &lt;strong&gt;Keep me signed in&lt;/strong&gt; and logged in to a website, his login credentials are stored in a &lt;em&gt;Cookies&lt;/em&gt; and the server creates a session ID for him and sent back to the browser.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;While navigating across multiple pages of the website, he's able to use his unique session ID to verify he's the current user.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;After a few moment, he feels tired and close the browser.&lt;/span&gt;&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;When he comes back again and open the website, the browser will send a request to the server to get the web page, the &lt;em&gt;Cookies&lt;/em&gt; belongings to that page will be added to the request via HTTP headers.&lt;/span&gt;
        &lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Since &lt;em&gt;Cookies&lt;/em&gt; remembers his login credentials, it'll check the informtion against the server&lt;/span&gt;, and thus log in &quot;Alex&quot; automatically.&lt;/li&gt;
        &lt;li&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;The lifetime of &lt;em&gt;Cookies&lt;/em&gt; can be set and it's deleted when its lifetime has passed.&lt;/span&gt;.&lt;/li&gt;
      &lt;/ol&gt;
      &lt;div style=&quot;margin-top: 15px&quot; class=&quot;div-purple&quot;&gt;
        &lt;span class=&quot;alert-header&quot;&gt;Note&lt;/span&gt;
        &lt;span class=&quot;alert-body&quot;&gt;Since &lt;em&gt;Cookies&lt;/em&gt; are stored on client-site computer, it can only be accessed by the user that initially created it. Other user cannot see it.&lt;/span&gt;
      &lt;/div&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  &lt;p&gt;&lt;span style=&quot;font-family: arial,helvetica,sans-serif;&quot;&gt;Now, we've some basic understanding on what is &lt;em&gt;Session&lt;/em&gt; and &lt;em&gt;Cookies&lt;/em&gt;. Let's go through their differences.&lt;/span&gt;&lt;/p&gt;
&lt;/section&gt;
&lt;section&gt;
  &lt;h2&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774611&quot; id=&quot;_Toc123774611&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Difference between Session and Cookies&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774611&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h2&gt;
  &lt;p&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Although both are used to track the information provided by the users, they still have some distinct differences. Let's see one by one:&lt;/span&gt;&lt;/p&gt;
  &lt;h3&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774612&quot; id=&quot;_Toc123774612&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Storage Location&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774612&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h3&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Session&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Session&lt;/em&gt; is stored as server-site files temporarily on the server. It'll be deleted when the application is shut down or the user logged out.&lt;/span&gt;
    &lt;/li&gt;
    &lt;li style=&quot;margin-top: 10px;&quot;&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Cookies&lt;/em&gt; is stored as a client-site files on the user's computer. It'll be deleted when the expiration date has reached.&lt;/span&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  &lt;h3&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774613&quot; id=&quot;_Toc123774613&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Security&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774613&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h3&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Session&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Session&lt;/em&gt; is more secure compare to a &lt;em&gt;Cookies&lt;/em&gt; because it's on server-site and cannot be tampered easily. Besides, the data in a &lt;em&gt;Session&lt;/em&gt; is encrypted and converted to binary form in the server.&lt;/span&gt;
    &lt;/li&gt;
    &lt;li style=&quot;margin-top: 10px;&quot;&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif&quot;&gt;A &lt;em&gt;Cookies&lt;/em&gt; is less secure because it's stored in the form of &lt;strong&gt;string text file&lt;/strong&gt; on the client's computer. Whenever an unauthorized user get access to the system, he can alter the data easily.&lt;/span&gt;
      &lt;div style=&quot;margin-top: 15px&quot; class=&quot;div-pink&quot;&gt;
        &lt;span class=&quot;alert-header&quot;&gt;Caution&lt;/span&gt;
        &lt;span class=&quot;alert-body&quot;&gt;When you choose &quot;&lt;strong&gt;&lt;em&gt;Keep me logged in&lt;/em&gt;&lt;/strong&gt;&quot;, if someone has stolen the unique identifier in the cookies, it's called &lt;em&gt;cookie stealing&lt;/em&gt; and now they can access your account. Always be aware of this option! &lt;/span&gt;
      &lt;/div&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  &lt;h3&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774614&quot; id=&quot;_Toc123774614&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Lifetime&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774614&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h3&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Session&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Session&lt;/em&gt; will be deleted once the user logs out or the application is shut down.&lt;/span&gt;
    &lt;/li&gt;
    &lt;li style=&quot;margin-top: 10px;&quot;&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Cookies&lt;/em&gt; is deleted when the user specified expiration date has passed.&lt;/span&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  &lt;h3&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774615&quot; id=&quot;_Toc123774615&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Stored Data Type&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774615&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h3&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Session&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Session&lt;/em&gt; stores the any type of value or the data type of an Object.&lt;/span&gt;
    &lt;/li&gt;
    &lt;li style=&quot;margin-top: 10px;&quot;&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;A &lt;em&gt;Cookies&lt;/em&gt; can only stores data in &quot;string&quot; type only.&lt;/span&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  &lt;h3&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774616&quot; id=&quot;_Toc123774616&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;Purpose of Usage&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774616&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h3&gt;
  &lt;ul&gt;
    &lt;li&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Session&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;
        A &lt;em&gt;Session&lt;/em&gt; is used to track an user's activity over a period of time. It's used to store the specific information of a user which should be consistent across multiple pages of a website. The example can be &lt;em&gt;login status&lt;/em&gt;, &lt;em&gt;user preferences&lt;/em&gt;, and &lt;em&gt;shopping cart&lt;/em&gt;. When a user logs in the website, a session is generated in the server-site and the unique session ID is generated and stored in the user's devices as a cookie.
      &lt;/span&gt;
    &lt;/li&gt;
    &lt;li style=&quot;margin-top: 10px;&quot;&gt;
      &lt;h3&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif; color: rgb(185, 106, 217);&quot;&gt;&lt;em&gt;Cookies&lt;/em&gt;&lt;/span&gt;&lt;/strong&gt;&lt;/h3&gt;
      &lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;
        A &lt;em&gt;Cookies&lt;/em&gt; are used to store user's information in their computer or mobile device to &lt;strong&gt;&quot;remember&quot;&lt;/strong&gt; their information such as tracking &lt;em&gt;login information&lt;/em&gt;, &lt;em&gt;user behavior&lt;/em&gt; and storing &lt;em&gt;user preferences&lt;/em&gt;. When the next time they visit the same website, the browser'll send the &lt;em&gt;Cookies&lt;/em&gt; along with the request to get the web page which compromises of more personalized and desired data for that particular user.&lt;/span&gt;
    &lt;/li&gt;
  &lt;/ul&gt;
  

  &lt;section&gt;
    &lt;h2&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774617&quot; id=&quot;_Toc123774617&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;The Conclusion&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774617&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h2&gt;
    &lt;p&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;In this study blog, we've learned what's &lt;em&gt;Session&lt;/em&gt; and &lt;em&gt;Cookies&lt;/em&gt;, what are their differences in term of &lt;em&gt;storage location&lt;/em&gt;, &lt;em&gt;security&lt;/em&gt;, &lt;em&gt;lifetime&lt;/em&gt;, &lt;em&gt;stored data type&lt;/em&gt;, and &lt;em&gt;purpose of usage&lt;/em&gt;. Bear in mind, both of them have their own pros and cons, but &lt;em&gt;Session&lt;/em&gt; is more secure compared to &lt;em&gt;Cookies&lt;/em&gt;. Hence, you should choose which one to use according to your own condition.&lt;/span&gt;&lt;/p&gt;
  &lt;/section&gt;

  &lt;section&gt;
    &lt;h2&gt;&lt;a class=&quot;custom_anchor&quot; name=&quot;_Toc123774618&quot; id=&quot;_Toc123774618&quot;&gt;&lt;/a&gt;&lt;strong&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;The Reference&lt;/span&gt; &lt;a style=&quot;color: blue;&quot; rel=&quot;nofollow&quot; href=&quot;#_Toc123774618&quot;&gt;#&lt;/a&gt;&lt;/strong&gt;&lt;/h2&gt;
    &lt;p&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;
        You may go through this reference for more information:
      &lt;/span&gt;&lt;/p&gt;
    &lt;ol&gt;
      &lt;li&gt;&lt;em&gt;&lt;span style=&quot;font-family: arial, helvetica, sans-serif;&quot;&gt;What's Session and Cookies: &lt;/span&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.oracle.com/en/middleware/webcenter/sites/12.2.1.3/wbcsd/sessions-and-cookies1.html#GUID-F8F240F3-E769-4B8C-8B7C-3886010D27BE&quot; target=&quot;_blank&quot;&gt;https://docs.oracle.com/en/middleware/webcenter/sites/12.2.1.3/wbcsd/sessions-and-cookies1.html#GUID-F8F240F3-E769-4B8C-8B7C-3886010D27BE&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/section&gt;&lt;/section&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27805/difference-between-session-and-cookies-in-php</guid>
<pubDate>Sun, 20 Sep 2026 07:36:12 +0000</pubDate>
</item>
<item>
<title>A Complete Guide to Install XAMPP, Configure Apache &amp; MySQL Services, and Deploy WordPress Using Pow</title>
<link>https://coderlegion.com/26591/complete-guide-install-xampp-configure-apache-mysql-services-and-deploy-wordpress-using</link>
<description>&lt;p&gt;Setting up a reliable local web development environment is one of the first steps for anyone working with WordPress, PHP, MySQL, or Apache. While XAMPP makes this process easier by bundling the required components into a single package, configuring the environment properly, especially through the command line, can still be challenging.&lt;/p&gt;
&lt;p&gt;Many online tutorials focus only on the graphical interface and often skip essential configuration steps, troubleshooting techniques, or service management. As a result, beginners frequently encounter issues with Apache, MySQL, PHP compatibility, phpMyAdmin, or WordPress installation.&lt;/p&gt;
&lt;p&gt;To address these challenges, I created a comprehensive guide that demonstrates how to install, configure, and manage an entire XAMPP and WordPress development environment using Windows PowerShell.&lt;/p&gt;
&lt;p&gt;Whether you're a student learning web technologies, an educator preparing laboratory sessions, or a developer looking for a repeatable deployment process, this guide provides a structured approach from start to finish.&lt;/p&gt;
&lt;h3 id=&quot;why-this-guide-&quot;&gt;Why This Guide?&lt;/h3&gt;
&lt;p&gt;A properly configured local development environment saves hours of troubleshooting and creates a consistent workflow for development, testing, and learning.&lt;/p&gt;
&lt;p&gt;Instead of relying solely on mouse clicks, this guide introduces a command-driven approach that helps users understand what happens behind the scenes while also making the installation process easier to reproduce across multiple machines.&lt;/p&gt;
&lt;p&gt;The guide follows a complete workflow covering installation, service management, WordPress deployment, version upgrades, and troubleshooting.&lt;/p&gt;
&lt;h3 id=&quot;what-you-ll-learn&quot;&gt;What You'll Learn&lt;/h3&gt;
&lt;p&gt;The guide covers every major step required to build a local WordPress development environment.&lt;/p&gt;
&lt;h4 id=&quot;1-xampp-installation&quot;&gt;1. XAMPP Installation&lt;/h4&gt;
&lt;p&gt;Learn how to install XAMPP using PowerShell commands, verify the installation, and safely uninstall and reinstall it when necessary.&lt;/p&gt;
&lt;p&gt;Topics include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Downloading XAMPP&lt;/li&gt;
&lt;li&gt;Silent installation&lt;/li&gt;
&lt;li&gt;Installation verification&lt;/li&gt;
&lt;li&gt;Troubleshooting incomplete installations&lt;/li&gt;
&lt;li&gt;Safe uninstallation&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-apache-service-configuration&quot;&gt;2. Apache Service Configuration&lt;/h4&gt;
&lt;p&gt;Apache is configured as a Windows Service, allowing it to be managed directly through PowerShell.&lt;/p&gt;
&lt;p&gt;You'll learn how to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install Apache as a service&lt;/li&gt;
&lt;li&gt;Verify service status&lt;/li&gt;
&lt;li&gt;Start and stop Apache&lt;/li&gt;
&lt;li&gt;Manage services efficiently&lt;/li&gt;
&lt;li&gt;Troubleshoot service-related issues&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-mysql-service-installation&quot;&gt;3. MySQL Service Installation&lt;/h4&gt;
&lt;p&gt;Database configuration is an essential part of every WordPress deployment.&lt;/p&gt;
&lt;p&gt;This section explains how to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install MySQL as a Windows Service&lt;/li&gt;
&lt;li&gt;Verify successful installation&lt;/li&gt;
&lt;li&gt;Start and stop the service&lt;/li&gt;
&lt;li&gt;Prepare the database environment for WordPress&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;4-wordpress-deployment-on-xampp&quot;&gt;4. WordPress Deployment on XAMPP&lt;/h4&gt;
&lt;p&gt;One of the largest sections of the guide walks through deploying WordPress on a local server.&lt;/p&gt;
&lt;p&gt;Topics include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Creating WordPress databases&lt;/li&gt;
&lt;li&gt;Configuring users and permissions&lt;/li&gt;
&lt;li&gt;Downloading WordPress&lt;/li&gt;
&lt;li&gt;Extracting and organizing files&lt;/li&gt;
&lt;li&gt;Running the installation&lt;/li&gt;
&lt;li&gt;Configuring the initial website&lt;/li&gt;
&lt;li&gt;Accessing the WordPress dashboard&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;5-upgrading-php&quot;&gt;5. Upgrading PHP&lt;/h4&gt;
&lt;p&gt;Modern WordPress versions require newer PHP releases.&lt;/p&gt;
&lt;p&gt;The guide explains how to safely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Replace outdated PHP versions&lt;/li&gt;
&lt;li&gt;Configure &lt;code&gt;php.ini&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Enable required PHP extensions&lt;/li&gt;
&lt;li&gt;Update Apache configuration&lt;/li&gt;
&lt;li&gt;Verify the installation&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;6-upgrading-phpmyadmin&quot;&gt;6. Upgrading phpMyAdmin&lt;/h4&gt;
&lt;p&gt;Many bundled XAMPP installations include older versions of phpMyAdmin.&lt;/p&gt;
&lt;p&gt;The guide demonstrates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Installing the latest release&lt;/li&gt;
&lt;li&gt;Preserving existing configurations&lt;/li&gt;
&lt;li&gt;Migrating settings safely&lt;/li&gt;
&lt;li&gt;Verifying successful upgrades&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;7-daily-service-management&quot;&gt;7. Daily Service Management&lt;/h4&gt;
&lt;p&gt;Instead of opening the XAMPP Control Panel every time, the guide shows how to manage services directly from PowerShell.&lt;/p&gt;
&lt;p&gt;This includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Starting Apache&lt;/li&gt;
&lt;li&gt;Starting MySQL&lt;/li&gt;
&lt;li&gt;Opening WordPress&lt;/li&gt;
&lt;li&gt;Accessing phpMyAdmin&lt;/li&gt;
&lt;li&gt;Stopping services after development&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-use-powershell-instead-of-only-the-xampp-control-panel-&quot;&gt;Why Use PowerShell Instead of Only the XAMPP Control Panel?&lt;/h3&gt;
&lt;p&gt;Learning to manage services through PowerShell provides several long-term advantages.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Faster setup&lt;/li&gt;
&lt;li&gt;Easier automation&lt;/li&gt;
&lt;li&gt;Better troubleshooting&lt;/li&gt;
&lt;li&gt;Repeatable deployments&lt;/li&gt;
&lt;li&gt;Improved understanding of Windows service management&lt;/li&gt;
&lt;li&gt;Practical DevOps and system administration skills&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These skills become increasingly valuable when working with larger development environments or automating deployments.&lt;/p&gt;
&lt;h3 id=&quot;who-should-read-this-guide-&quot;&gt;Who Should Read This Guide?&lt;/h3&gt;
&lt;p&gt;This guide is suitable for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Web Developers&lt;/li&gt;
&lt;li&gt;WordPress Developers&lt;/li&gt;
&lt;li&gt;PHP Developers&lt;/li&gt;
&lt;li&gt;Computer Science Students&lt;/li&gt;
&lt;li&gt;Information Technology Students&lt;/li&gt;
&lt;li&gt;Software Engineering Undergraduates&lt;/li&gt;
&lt;li&gt;Lecturers and Instructors&lt;/li&gt;
&lt;li&gt;System Administrators&lt;/li&gt;
&lt;li&gt;Beginners learning local web development&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is especially useful in academic environments where students need to deploy multiple local development environments using consistent procedures.&lt;/p&gt;
&lt;h3 id=&quot;github-repository&quot;&gt;GitHub Repository&lt;/h3&gt;
&lt;p&gt;The complete guide and supporting resources are available on GitHub.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/NithilaLD/XAMPP-Wordpress-Powershell-Guide&quot;&gt;XAMPP-WordPress-PowerShell-Guide&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you find this resource useful, consider giving the repository a star. It helps increase visibility and encourages further improvements.&lt;/p&gt;
&lt;h3 id=&quot;also-published-on&quot;&gt;Also Published On&lt;/h3&gt;
&lt;p&gt;This article is also available on the following platforms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/pulse/complete-guide-installing-xampp-configuring-apache-liyanarachchi-6fqzc/&quot;&gt;Read on LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dulannithilaliyanarachchi.medium.com/a-complete-guide-to-install-xampp-configure-apache-mysql-services-and-deploy-wordpress-b6076092f948&quot;&gt;Read on Medium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://builder.aws.com/content/3ImJ4y1l6v3vxDM6zoSjnTskHRi/a-complete-guide-to-install-xampp-configure-apache-and-mysql-services-and-deploy-wordpress-using-powershell-commands&quot;&gt;Read on AWS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DEV Community:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/dulannithilaliyanarachchi/a-complete-guide-to-install-xampp-configure-apache-mysql-services-and-deploy-wordpress-using-3f0h&quot;&gt;Read on DEV Community&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;acknowledgements&quot;&gt;Acknowledgements&lt;/h3&gt;
&lt;p&gt;I would like to express my sincere gratitude to &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/madusanka-mithrananda/&quot;&gt;&lt;strong&gt;Mr. Madusanka Mithrananda&lt;/strong&gt;&lt;/a&gt; for his invaluable guidance, encouragement, and continuous support throughout the development of this guide and the article. His expertise, constructive feedback, and mentorship were instrumental in shaping this work and deepening my understanding of XAMPP, PowerShell, and WordPress deployment.&lt;/p&gt;
&lt;p&gt;I truly appreciate his dedication to teaching, willingness to share knowledge, and the encouragement provided throughout this journey. His guidance has been an invaluable part of the successful completion of this guide.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Documentation is one of the most valuable yet overlooked contributions to the developer community. A clear, repeatable guide can save countless hours for students, developers, and educators trying to build reliable development environments.&lt;/p&gt;
&lt;p&gt;This project was created with the goal of simplifying the setup process while encouraging developers to understand the underlying configuration instead of simply following GUI-based installation steps.&lt;/p&gt;
&lt;p&gt;I hope it serves as a practical reference for anyone working with XAMPP, Apache, MySQL, PHP, PowerShell, and WordPress.&lt;/p&gt;
&lt;p&gt;If you have suggestions, discover improvements, or would like to contribute, feel free to open an issue or submit a pull request on GitHub.&lt;/p&gt;
&lt;p&gt;Happy coding!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26591/complete-guide-install-xampp-configure-apache-mysql-services-and-deploy-wordpress-using</guid>
<pubDate>Wed, 09 Sep 2026 10:21:11 +0000</pubDate>
</item>
<item>
<title>Building a Real-Time Payment Gateway Integration with PHP, PayHere, ngrok, and MySQL</title>
<link>https://coderlegion.com/26585/building-a-real-time-payment-gateway-integration-with-php-payhere-ngrok-and-mysql</link>
<description>&lt;p&gt;Recently, I built a complete &lt;strong&gt;real-time payment gateway integration&lt;/strong&gt; using &lt;strong&gt;PHP, PayHere Sandbox, ngrok, and MySQL&lt;/strong&gt; to simulate and test online payments in a local development environment.&lt;/p&gt;
&lt;p&gt;This project covered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Secure PayHere checkout integration&lt;/li&gt;
&lt;li&gt;Real-time payment callback handling through ngrok&lt;/li&gt;
&lt;li&gt;Transaction logging and MySQL database storage&lt;/li&gt;
&lt;li&gt;Email notifications using PHPMailer&lt;/li&gt;
&lt;li&gt;PDF receipt generation using mPDF&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of the most interesting parts of the project was using &lt;strong&gt;ngrok to expose localhost securely&lt;/strong&gt;, allowing PayHere to communicate with local callback URLs such as &lt;code&gt;notify_url&lt;/code&gt;, &lt;code&gt;return_url&lt;/code&gt;, and &lt;code&gt;cancel_url&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The project structure was designed to keep the workflow clean and modular, with separate files for checkout processing, notifications, verification, configuration, and database handling.&lt;/p&gt;
&lt;p&gt;This implementation was a great hands-on experience in understanding how real-world payment systems work behind the scenes, from &lt;strong&gt;hash generation and callback verification&lt;/strong&gt; to &lt;strong&gt;preventing duplicate transactions and securely handling payment confirmations&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;repository&quot;&gt;Repository&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/NithilaLD/Building-a-Real-Time-Payment-Gateway-Integration-with-PHP-PayHere-ngrok-and-MySQL/&quot;&gt;https://github.com/NithilaLD/Building-a-Real-Time-Payment-Gateway-Integration-with-PHP-PayHere-ngrok-and-MySQL/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The repository includes the complete project resources, including the full implementation guide in PDF format, source code files for the payment gateway integration, and database setup scripts required to configure and run the application locally.&lt;/p&gt;
&lt;h3 id=&quot;tech-stack&quot;&gt;Tech Stack&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;PHP | MySQL | PayHere Sandbox | ngrok | Composer | PHPMailer | mPDF&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;also-published-on-1&quot;&gt;Also Published On&lt;/h3&gt;
&lt;p&gt;This article is also available on the following platforms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/pulse/building-real-time-payment-gateway-integration-php-liyanarachchi-yujgc/&quot;&gt;&lt;strong&gt;Read on LinkedIn&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dulannithilaliyanarachchi.medium.com/building-a-real-time-payment-gateway-integration-with-php-payhere-ngrok-and-mysql-533c5e4a5720&quot;&gt;&lt;strong&gt;Read on Medium&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://builder.aws.com/content/3ImAUjqYN0gmihHAxXEIlVJoHFn/building-a-real-time-payment-gateway-integration-with-php-payhere-ngrok-and-mysql&quot;&gt;&lt;strong&gt;Read on AWS&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DEV Community:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/dulannithilaliyanarachchi/building-a-real-time-payment-gateway-integration-with-php-payhere-ngrok-and-mysql-dnf&quot;&gt;&lt;strong&gt;Read on DEV Community&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26585/building-a-real-time-payment-gateway-integration-with-php-payhere-ngrok-and-mysql</guid>
<pubDate>Wed, 09 Sep 2026 09:49:02 +0000</pubDate>
</item>
<item>
<title>From a Commodore 64 to AI Agents: Istvan Dobrentei's Journey Through Software Development</title>
<link>https://coderlegion.com/25398/from-a-commodore-64-to-ai-agents-istvan-dobrenteis-journey-through-software-development</link>
<description>&lt;h2 id=&quot;from-a-commodore-64-to-ai-agents-istvan-dobrentei-s-journey-through-software-development&quot;&gt;From a Commodore 64 to AI Agents: Istvan Dobrentei's Journey Through Software Development&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;From a black-and-white TV and BASIC programming to PHP, cloud migration, DevOps, and today's world of AI agents and LLMs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;meet-istvan-dobrentei&quot;&gt;Meet Istvan Dobrentei&lt;/h3&gt;
&lt;p&gt;Some programming journeys begin with a computer science degree.&lt;/p&gt;
&lt;p&gt;Others begin with a job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Istvan Dobrentei's started with a Commodore 64 connected to a small black-and-white television.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At around 10 years old, Istvan encountered something that felt almost magical: a machine communicating with him through a TV screen.&lt;/p&gt;
&lt;p&gt;The messages weren't exactly encouraging.&lt;/p&gt;
&lt;p&gt;They included things like:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Syntax error&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Type mismatch error&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;He didn't understand them yet.&lt;/p&gt;
&lt;p&gt;But he was fascinated.&lt;/p&gt;
&lt;p&gt;That curiosity eventually led him from BASIC to Turbo Pascal, C, Delphi, PHP, JavaScript and Java, and ultimately into a long career as a software engineer.&lt;/p&gt;
&lt;p&gt;His first computer-related job came in 2001, working in a library where he built a PHP application that made publications searchable online.&lt;/p&gt;
&lt;p&gt;In many ways, his journey into software development started with books.&lt;/p&gt;
&lt;p&gt;Since then, his career has taken him through real-world business systems, legacy applications, cloud migration, DevOps, and modern web development.&lt;/p&gt;
&lt;p&gt;Today, he is also exploring &lt;strong&gt;AI, LLMs and agentic architecture&lt;/strong&gt;, alongside his background as a Zend-certified full-stack PHP developer and Microsoft- and AWS-certified engineer.&lt;/p&gt;
&lt;p&gt;But technology is only one part of Istvan's story.&lt;/p&gt;
&lt;p&gt;His journey has also been shaped by continuous learning, ethical decisions, difficult experiences, resilience, and the realization that good software is about much more than simply making code work.&lt;/p&gt;
&lt;p&gt;In this Developer Story, Istvan takes us from his first Commodore 64 to the modern world of AI and shares the lessons he has learned along the way.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;istvan-s-story&quot;&gt;Istvan's Story&lt;/h2&gt;
&lt;h3 id=&quot;it-started-with-a-commodore-64&quot;&gt;It Started With a Commodore 64&lt;/h3&gt;
&lt;p&gt;When I was around 10 years old, I got my first computer: a &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.wikipedia.org/wiki/Commodore_64&quot;&gt;Commodore 64&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;At that time, it was something truly special.&lt;/p&gt;
&lt;p&gt;Have you ever heard about the &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.wikipedia.org/wiki/Coordinating_Committee_for_Multilateral_Export_Controls&quot;&gt;COCOM list&lt;/a&gt;?&lt;/p&gt;
&lt;p&gt;Technology behind the &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.wikipedia.org/wiki/Iron_Curtain&quot;&gt;Iron Curtain&lt;/a&gt; was very limited.&lt;/p&gt;
&lt;p&gt;There was no monitor, so we had to connect the machine to a TV and manually adjust the frequency just to see the command prompt on the screen.&lt;/p&gt;
&lt;p&gt;Back then, even having a color TV was a big deal.&lt;/p&gt;
&lt;p&gt;I remember that I only had a small black-and-white TV called Junoszty.&lt;/p&gt;
&lt;p&gt;In our country, there weren't many opportunities to learn English during that period.&lt;/p&gt;
&lt;p&gt;Russian was mandatory in school, and I could choose only one additional language: German.&lt;/p&gt;
&lt;p&gt;So when I saw the very first message appear on the black-and-white TV screen, I felt like I was on the moon.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The machine was communicating with me!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The messages were things like:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Syntax error&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Type mismatch error&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;At that time, I had no idea what they meant, but I was fascinated.&lt;/p&gt;
&lt;p&gt;Today, of course, I understand them well, but back then it felt almost magical.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;imagining-a-worldwide-computer-network&quot;&gt;Imagining a Worldwide Computer Network&lt;/h3&gt;
&lt;p&gt;I also clearly remember reading an article about computer networks in a magazine around 1992.&lt;/p&gt;
&lt;p&gt;They wrote about &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.wikipedia.org/wiki/CompuServe&quot;&gt;CompuServe&lt;/a&gt;, and I imagined what a worldwide computer network could look like.&lt;/p&gt;
&lt;p&gt;Today, of course, we know it as the World Wide Web.&lt;/p&gt;
&lt;p&gt;My friends and I spent many hours playing games on that machine.&lt;/p&gt;
&lt;p&gt;But eventually, programming became more interesting than simply using the computer.&lt;/p&gt;
&lt;p&gt;One day, a friend gave me a book about the BASIC programming language.&lt;/p&gt;
&lt;p&gt;I was probably 12 or 13 years old, and that was when I started learning programming by myself.&lt;/p&gt;
&lt;p&gt;Later, in school, I chose computer science subjects to learn more.&lt;/p&gt;
&lt;p&gt;We started with BASIC, then moved on to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Turbo Pascal&lt;/li&gt;
&lt;li&gt;C&lt;/li&gt;
&lt;li&gt;Delphi&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Around 2000, I learned PHP and JavaScript, and later Java.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learning-the-fundamentals&quot;&gt;Learning the Fundamentals&lt;/h3&gt;
&lt;p&gt;At the very beginning, in the classroom environment, I learned from mathematics teachers and solved basic mathematical and programming problems.&lt;/p&gt;
&lt;p&gt;I also had a book full of beginner programming exercises.&lt;/p&gt;
&lt;p&gt;Learning the fundamentals was very important.&lt;/p&gt;
&lt;p&gt;I created many side projects that helped solve different kinds of problems.&lt;/p&gt;
&lt;p&gt;For example, I developed an application that helped teachers organize class schedules and subject timelines for different classrooms.&lt;/p&gt;
&lt;p&gt;Those projects gave me something that books alone couldn't provide:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;practice.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You learn something differently when you actually have to make it work.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;my-first-computer-related-job-was-in-a-library&quot;&gt;My First Computer-Related Job Was in a Library&lt;/h3&gt;
&lt;p&gt;Interestingly, my first job was not related to computers.&lt;/p&gt;
&lt;p&gt;I first worked as a book sales agent, and later I worked in a library.&lt;/p&gt;
&lt;p&gt;That became my first computer-related job in &lt;strong&gt;2001&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;There, I learned PHP and created my first real application.&lt;/p&gt;
&lt;p&gt;It helped organize different kinds of publications and made them searchable through an online interface.&lt;/p&gt;
&lt;p&gt;Looking back, there is something quite fitting about this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My journey into software development started with books.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Those early experiences eventually led me into professional web development and a career that would span many different technologies.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-years-of-experience-changed&quot;&gt;What Years of Experience Changed&lt;/h3&gt;
&lt;p&gt;It is really interesting because today I see myself in a different way.&lt;/p&gt;
&lt;p&gt;After many years of experience, I see things very differently than I did when I was younger.&lt;/p&gt;
&lt;p&gt;I have seen many kinds of code across many different domains and businesses of various sizes.&lt;/p&gt;
&lt;p&gt;I have seen insightful and highly successful companies, but also shrinking businesses, along with the codebases behind those business models.&lt;/p&gt;
&lt;p&gt;One surprising realization was that the best-designed and most well-organized code wasn't always the one that generated huge profits or became the most successful.&lt;/p&gt;
&lt;p&gt;That was a key moment for me.&lt;/p&gt;
&lt;p&gt;It taught me that business success depends on many factors beyond technology alone.&lt;/p&gt;
&lt;p&gt;At the same time, I realized that good architecture and clean code are essential for long-term success.&lt;/p&gt;
&lt;p&gt;Good code improves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;maintainability&lt;/li&gt;
&lt;li&gt;scalability&lt;/li&gt;
&lt;li&gt;teamwork&lt;/li&gt;
&lt;li&gt;long-term sustainability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In many cases, poorly designed systems eventually become difficult to maintain and slow down innovation.&lt;/p&gt;
&lt;p&gt;So I've learned that software engineering requires a balance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Technology matters, but technology alone does not determine business success.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-challenge-of-working-with-real-businesses&quot;&gt;The Challenge of Working With Real Businesses&lt;/h3&gt;
&lt;p&gt;One of the biggest challenges in my developer journey was working as an independent contractor for SMEs.&lt;/p&gt;
&lt;p&gt;I saw many interesting business ideas and situations from a business perspective.&lt;/p&gt;
&lt;p&gt;However, I always stayed committed to ethical and legal principles.&lt;/p&gt;
&lt;p&gt;Whenever I was asked to do something that was against the law or not ethical, I said no.&lt;/p&gt;
&lt;p&gt;For me, that is an important part of being a professional.&lt;/p&gt;
&lt;p&gt;Developers can have access to sensitive systems, information, business processes, and data.&lt;/p&gt;
&lt;p&gt;Technical ability comes with responsibility.&lt;/p&gt;
&lt;p&gt;Sometimes doing the right thing means refusing to do something.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;migrating-a-legacy-webshop-to-the-cloud&quot;&gt;Migrating a Legacy Webshop to the Cloud&lt;/h3&gt;
&lt;p&gt;Another major challenge was migrating a live legacy webshop to a cloud environment.&lt;/p&gt;
&lt;p&gt;At that time, cloud technologies were a completely new area for me.&lt;/p&gt;
&lt;p&gt;I had to quickly learn many different concepts and technologies.&lt;/p&gt;
&lt;p&gt;To overcome the challenge, I invested a lot of time in learning, earned relevant certifications, and continuously improved my knowledge step by step.&lt;/p&gt;
&lt;p&gt;In the end, the migration was successful.&lt;/p&gt;
&lt;p&gt;The experience taught me the importance of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;adaptability&lt;/li&gt;
&lt;li&gt;continuous learning&lt;/li&gt;
&lt;li&gt;persistence&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Technology doesn't wait for you to become comfortable.&lt;/p&gt;
&lt;p&gt;Sometimes you have to learn something new because the project demands it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;there-is-no-single-technology-that-defines-a-developer&quot;&gt;There Is No Single Technology That Defines a Developer&lt;/h3&gt;
&lt;p&gt;I don't think there is a single technology that I could highlight as being responsible for my growth.&lt;/p&gt;
&lt;p&gt;In the IT industry, continuous learning and self-learning are what really matter.&lt;/p&gt;
&lt;p&gt;New solutions, tools, and resources appear every month, especially in today's world.&lt;/p&gt;
&lt;p&gt;One thing is certain:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Continuous learning and adapting to change are essential.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Instead of focusing only on specific technologies, I would highlight:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;critical thinking&lt;/li&gt;
&lt;li&gt;curiosity&lt;/li&gt;
&lt;li&gt;the ability to learn new things every day&lt;/li&gt;
&lt;li&gt;adapting to change&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Technologies will come and go.&lt;/p&gt;
&lt;p&gt;The ability to learn remains.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-job-that-taught-me-a-difficult-lesson&quot;&gt;The Job That Taught Me a Difficult Lesson&lt;/h3&gt;
&lt;p&gt;I remember a sad story, although today I can laugh about it.&lt;/p&gt;
&lt;p&gt;At the time, however, it was a very difficult experience.&lt;/p&gt;
&lt;p&gt;Once, I applied for a job where my task was to create an Android application to handle one of the company's logistics business functions.&lt;/p&gt;
&lt;p&gt;It was an experimental project.&lt;/p&gt;
&lt;p&gt;The company worked in a team environment without any version control and without applying object-oriented principles.&lt;/p&gt;
&lt;p&gt;That really shocked me.&lt;/p&gt;
&lt;p&gt;One day, the CEO asked for my opinion about their system.&lt;/p&gt;
&lt;p&gt;I spent the weekend preparing a detailed and honest presentation and presented it to the team.&lt;/p&gt;
&lt;p&gt;The next day, I was unemployed.&lt;/p&gt;
&lt;p&gt;I did not criticize the system personally.&lt;/p&gt;
&lt;p&gt;I only highlighted the main issues and shared what I had learned over the years.&lt;/p&gt;
&lt;p&gt;It was a difficult experience.&lt;/p&gt;
&lt;p&gt;But it taught me something important.&lt;/p&gt;
&lt;p&gt;Life can sometimes be difficult and unfair.&lt;/p&gt;
&lt;p&gt;Not every situation will reward honesty immediately.&lt;/p&gt;
&lt;p&gt;But persistence and hard work eventually pay off.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Never give up.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learning-to-think-like-a-developer&quot;&gt;Learning to Think Like a Developer&lt;/h3&gt;
&lt;p&gt;At the beginning of my learning journey, even solving simple tasks programmatically was a challenge.&lt;/p&gt;
&lt;p&gt;Learning how to think like a developer and how to write code was not easy at first.&lt;/p&gt;
&lt;p&gt;I believe software development is a step-by-step journey.&lt;/p&gt;
&lt;p&gt;One of the most important lessons I learned is to try solving problems by yourself instead of always looking for shortcuts.&lt;/p&gt;
&lt;p&gt;Practice is essential.&lt;/p&gt;
&lt;p&gt;The more you build, experiment, and learn from mistakes, the more you improve over time.&lt;/p&gt;
&lt;p&gt;You don't become a better developer simply by reading about programming.&lt;/p&gt;
&lt;p&gt;You become better by actually doing it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;nobody-is-perfect&quot;&gt;Nobody Is Perfect&lt;/h3&gt;
&lt;p&gt;Another important lesson is that nobody is perfect, and growth is a continuous process.&lt;/p&gt;
&lt;p&gt;Mistakes are a natural part of learning.&lt;/p&gt;
&lt;p&gt;The real question is whether you are able to reflect on them, adapt, and move forward.&lt;/p&gt;
&lt;p&gt;This is especially important in technology because there is always something new to learn.&lt;/p&gt;
&lt;p&gt;You will make mistakes.&lt;/p&gt;
&lt;p&gt;You will sometimes choose the wrong technology.&lt;/p&gt;
&lt;p&gt;You will sometimes write code that you later want to rewrite.&lt;/p&gt;
&lt;p&gt;That's normal.&lt;/p&gt;
&lt;p&gt;What matters is what you learn from the experience.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;resilience-matters&quot;&gt;Resilience Matters&lt;/h3&gt;
&lt;p&gt;I also learned the importance of resilience.&lt;/p&gt;
&lt;p&gt;Resilience is the ability to recharge yourself after challenges and keep going even when things become difficult.&lt;/p&gt;
&lt;p&gt;Software development can be frustrating.&lt;/p&gt;
&lt;p&gt;Projects can fail.&lt;/p&gt;
&lt;p&gt;Jobs can end unexpectedly.&lt;/p&gt;
&lt;p&gt;Technologies can change.&lt;/p&gt;
&lt;p&gt;You can spend days solving a problem that seemed like it should take an hour.&lt;/p&gt;
&lt;p&gt;The ability to recover and continue is therefore just as important as technical knowledge.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learn-from-other-people&quot;&gt;Learn From Other People&lt;/h3&gt;
&lt;p&gt;Having mentors and being open to feedback is extremely valuable.&lt;/p&gt;
&lt;p&gt;Nobody can know everything or be an expert in every area.&lt;/p&gt;
&lt;p&gt;It is essential to accept advice and learn from others.&lt;/p&gt;
&lt;p&gt;Doing so can accelerate both personal and professional growth much faster than trying to figure everything out alone.&lt;/p&gt;
&lt;p&gt;Don't hesitate to ask questions.&lt;/p&gt;
&lt;p&gt;Don't be afraid to admit that you don't understand something.&lt;/p&gt;
&lt;p&gt;Learning from other people is not a weakness.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It is one of the fastest ways to grow.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;from-php-to-cloud-and-ai&quot;&gt;From PHP to Cloud and AI&lt;/h3&gt;
&lt;p&gt;My technology journey has continued to evolve.&lt;/p&gt;
&lt;p&gt;PHP and web development remain an important part of my background, but my interests have expanded into cloud, DevOps, AI, and modern development tools.&lt;/p&gt;
&lt;p&gt;I've earned certifications including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PHP Zend Engineer&lt;/li&gt;
&lt;li&gt;Microsoft Azure Fundamentals&lt;/li&gt;
&lt;li&gt;Microsoft Azure Developer Associate&lt;/li&gt;
&lt;li&gt;Microsoft DevOps Engineer Expert&lt;/li&gt;
&lt;li&gt;AWS Certified AI Practitioner&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Today, I'm particularly interested in &lt;strong&gt;LLMs, AI, cloud technologies, and AI agents&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The interesting thing about technology is that the learning never really stops.&lt;/p&gt;
&lt;p&gt;The Commodore 64 I used as a child could not have prepared me for today's AI systems.&lt;/p&gt;
&lt;p&gt;But the curiosity that made me want to understand that first computer is still useful.&lt;/p&gt;
&lt;p&gt;The tools changed.&lt;/p&gt;
&lt;p&gt;The curiosity didn't.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;advice-for-developers-starting-today&quot;&gt;Advice for Developers Starting Today&lt;/h3&gt;
&lt;p&gt;As I mentioned earlier, in our rapidly changing world, the most important thing is to keep learning new things, stay curious, and never give up.&lt;/p&gt;
&lt;p&gt;Hard work pays off in the long term.&lt;/p&gt;
&lt;p&gt;Teamwork is also very important because nobody can know everything or be an expert in every area.&lt;/p&gt;
&lt;p&gt;It is essential to truly understand what you are doing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't just copy and paste code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Understand why it works.&lt;/p&gt;
&lt;p&gt;Understand why a feature works.&lt;/p&gt;
&lt;p&gt;And understand why a feature may not work.&lt;/p&gt;
&lt;p&gt;If you have doubts or don't understand something, ask for help.&lt;/p&gt;
&lt;p&gt;There is nothing wrong with asking questions.&lt;/p&gt;
&lt;p&gt;In fact, asking the right question can sometimes teach you more than finding the answer yourself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;my-experience-with-coderlegion&quot;&gt;My Experience With CoderLegion&lt;/h3&gt;
&lt;p&gt;It was a surprise for me when I was first invited to create publications.&lt;/p&gt;
&lt;p&gt;It is an honor to share my thoughts with the community through a platform like CoderLegion.&lt;/p&gt;
&lt;p&gt;It is also great to see the active community and the engagement behind the platform.&lt;/p&gt;
&lt;p&gt;I have been able to read many interesting and up-to-date articles from talented people all around the world.&lt;/p&gt;
&lt;p&gt;This gives me motivation to keep moving forward and continue learning.&lt;/p&gt;
&lt;p&gt;Thank you for the opportunity, and I wish continued success to both the community and CoderLegion.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;looking-back&quot;&gt;Looking Back&lt;/h2&gt;
&lt;p&gt;When I think about my journey, the contrast is quite remarkable.&lt;/p&gt;
&lt;p&gt;I started with a Commodore 64 connected to a black-and-white television.&lt;/p&gt;
&lt;p&gt;I learned BASIC from a book.&lt;/p&gt;
&lt;p&gt;Then came Turbo Pascal, C, Delphi, PHP, JavaScript, and Java.&lt;/p&gt;
&lt;p&gt;I worked in a library and built a searchable publication system.&lt;/p&gt;
&lt;p&gt;I worked with real businesses and legacy systems.&lt;/p&gt;
&lt;p&gt;I learned cloud technologies when they were new to me.&lt;/p&gt;
&lt;p&gt;I earned certifications and continued developing my skills.&lt;/p&gt;
&lt;p&gt;And today, I'm exploring AI, LLMs, cloud technologies, and AI agents.&lt;/p&gt;
&lt;p&gt;The technology has changed dramatically.&lt;/p&gt;
&lt;p&gt;But the fundamental lesson has remained the same:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Stay curious. Keep learning. Never give up.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-istvan-dobrentei&quot;&gt;About Istvan Dobrentei&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Istvan Dobrentei&lt;/strong&gt; is a Software Engineer and Zend-certified full-stack PHP developer based in Hungary.&lt;/p&gt;
&lt;p&gt;His technical background includes PHP, JavaScript, Python, Docker, MySQL, Ansible, and GitHub, alongside extensive experience with Microsoft Azure and DevOps.&lt;/p&gt;
&lt;p&gt;He holds certifications including &lt;strong&gt;PHP Zend Engineer, AWS Certified AI Practitioner, Microsoft Azure Developer Associate, and Microsoft DevOps Engineer Expert&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Today, Istvan is exploring &lt;strong&gt;LLMs, AI, cloud technologies, AI agents, and agentic architecture&lt;/strong&gt;, continuing a technology journey that began with a Commodore 64 when he was a child.&lt;/p&gt;
&lt;p&gt;You can learn more about Istvan and his work on his &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.en.dobrenteiistvan.hu/&quot;&gt;website&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/istvan-dobrentei&quot;&gt;CoderLegion profile&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-final-thought&quot;&gt;A Final Thought&lt;/h3&gt;
&lt;p&gt;Istvan's journey shows how dramatically a developer's world can change.&lt;/p&gt;
&lt;p&gt;A child connecting a Commodore 64 to a black-and-white television could hardly have imagined today's cloud platforms, AI agents, and large language models.&lt;/p&gt;
&lt;p&gt;But perhaps the most important skill was already there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Curiosity.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The desire to understand how something works.&lt;/p&gt;
&lt;p&gt;The willingness to learn.&lt;/p&gt;
&lt;p&gt;The courage to ask questions.&lt;/p&gt;
&lt;p&gt;And the resilience to keep going when things don't work out.&lt;/p&gt;
&lt;p&gt;Technologies will continue to change.&lt;/p&gt;
&lt;p&gt;Programming languages will evolve.&lt;/p&gt;
&lt;p&gt;New AI systems will appear.&lt;/p&gt;
&lt;p&gt;But the developers who continue to learn, adapt, and stay curious will always have somewhere to go next.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The technology changes. The learning never stops.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This Developer Story is part of CoderLegion's Developer Stories series, where we share the real journeys, experiences, challenges, and lessons of developers from our community.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25398/from-a-commodore-64-to-ai-agents-istvan-dobrenteis-journey-through-software-development</guid>
<pubDate>Wed, 26 Aug 2026 01:28:04 +0000</pubDate>
</item>
<item>
<title>Ranking New Papers By Meaning Instead Of Keywords, In One PHP App</title>
<link>https://coderlegion.com/25197/ranking-new-papers-by-meaning-instead-of-keywords-in-one-php-app</link>
<description>&lt;p&gt;Most of us have built some version of a scheduled job that watches a feed and mails out a summary, and it almost never fails on the plumbing. It fails on relevance, and this is a writeup of a small PHP and SQLite project that puts a model scoring step where the keyword filter usually goes.&lt;/p&gt;
&lt;h3 id=&quot;why-keyword-alerts-keep-missing-the-right-paper&quot;&gt;Why Keyword Alerts Keep Missing The Right Paper&lt;/h3&gt;
&lt;p&gt;Every major index offers alerts, and they all match on words. That is the whole problem. A paper that applies your exact method in a neighboring field will share none of your keywords, so it never reaches you. A paper that happens to use your words while solving an unrelated problem arrives every single week.&lt;/p&gt;
&lt;p&gt;Word overlap is a proxy for relevance, and it is a bad one the moment your work sits between two subfields. Any watcher you build on top of a keyword query inherits that ceiling no matter how good the rest of the code is.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-of-the-pipeline&quot;&gt;The Shape Of The Pipeline&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learnhowtoscience.com/ai-science-tools/literature-radar.php&quot;&gt;Literature Radar&lt;/a&gt; splits the job into four steps, and the split is the interesting part.&lt;/p&gt;
&lt;p&gt;First, an interest profile. Not a keyword list, a short paragraph written the way you would describe your work to a colleague. Separately you define topic queries that tell the poller what to pull, so retrieval stays broad while ranking stays narrow.&lt;/p&gt;
&lt;p&gt;Second, a scheduled poll against arXiv, PubMed, Crossref, bioRxiv and medRxiv, writing into one local SQLite file. Anything already seen is skipped, so the table doubles as the dedupe layer.&lt;/p&gt;
&lt;p&gt;Third, the ranking pass. Your model reads each new title and abstract and scores it 0 to 10 against the profile, attaching a one line reason. This is where a paper about your method in another field can outscore a paper that matched your keywords exactly.&lt;/p&gt;
&lt;p&gt;Fourth, the digest. One email on your schedule, best first, score and reason attached, nothing repeated across sends.&lt;/p&gt;
&lt;h3 id=&quot;choosing-where-the-ranking-runs&quot;&gt;Choosing Where The Ranking Runs&lt;/h3&gt;
&lt;p&gt;The ranking backend is resolved in order: the Claude command line tool, the Codex command line tool, an Anthropic API key, then any OpenAI compatible endpoint, which covers OpenAI, OpenRouter, and local servers like Ollama or LM Studio.&lt;/p&gt;
&lt;p&gt;That order matters for cost. If you already pay for a Claude or ChatGPT subscription, the CLI path means the ranking step adds no per token cost at all, which is what makes a daily job over a few hundred abstracts practical for one person. Falling back to a local model through an OpenAI compatible endpoint keeps abstracts on your own machine entirely.&lt;/p&gt;
&lt;p&gt;The whole thing is one PHP app plus one SQLite file, driven by a single cron entry that decides internally whether a poll, a rank, or a digest is due:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0 */2 * * * php /path/to/Literature-Radar/cli/cron.php
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each step also runs standalone, which is what you want while tuning the profile.&lt;/p&gt;
&lt;h3 id=&quot;what-transfers-to-your-own-watcher&quot;&gt;What Transfers To Your Own Watcher&lt;/h3&gt;
&lt;p&gt;Three things generalize past papers. Keep retrieval broad and put the judgment in a separate ranking pass, because a narrow query loses items you can never recover later. Store the reason next to the score, since a ranked list with no explanation gives you nothing to tune against. And make the scoring backend swappable from day one, because the model you start on will not be the one you finish on.&lt;/p&gt;
&lt;p&gt;It is MIT licensed with no account and no service in the middle, so the abstracts go to whichever model you picked and nowhere else.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25197/ranking-new-papers-by-meaning-instead-of-keywords-in-one-php-app</guid>
<pubDate>Sun, 23 Aug 2026 22:05:46 +0000</pubDate>
</item>
<item>
<title>One PHP App and One SQLite File: A Self Hosted Workflow Engine</title>
<link>https://coderlegion.com/24640/one-php-app-and-one-sqlite-file-a-self-hosted-workflow-engine</link>
<description>&lt;p&gt;Most of us here have written the same glue script more than once, the one that catches a form submission and pushes it somewhere else, then quietly rots in a cron job nobody documented. This is a look at an open source engine that gives that glue a proper home on your own server, written for anyone who has priced a hosted automation tool and done the math.&lt;/p&gt;
&lt;h3 id=&quot;what-it-actually-is&quot;&gt;What It Actually Is&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.aiappsapi.com/Workflow-Chain-Engine/&quot;&gt;Workflow Chain Engine&lt;/a&gt; is a visual workflow automation engine you host yourself. You draw a flow in the browser, call an API, branch on the answer, loop over a list, and end in success or fail. The whole system is one small PHP application with a single SQLite file. No framework, no build step, no dependency install, and the editor library ships inside the project, so nothing is loaded from a CDN at runtime.&lt;/p&gt;
&lt;p&gt;That constraint is the interesting part. It means the tool is at home on a three dollar shared host, a VPS, or a Docker container, which matters when the process you are automating is worth ten dollars a month rather than two hundred.&lt;/p&gt;
&lt;h3 id=&quot;the-parts-that-matter-in-practice&quot;&gt;The Parts That Matter in Practice&lt;/h3&gt;
&lt;p&gt;Six node types cover the logic you need: calling out, branching, looping, and terminating in a success or fail state. Variables carry data between steps, so the response from one API call is available to the next node without writing any glue of your own to move it there.&lt;/p&gt;
&lt;p&gt;Triggers come in four flavors: a webhook, a schedule, a call from your own code, or a button someone clicks. That last one is underrated. A lot of internal automation is really a task somebody runs by hand at an unpredictable time, and handing that person a button beats handing them a runbook.&lt;/p&gt;
&lt;h3 id=&quot;the-run-log-is-the-feature&quot;&gt;The Run Log Is the Feature&lt;/h3&gt;
&lt;p&gt;Every run leaves a step by step log you can actually read. That sounds mundane right up until you have debugged a hosted automation whose entire error report was the phrase workflow failed. Knowing which node fired, what it received, and what it returned is the difference between a five minute fix and a lost afternoon.&lt;/p&gt;
&lt;p&gt;Loop protection is built in as well, which is the other thing you want in place before you point a webhook at something capable of calling itself.&lt;/p&gt;
&lt;h3 id=&quot;worth-a-look-if&quot;&gt;Worth a Look If&lt;/h3&gt;
&lt;p&gt;You are already self hosting the database and the object storage but still renting the glue. The code is MIT licensed and on GitHub, so the cost of finding out is one afternoon.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24640/one-php-app-and-one-sqlite-file-a-self-hosted-workflow-engine</guid>
<pubDate>Fri, 14 Aug 2026 16:23:47 +0000</pubDate>
</item>
<item>
<title>Turn-Based Multiplayer Without a Websocket Server</title>
<link>https://coderlegion.com/24607/turn-based-multiplayer-without-a-websocket-server</link>
<description>&lt;p&gt;Most multiplayer tutorials on here jump straight to websockets, and for a real-time shooter that is the right call. For a turn-based game it is usually more machinery than the problem needs, so here is the plain HTTP version for anyone who has been putting multiplayer off.&lt;/p&gt;
&lt;h3 id=&quot;the-three-problems-a-turn-based-game-actually-has&quot;&gt;The Three Problems A Turn-Based Game Actually Has&lt;/h3&gt;
&lt;p&gt;Every turn-based multiplayer game needs the same three things: players have to find each other, friends have to be able to play on purpose, and every player has to see every move in the same order. None of those three require a persistent connection. A queue can be polled, a join code is a lookup, and move ordering is what a database does for a living.&lt;/p&gt;
&lt;p&gt;The usual reason people reach for websockets is push. In a turn-based game the push is worth very little, because a human is going to take several seconds to decide anyway. A two second poll while it is the opponent's turn feels identical to the player and costs you nothing operationally.&lt;/p&gt;
&lt;h3 id=&quot;what-the-server-should-not-know&quot;&gt;What The Server Should Not Know&lt;/h3&gt;
&lt;p&gt;The design decision that saves the most work is keeping game rules out of the server. Treat a move as an action with a type and a few opaque data fields, and let your clients decide what the fields mean. Chess notation, a card play, a dice roll, a word submission, a chat line, a surrender flag, they are all the same shape to the server.&lt;/p&gt;
&lt;p&gt;Once the server holds no rules, one deployment serves every game you write. You do not stand up new infrastructure for each project, and a rules change never means a server deploy.&lt;/p&gt;
&lt;h3 id=&quot;a-working-reference&quot;&gt;A Working Reference&lt;/h3&gt;
&lt;p&gt;If you want to read a small implementation rather than a diagram, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.abratabia.com/AbraTabia-Game-Server/&quot;&gt;AbraTabia Game Server&lt;/a&gt; is one PHP application with a single SQLite file, five HTTPS endpoints, MIT licensed. Queue entries and finished matches expire on their own, and an admin view shows the live queue, active matches, and the full action log for each match.&lt;/p&gt;
&lt;p&gt;It runs on shared hosting, a VPS, a container, or a laptop during development, which is the real point: for turn-based games the infrastructure bill for multiplayer can be zero.&lt;/p&gt;
&lt;h3 id=&quot;where-this-stops-working&quot;&gt;Where This Stops Working&lt;/h3&gt;
&lt;p&gt;Be honest about the boundary. Anything with continuous state, physics, or sub-second reaction time wants a real-time transport and probably an authoritative simulation. The polling model covers turn-based, asynchronous, and most board and card game designs, and it stops being a good idea the moment your game has a frame rate that both players share.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24607/turn-based-multiplayer-without-a-websocket-server</guid>
<pubDate>Fri, 14 Aug 2026 00:02:08 +0000</pubDate>
</item>
<item>
<title>Amazon Runs Agents. Mercado Libre Runs on Manual Spanish Replies.</title>
<link>https://coderlegion.com/24032/amazon-runs-agents-mercado-libre-runs-on-manual-spanish-replies</link>
<description>&lt;p&gt;The Amazon seller's morning is already automated. An ads agent adjusted bids overnight, added negative keywords at 2 AM, and topped up the budget when CPC spiked. A product-selection agent scanned thousands of reviews and flagged a sub-category with weak competition. Customer service is running at 90% auto-reply. The official assistant — now on its third generation — is quietly clearing dead stock from the warehouse.&lt;/p&gt;
&lt;p&gt;Meanwhile, at a Mercado Libre operation in São Paulo, someone is manually typing &quot;Olá, muito obrigado pela sua compra&quot; into an internal message box at 11 PM, because the 24-hour reply deadline is tied to store rating, and store rating is tied to traffic. The restock forecast lives in an Excel sheet. The translation software produces Spanish that reads like translated software.&lt;/p&gt;
&lt;p&gt;This is the Mercado Libre Agent Gap: the largest e-commerce ecosystem in Latin America — 120 million annual buyers, 2.4 billion items sold in 2025 — has no seller-agent ecosystem. Not a nascent one. Not a struggling one. Zero.&lt;/p&gt;
&lt;h3 id=&quot;two-daily-realities&quot;&gt;Two Daily Realities&lt;/h3&gt;
&lt;p&gt;Amazon's seller tooling has been through three full generations. The first was rule-based automation: if this happens, do that. The second added prediction: forecast demand, pre-adjust bids. The third is agentic: autonomous execution with objectives. Ad agents that reallocate budget when ROAS dips. Creative agents that generate ad imagery from text prompts and lift ROAS by 121% in some campaigns. Inventory agents that clear dead stock before storage fees eat margin.&lt;/p&gt;
&lt;p&gt;The numbers from Amazon's official agent rollout are not subtle. Ads agents improved outcomes for 65% of advertisers, with CPM down 18% and CPA down 16%. The seller assistant's agentic auto-publish feature moved old listings up 12 organic ranking positions, lifted mobile conversion 14.3%, and cut returns 9%. This is not laboratory performance — this is production tooling at platform scale.&lt;/p&gt;
&lt;p&gt;Mercado Libre sellers operate like it's 2015. Portuguese replies are typed by hand. Spanish replies are run through translation software and then patched up by whoever speaks the language best. Restock forecasts are Excel formulas. When a stockout hits — and it hits often, because Brazil customs clearance is measured in months, not days — the listing weight resets to zero. Years of accumulated ranking, reviews, and sales velocity evaporate.&lt;/p&gt;
&lt;h3 id=&quot;the-three-walls&quot;&gt;The Three Walls&lt;/h3&gt;
&lt;p&gt;Why is there no agent ecosystem on Mercado Libre? Three walls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The language wall.&lt;/strong&gt; Spanish and Portuguese are not English with a dictionary swap. They are complete systems of search habits, slang, and complaint phrasing. A Peruvian buyer writes &quot;me llegó chueco&quot; — it arrived crooked — and no translation tool picks up the implicit defect claim. A Brazilian buyer searches &quot;ventilador&quot; when they want a fan and &quot;circulador de ar&quot; when they want an air circulator; a seller who translated &quot;air circulator&quot; literally loses the first search entirely. Customer service agents on Mercado Libre need to parse colloquial complaints, respond within platform time windows, and do it in two languages. That is a hard automation problem. It is also the entire game.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The data wall.&lt;/strong&gt; Mercado Libre's platform data openness is low, and the third-party tool ecosystem reflects it. On the Amazon side, one analytics tool alone has 1.5 million users; entire companies are built on scraping, analyzing, and predicting Amazon listing performance. Mercado Libre's third-party ecosystem is a desert. Without data access, agents have nothing to train on and nothing to execute against.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The heavy model.&lt;/strong&gt; The typical cross-border seller on Mercado Libre runs a local store, an overseas warehouse, a Full (Fulfillment by Mercado Libre) warehouse, and CBT multi-account operations simultaneously. That's four inventory pools, four pricing logics, and four reputation scores to manage. Amazon sellers mostly deal with one fulfillment mechanism. Mercado Libre sellers are running a logistics company, a customer service company, and a marketplace operation at the same time — by hand.&lt;/p&gt;
&lt;h3 id=&quot;six-gaps-no-agent-has-filled&quot;&gt;Six Gaps No Agent Has Filled&lt;/h3&gt;
&lt;p&gt;Walk through a Mercado Libre operation and the blank spaces are obvious.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Spanish/Portuguese intelligent customer service.&lt;/strong&gt; The 24-hour reply rule is a traffic lever, not a compliance checkbox. Reply speed feeds store rating, and rating feeds search placement. A five-person team often has two people permanently stuck on internal messages — the platform's chat — while product questions, order inquiries, and return requests pile up. The Q&amp;amp;A standard is a 10-minute response window. No seller meets it consistently. Nobody even comes close.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Listing localization.&lt;/strong&gt; Around 99% of cross-border sellers &quot;translate&quot; listings instead of &quot;localizing&quot; them. The ventilador/circulador example is not an edge case; it's the norm. Localization means understanding that Spanish speakers in Mexico search differently than Spanish speakers in Argentina, that Brazilian Portuguese has its own e-commerce vocabulary, and that sizing, color names, and product descriptors are culturally loaded. An agent that localizes listings — not just translates them — would be an instant ranking lever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Mercado Ads agent.&lt;/strong&gt; Amazon's ad agents are on generation three. Mercado Libre sellers are monitoring ACOS manually, in spreadsheets, with no automated bid adjustment and no negative-keyword engine. The gap between &quot;the platform will sell ads to anyone&quot; and &quot;nobody has built a tool to manage those ads&quot; is one of the strangest open opportunities in e-commerce.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Full/overseas warehouse restock forecast.&lt;/strong&gt; Brazil customs clearance is counted in months. Restock lead times run 60 to 90 days. The restock formula is simple — (daily sales × days in transit) + safety stock — but the execution is brutal. Inventory below 15 days of daily sales drops listing weight by 30%. Stockout resets it to zero. Every forecast error is either stranded inventory or a destroyed listing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Store reputation maintenance.&lt;/strong&gt; Reputación is the traffic master switch on Mercado Libre. Negative reviews and late case resolutions deduct points; points determine visibility; visibility determines sales. The order defect rate ceiling is 0.8%. Self-fulfillment on-time rate must stay at 96%. One 1-star review can take daily sales from 40-plus units to single digits. Sellers are managing reputation with manual review monitoring and prayer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Multi-account data dashboard.&lt;/strong&gt; Local store, CBT store, overseas warehouse, Full warehouse. Four isolated islands. No unified view of inventory, orders, or performance. An Amazon seller can see their entire operation in one dashboard; a Mercado Libre seller is tabbing between four.&lt;/p&gt;
&lt;h3 id=&quot;the-market-behind-the-vacuum&quot;&gt;The Market Behind the Vacuum&lt;/h3&gt;
&lt;p&gt;The opportunity is not hypothetical. Mercado Libre's 2025 full-year results: net revenue of $28.9 billion, up 39%; GMV of $65 billion, up 26%; 2.4 billion items sold; 120 million annual buyers. This is a platform the size of Amazon's international business, with a fraction of the tooling.&lt;/p&gt;
&lt;p&gt;Chinese sellers hold less than 1% of the Brazilian market and a negligible share in Colombia. The category is a blue ocean. Meanwhile, 80% of Mercado Libre sellers are SMBs — small operators who cannot build internal tooling and will pay for software that moves the needle. The willingness to pay is there. The product is not.&lt;/p&gt;
&lt;p&gt;Mercado Libre's official AI efforts are real but deliberately limited. The Seller Assistant, launched in March 2026, grew daily active users 40% month-over-month and now touches roughly 20% of GMV. Mercado Pago's AI resolves 87% of customer interactions without human involvement. The official arbitration AI handles 10% of customer-service disputes — the equivalent of 9,000 human agents making $450 million in decisions annually.&lt;/p&gt;
&lt;p&gt;But all of this is platform-side. The official tools offer recommendations and content, not seller-side autonomous agents. Third-party tools exist — customer-service agents at $39–199 per month, ERPs covering 30+ platforms with over a million sellers — but they solve single-point problems. Nobody has built the integrated agent layer.&lt;/p&gt;
&lt;h3 id=&quot;the-whatsapp-blind-spot&quot;&gt;The WhatsApp Blind Spot&lt;/h3&gt;
&lt;p&gt;Here is the gap within the gap. About 90% of Latin American transactions happen — or at least are negotiated — on WhatsApp. Buyers message sellers there. Sellers confirm orders there. Disputes get hashed out there. But WhatsApp is completely fragmented from the platform ecosystem.&lt;/p&gt;
&lt;p&gt;A seller today manages the platform internal message system, WhatsApp conversations, and Spanish/Portuguese language requirements as three separate workloads. The 8-business-hour internal message deadline, the 10-minute Q&amp;amp;A standard, and the WhatsApp response expectation are three different clocks running simultaneously.&lt;/p&gt;
&lt;p&gt;A three-in-one agent — internal message + WhatsApp + bilingual Spanish/Portuguese — does not exist. It is the single biggest white space in Latin American e-commerce tooling. The platform-side agents can't reach WhatsApp. The WhatsApp tools can't reach platform data. The translation tools can't handle either one with context. The seller is the only integration point, and the seller is a human being typing Portuguese at 11 PM.&lt;/p&gt;
&lt;h3 id=&quot;what-the-first-real-agent-stack-looks-like&quot;&gt;What the First Real Agent Stack Looks Like&lt;/h3&gt;
&lt;p&gt;The first functional Mercado Libre agent stack will not be a single bot. It will be an operations layer that sits across all of it: order sync across local and CBT stores, warehouse consolidation logic that knows what is in the overseas warehouse versus the Full warehouse versus in transit, and restock forecasting that accounts for Brazil's month-scale customs timeline.&lt;/p&gt;
&lt;p&gt;That same layer handles the three most expensive manual workloads: ads management with automated bid and budget adjustments, restock forecasting tied to real inventory positions, and a multi-account dashboard that finally connects the isolated islands.&lt;/p&gt;
&lt;p&gt;The sellers who get there first will not just save labor. They will compound. Every hour not spent typing Portuguese replies is an hour spent on listing quality, on pricing, on the product-side work that actually moves rankings. Every stockout avoided is listing weight preserved. Every localized listing is a new search term won.&lt;/p&gt;
&lt;p&gt;The tools for competing in Latin America already exist in the form of Chinese cross-border e-commerce software — mature, aggressive, battle-tested on Amazon. What they lack is the Latin America layer: the languages, the customs timelines, the WhatsApp integration, the Reputación logic.&lt;/p&gt;
&lt;p&gt;Mercado Libre's sellers are still replying by hand in a market where 120 million buyers made 2.4 billion purchases last year. The agent gap is the biggest open category in Latin American e-commerce. It will not stay open long.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24032/amazon-runs-agents-mercado-libre-runs-on-manual-spanish-replies</guid>
<pubDate>Mon, 03 Aug 2026 08:26:39 +0000</pubDate>
</item>
<item>
<title>The Cost of Failing Closed: What Shipped Across Nine PHP Extensions</title>
<link>https://coderlegion.com/23606/the-cost-of-failing-closed-what-shipped-across-nine-php-extensions</link>
<description>&lt;p&gt;Making &lt;code&gt;ExcelBook::save()&lt;/code&gt; atomic is easy to describe. Write the workbook to a temporary file, then rename it into place. A save that dies halfway leaves the original file untouched.&lt;/p&gt;
&lt;p&gt;The obvious way to build that staged file is to ask LibXL for the finished archive as one buffer and write the buffer out. It works. It also costs 67.7 MB of peak RSS on a 3.3 MB workbook, and the PHP-side copy counts against &lt;code&gt;memory_limit&lt;/code&gt;. The streaming writer php_excel used before the staging work costs 0.7 MB for the same file. That is roughly twenty times the workbook size in RAM, spent by a change whose whole purpose was safety.&lt;/p&gt;
&lt;p&gt;That one arrived and was fixed inside the same release window, so no published version carries it. It is still a fair summary of the last two weeks. Nine of my PHP extensions released today. Between them they add one new method, one set of interfaces, and a few error constants. Everything else is a bug fixed, a value rejected, a limit enforced, or a cost paid back down.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/failing-closed-what-shipped-across-seven-php-extensions&quot;&gt;previous roundup&lt;/a&gt; ended on the claim that most of the work in these extensions is the unglamorous kind. This cycle is what comes after that claim, and it has a bill attached. Checking something on every row costs something on every row. Staging a write costs memory. Validating a wrapper at construction costs construction time. Most of the performance work below is repair rather than new optimization: finding where the safety checks got expensive, and making them cheap again.&lt;/p&gt;
&lt;h3 id=&quot;php-excel-2-6-0-every-save-stages-and-renames&quot;&gt;php_excel 2.6.0: every save stages and renames&lt;/h3&gt;
&lt;p&gt;2.5.0 extended atomic save from stream wrappers to plain local paths, then restored the streaming writer underneath it. Local saves now stage through &lt;code&gt;xlBookSave&lt;/code&gt; into an exclusively created sibling temp file and rename it into place, so the atomicity holds and the memory cost does not. Save time is unchanged. The staged file also carries the destination's existing permission bits, so replacing a &lt;code&gt;0600&lt;/code&gt; workbook no longer widens it to &lt;code&gt;0644&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Atomicity on local paths changes three behaviours worth knowing before you upgrade. A symlink destination is replaced by a regular file instead of written through. An existing hard link is broken. Saving into a writable file inside a non-writable directory now fails instead of succeeding, because the rename needs the directory.&lt;/p&gt;
&lt;p&gt;Typed writes stopped lying. An explicit data type that the value cannot satisfy is now rejected rather than silently stored as something else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// 2.4.0: stored the string as text. 2.5.0: rejects, and names the cell it stopped at.
$sheet-&amp;gt;write(0, 0, 'not a date', null, ExcelFormat::AS_DATE);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;null&lt;/code&gt; still writes a blank cell under any data type, so bulk &lt;code&gt;writeRow()&lt;/code&gt; and &lt;code&gt;writeCol()&lt;/code&gt; calls over a typed column with gaps keep working.&lt;/p&gt;
&lt;p&gt;2.6.0 is two lines on top of that, and one is breaking. &lt;code&gt;ExcelFormat::borderDiagonalStyle()&lt;/code&gt; was wired to the diagonal direction rather than the line style it is named for. It now addresses the style, and the new &lt;code&gt;ExcelFormat::borderDiagonal()&lt;/code&gt; gets and sets which diagonals are drawn. Code passing a &lt;code&gt;BORDERDIAGONAL_*&lt;/code&gt; constant to the old method keeps running without error and quietly stops drawing its diagonal, so it is worth grepping for.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_excel&quot;&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;pdo-duckdb-0-5-0-the-sandbox-stopped-charging-by-the-row&quot;&gt;pdo_duckdb 0.5.0: the sandbox stopped charging by the row&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;open_basedir&lt;/code&gt; sandbox re-latched itself once per row and compared the recorded basedir by hash. Under a long &lt;code&gt;open_basedir&lt;/code&gt; that cost scaled with the string length, which is a strange thing for a per-row cost to depend on. It now re-latches once per fetched data chunk and compares by string.&lt;/p&gt;
&lt;p&gt;A 400,000 row scan with a 223 character &lt;code&gt;open_basedir&lt;/code&gt; went from 133 ns/row back to 51 ns/row. Bulk &lt;code&gt;Appender::appendRow()&lt;/code&gt; went from 196 ns/row back to 96 ns/row.&lt;/p&gt;
&lt;p&gt;The rest of the release is the sandbox itself getting harder to walk out of. DuckDB re-allowlists a non-empty &lt;code&gt;temp_directory&lt;/code&gt; and any attached paths when external access is turned off, and those entries cannot be cleared afterwards, so the escalate sequence now clears temp, clears the sticky &lt;code&gt;log_query_path&lt;/code&gt; and &lt;code&gt;profiling_output&lt;/code&gt; writers, drops the path allowlists, and detaches out-of-basedir databases before flipping &lt;code&gt;enable_external_access&lt;/code&gt; off and locking configuration. Re-narrowing &lt;code&gt;open_basedir&lt;/code&gt; after that fails closed rather than leaving the wider policy in force.&lt;/p&gt;
&lt;p&gt;One rendering bug is worth calling out because it is silent. A &lt;code&gt;DECIMAL&lt;/code&gt; whose type has no integer digits rendered with a leading zero that DuckDB itself does not emit.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// DECIMAL(2,2): DuckDB renders .05, the driver rendered 0.05
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That affected scalar cells since 0.4.1 and nested &lt;code&gt;LIST&lt;/code&gt;, &lt;code&gt;ARRAY&lt;/code&gt;, &lt;code&gt;STRUCT&lt;/code&gt;, and &lt;code&gt;MAP&lt;/code&gt; cells since the direct nested renderer landed. Prebuilt binaries now link DuckDB 1.5.5; a source build still works against the advertised 1.5.3 floor.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/pdo_duckdb&quot;&gt;github.com/iliaal/pdo_duckdb&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fast-uuid-0-6-0-a-batch-that-reads-the-clock-once&quot;&gt;fast_uuid 0.6.0: a batch that reads the clock once&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;uuid_v7_batch()&lt;/code&gt; and &lt;code&gt;uuid_v7_bin_batch()&lt;/code&gt; used to read the clock once per UUID. They now read it once per call, which is about 2.6x faster per UUID on aarch64. The v4 batch generators draw CSPRNG bytes for 64 UUIDs at a time instead of 16 bytes per UUID, worth another 6 to 8%.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$ids = uuid_v7_batch(100000);   // ~8 ms, all carrying the batch-start millisecond
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trade is stated in the release notes because it is observable: every UUID in one call carries the batch-start millisecond, so &lt;code&gt;getDateTime()&lt;/code&gt; on the tail of a large batch can trail the wall clock by the batch duration. Values stay unique, sorted, and monotonic against the next single call.&lt;/p&gt;
&lt;p&gt;The compat layer paid a cost in the other direction. &lt;code&gt;Internal\ConstructionToken::Trusted&lt;/code&gt; used to skip the wrapper-class check, and it no longer does, so every compat wrapper validates its core on construction. That is one &lt;code&gt;getVersion()&lt;/code&gt; call and a class-name comparison, and it makes construction about a third slower than 0.4.0: &lt;code&gt;UuidFactory::fromBytes()&lt;/code&gt; drops from 1.81M to 1.21M ops/s on aarch64. I kept it. A wrapper that does not match its core is a bug that surfaces somewhere much less convenient.&lt;/p&gt;
&lt;p&gt;0.6.0 also settled how far the compat layer follows &lt;code&gt;ramsey/uuid&lt;/code&gt;. Every identity form now derives from &lt;code&gt;toString()&lt;/code&gt;, as it does upstream, so &lt;code&gt;getUrn()&lt;/code&gt;, &lt;code&gt;getHex()&lt;/code&gt;, &lt;code&gt;getInteger()&lt;/code&gt;, &lt;code&gt;equals()&lt;/code&gt;, and &lt;code&gt;compareTo()&lt;/code&gt; all follow a COMB codec's reshaped text rather than the network-order core. The one deliberate departure is &lt;code&gt;GuidStringCodec&lt;/code&gt;, which reshapes the byte array only. Over 400 &lt;code&gt;$guidFactory-&amp;gt;uuid4()&lt;/code&gt; calls, &lt;code&gt;ramsey/uuid&lt;/code&gt; 4.9.2 threw 190 times with &quot;The byte string received does not contain a valid version&quot; and emitted a wrong version nibble in the text 184 more times, leaving 26 clean. The same loop here returns 400 usable v4 GUIDs.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fast_uuid&quot;&gt;github.com/iliaal/fast_uuid&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastchart-1-7-0-limits-and-one-honest-regression&quot;&gt;fastchart 1.7.0: limits, and one honest regression&lt;/h3&gt;
&lt;p&gt;Every documented resource and numeric limit is now enforced before valid chart state is replaced. Pie totals, graph-label aggregates, error-bar arrays, Venn sets, category labels, and scatter image-map strings used to validate after clearing, so a caught over-cap &lt;code&gt;ValueError&lt;/code&gt; left you holding a wiped chart. Arc, Chord, and Network &lt;code&gt;setNodes()&lt;/code&gt; and &lt;code&gt;setLinks()&lt;/code&gt; throw on over-cap input instead of silently truncating it.&lt;/p&gt;
&lt;p&gt;Two new INI settings bound memory that &lt;code&gt;memory_limit&lt;/code&gt; never saw, because it lives outside the PHP allocator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;fastchart.max_render_pixels = 64M      ; PHP_INI_SYSTEM, bounds raster buffers
fastchart.max_image_cache_bytes = 64M  ; PHP_INI_SYSTEM, bounds retained decoded images
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caller-supplied SVG is now rejected above 65,536 elements, 262,144 attributes, or 256 nesting levels. &lt;code&gt;renderToFile()&lt;/code&gt; accepts only local filesystem paths, because a stream wrapper cannot provide atomic replacement, and it preserves an existing destination's mode, revalidates &lt;code&gt;open_basedir&lt;/code&gt; and staging identity at commit, and leaves the prior file under a recovery name when a post-install race prevents cleanup.&lt;/p&gt;
&lt;p&gt;The performance line is mixed and I would rather write it down than round it off. Stock rolling indicators moved to linear-time extrema and statistics paths, and ordinary series are 2 to 33% faster. A stock series carrying a near-&lt;code&gt;DBL_MAX&lt;/code&gt; outlier with a multi-thousand window costs about 4x the 1.6.0 setter time, because that series now goes through normalized sliding aggregates instead of an overflow-prone accumulator. Raster file output streams encoder bytes straight into the atomic destination, and in-memory raster output no longer keeps a second full-size pixel frame.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastchart&quot;&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastjson-0-7-0-ext-json-parity-down-to-the-error-state&quot;&gt;fastjson 0.7.0: ext/json parity, down to the error state&lt;/h3&gt;
&lt;p&gt;fastjson's promise is that you can swap it in for &lt;code&gt;ext/json&lt;/code&gt;, and this release is mostly about the places where that wasn't quite true. &lt;code&gt;fastjson_encode()&lt;/code&gt;, &lt;code&gt;fastjson_file_encode()&lt;/code&gt;, and replacement encoding in &lt;code&gt;fastjson_pointer_set()&lt;/code&gt; now publish the outer operation's final error state after a nested callback or destructor calls fastjson recursively. A decode of a string carrying both a backslash escape and invalid UTF-8 under &lt;code&gt;JSON_INVALID_UTF8_IGNORE&lt;/code&gt; no longer rejects: &lt;code&gt;&quot;\/\xFFe&quot;&lt;/code&gt; was reported as an unexpected control character and now decodes to &lt;code&gt;/e&lt;/code&gt;, as &lt;code&gt;json_decode()&lt;/code&gt; does.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fastjson_pointer_set()&lt;/code&gt; picked up three fixes that matter if you use it on documents you didn't write. It validates pointer syntax and traversal before serialising the replacement, so a malformed pointer no longer runs &lt;code&gt;JsonSerializable&lt;/code&gt; callbacks first. It preserves untouched number lexemes byte for byte, so large integers, &lt;code&gt;-0&lt;/code&gt;, and trailing-zero forms survive a set elsewhere in the document. And &lt;code&gt;fastjson_pointer_get()&lt;/code&gt; now rejects a pointer whose traversed path is made ambiguous by duplicate object members rather than silently taking the first.&lt;/p&gt;
&lt;p&gt;The performance work, measured against 0.6.0 on PHP 8.4 release builds, x86_64 unless the changelog notes aarch64:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fastjson_merge_patch()&lt;/code&gt;, 2,000-key merge: ~95% faster&lt;/li&gt;
&lt;li&gt;Large clean-ASCII encode, 256 KiB to 1 MiB: 32 to 66% faster&lt;/li&gt;
&lt;li&gt;Tolerant decode on clean input: 20 to 64% faster&lt;/li&gt;
&lt;li&gt;Packed-array decode: ~22% faster&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastjson_pointer_set()&lt;/code&gt;: 42 to 66% faster&lt;/li&gt;
&lt;li&gt;Top-level scalar encode: 35 to 43% faster&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of those is a reversal. The exact-size preflight for very large strings now starts at 8 MiB rather than 1 MiB, because below that the second pass cost more than the reservation it saved: 75% slower on x86_64 and 160% slower on aarch64 for UTF-8 text at 1 MiB. The optimization was real and the threshold was wrong.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastjson&quot;&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phpser-0-5-0-a-signature-is-not-a-provenance-proof&quot;&gt;phpser 0.5.0: a signature is not a provenance proof&lt;/h3&gt;
&lt;p&gt;The signed decode path in phpser assumed that a validly signed frame came from phpser's own encoder, and used that assumption to skip work. It skipped the object pin, and it used &lt;code&gt;add_new&lt;/code&gt; style inserts for assoc, object-property, and rowset schema keys on the grounds that the encoder would never emit a duplicate.&lt;/p&gt;
&lt;p&gt;A valid HMAC proves key possession, not honest-encoder provenance. Anyone holding the key can hand the decoder a frame the encoder would never produce. A forged-but-validly-signed frame carrying a duplicate key could free an id-registered object on overwrite while a later back-reference still pointed at it, which is a use-after-free reachable by anyone who can sign. Objects are now pinned unconditionally for the decode pass, and the trusted paths use uniqueness-gated inserts.&lt;/p&gt;
&lt;p&gt;Two more use-after-frees came out of the encoder, both through user hooks. A &lt;code&gt;__serialize&lt;/code&gt; or &lt;code&gt;__sleep&lt;/code&gt; hook that grows the array being walked through a by-reference alias reallocated the table under the element iterator; the walk now holds a reference so the write copy-on-write separates instead. Native &lt;code&gt;serialize()&lt;/code&gt; still exhibits this on the same input. A nested hook that replaced a referenced &lt;code&gt;__sleep()&lt;/code&gt; member name after phpser had borrowed the string pointer is the third, and the encoder now owns the selected property names until it finishes the object. Decoder work on crafted Zend hash collisions is bounded, so wire-controlled keys cannot turn an associative array into quadratic CPU.&lt;/p&gt;
&lt;p&gt;The columnar format got better at the same time. &lt;code&gt;TAG_TABLE&lt;/code&gt; deduplicates low-cardinality strings and equal packed-string vectors by content, which makes distinct-allocation rowsets 53% smaller and 44% faster to decode than igbinary on ARM. Table decode streams each column into the row arrays instead of materializing the columnar matrix first: on a 60,000 by 4 table, peak memory drops from 34.0 MB to 30.0 MB with no speed change, and a rejected frame never pays for the rows it claims.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-clickhouse-0-11-0-values-that-came-back-different&quot;&gt;php_clickhouse 0.11.0: values that came back different&lt;/h3&gt;
&lt;p&gt;These are the kind of bug you don't notice until a number is wrong in a report.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Float32&lt;/code&gt; reads round-tripped through &lt;code&gt;%.6g&lt;/code&gt;, which drops significant digits. &lt;code&gt;toFloat32(123456789)&lt;/code&gt; read back as &lt;code&gt;123457000&lt;/code&gt;. Reads now return the exact value the server sent, with the full binary expansion, so &lt;code&gt;toFloat32(0.55)&lt;/code&gt; is &lt;code&gt;0.550000011920929&lt;/code&gt;. Inserting a float outside the column's range throws instead of storing &lt;code&gt;±INF&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;compression&lt;/code&gt; setting of &lt;code&gt;2&lt;/code&gt; was silently downgraded to LZ4 rather than selecting ZSTD. The option now accepts &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;1&lt;/code&gt;/&lt;code&gt;2&lt;/code&gt;, the strings &lt;code&gt;&quot;none&quot;&lt;/code&gt;/&lt;code&gt;&quot;lz4&quot;&lt;/code&gt;/&lt;code&gt;&quot;zstd&quot;&lt;/code&gt;, and bool, and throws on anything else. &lt;code&gt;Bool&lt;/code&gt; inserts accept only &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;1&lt;/code&gt;, and those four values as strings; the string &lt;code&gt;&quot;false&quot;&lt;/code&gt; previously stored &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Map&lt;/code&gt; decoding produced PHP arrays with duplicate keys, because the duplicate check went through &lt;code&gt;zend_hash_str_add_new()&lt;/code&gt;, which skips the existence test and appends a second bucket under the same key instead of reporting the collision. Associative decoding now rejects a &lt;code&gt;Map&lt;/code&gt; whose entries would collapse onto the same PHP array key, and a new mode gives you the lossless form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$rows = $ch-&amp;gt;select('SELECT m FROM t', [], ClickHouse::MAP_AS_PAIRS);  // ordered [key, value] pairs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ClickHouseStatement&lt;/code&gt; joins cycle collection, so a statement holding an object cell stops leaking its rows until request shutdown. A failed TLS handshake stops leaking the peer certificate and its chain, about 4.4 KB per rejected connection. And &lt;code&gt;selectStream()&lt;/code&gt; works on a result carrying an untyped &lt;code&gt;NULL&lt;/code&gt; column when &lt;code&gt;memory_limit&lt;/code&gt; is set, which php-fpm sets by default. On 32-bit PHP, protocol integers and DateTime values wider than &lt;code&gt;zend_long&lt;/code&gt; come back as decimal strings instead of wrapping negative.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;mdparser-0-5-0-less-memory-same-output&quot;&gt;mdparser 0.5.0: less memory, same output&lt;/h3&gt;
&lt;p&gt;The output buffer used to reserve an exact size for every document. It now seeds a roughly 1.25x reserve capped at 1 MiB, so ordinary documents reallocate less, sparse input stops reserving output it never produces, and dense input past the cap grows by doubling. Heading-anchor side buffers are released as each heading is flushed instead of held for the document. Adjacent AST text fragments are coalesced into one &lt;code&gt;text&lt;/code&gt; node, which cuts the callback-driven array overhead that showed up on AST-heavy workloads.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;toInlineHtml()&lt;/code&gt; got the worst of the old behaviour. It appended multiline content one byte at a time, dropped line-leading inline delimiters, and let its per-line sentinel surface inside code and LaTeX spans that crossed a line break. It now bulk-appends content runs and preserves both the delimiters and literal U+200B.&lt;/p&gt;
&lt;p&gt;Two GitHub Flavored Markdown gaps closed. &lt;code&gt;Options::github()&lt;/code&gt; enables alerts as well as footnotes, and a bare URL containing a percent sign autolinks: &lt;code&gt;http://a.com/x%20y&lt;/code&gt; used to stop the scan at the percent and stay literal text. Fenced code whose info string entity-decodes to a &lt;code&gt;language-&lt;/code&gt; prefix no longer gets a second one prepended, so &lt;code&gt;language&amp;amp;#45;php&lt;/code&gt; produces &lt;code&gt;class=&quot;language-php&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The vendored md4c moved to master &lt;code&gt;10c0158&lt;/code&gt;, which lets the local code-span line-break patch go away now that upstream carries it and picks up a bounds guard on the table-alignment dash scan. Zend memory-limit bailouts now release md4c's libc allocations before the request aborts.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/mdparser&quot;&gt;github.com/iliaal/mdparser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phonetic-0-4-0-constants-that-cannot-be-confused&quot;&gt;phonetic 0.4.0: constants that cannot be confused&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;bmpm()&lt;/code&gt; takes a name type and an accuracy, in that order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;function bmpm(string $string, int $name_type = BMPM_GENERIC, int $accuracy = BMPM_APPROX, string $language = &quot;&quot;): string
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BMPM_APPROX&lt;/code&gt; was &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;BMPM_EXACT&lt;/code&gt; was &lt;code&gt;2&lt;/code&gt;. &lt;code&gt;BMPM_ASHKENAZI&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;BMPM_SEPHARDIC&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt;. So &lt;code&gt;bmpm($name, BMPM_APPROX)&lt;/code&gt;, which is a natural thing to write and wrong, ran as an Ashkenazi encode and returned a plausible answer. Nothing rejected it because nothing could tell the difference.&lt;/p&gt;
&lt;p&gt;The accuracy constants are now &lt;code&gt;10&lt;/code&gt; and &lt;code&gt;20&lt;/code&gt;, disjoint from the name-type range, so the same call is rejected by argument validation. This is breaking only for code that hard-codes the numeric values &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;2&lt;/code&gt; for the accuracy argument; code using the constant names is unaffected. Forcing &lt;code&gt;$language = &quot;any&quot;&lt;/code&gt; also raises a &lt;code&gt;ValueError&lt;/code&gt; now, since that is the default ruleset label rather than a language; pass an empty string for auto-detect.&lt;/p&gt;
&lt;p&gt;The rest is BMPM performance and bounds. The final-rule merge sorts once instead of running an O(R²) insert-sort, &lt;code&gt;BMPM_MAX_PREFIX_DEPTH&lt;/code&gt; drops from 16 to 6 to bound the prefix dual-encode fan-out, and phoneme-set growth uses overflow-safe allocation. One earlier optimization got reverted: routing 1 to 3 element compares through &lt;code&gt;memcmp()&lt;/code&gt; cost 5 to 6% of encode time, so the rule-matching inner loops compare code points inline again. &lt;code&gt;double_metaphone()&lt;/code&gt; stops encoding once both codes reach &lt;code&gt;max_length&lt;/code&gt;, and the match helpers skip the second encode when the first operand is unencodable or the two operands are identical.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phonetic&quot;&gt;github.com/iliaal/phonetic&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-a-stabilization-release-actually-costs&quot;&gt;What a stabilization release actually costs&lt;/h3&gt;
&lt;p&gt;Across these nine changelogs there is almost no new capability. One border method, one set of interfaces, a few error-code constants, one lossless &lt;code&gt;Map&lt;/code&gt; mode. The rest of it is a &lt;code&gt;Float32&lt;/code&gt; that finally comes back the way the server sent it, a signed frame trusted for exactly what a signature proves and nothing further, and a &lt;code&gt;bmpm()&lt;/code&gt; accuracy constant that can no longer be mistaken for a name type.&lt;/p&gt;
&lt;p&gt;The part I didn't expect when I started this cycle is how much of the performance work turned out to be the same job. Streaming the workbook save back, re-latching the sandbox per chunk instead of per row, reading the clock once per batch, moving the string preflight threshold from 1 MiB to 8 MiB, comparing three code points inline instead of through &lt;code&gt;memcmp()&lt;/code&gt;. None of that is a new algorithm. It is the cost of correctness, found and paid down after the correctness landed. Some of it stays paid: compat construction in fast_uuid is a third slower on purpose, and a fastchart stock series with a near-&lt;code&gt;DBL_MAX&lt;/code&gt; outlier costs 4x what it did. Those are the trades I would make again, and they belong in the changelog rather than in the rounding.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23606/the-cost-of-failing-closed-what-shipped-across-nine-php-extensions</guid>
<pubDate>Mon, 27 Jul 2026 19:19:31 +0000</pubDate>
</item>
<item>
<title>How to Choose the Right Web Development Technology for Long-Term Business Growth</title>
<link>https://coderlegion.com/23560/how-to-choose-the-right-web-development-technology-for-long-term-business-growth</link>
<description>&lt;p&gt;Building a website today is no longer just about creating an online presence. Modern businesses expect their websites to generate leads, support customers, integrate with business tools, and grow alongside the company. This is why selecting the right web development technology is one of the most important decisions during the planning stage.&lt;/p&gt;
&lt;p&gt;Many organizations rush into development by choosing the latest programming language or framework without considering whether it actually suits their business goals. A better approach is to evaluate your project requirements, compare available technologies, and work with experienced developers who can recommend the best solution.&lt;/p&gt;
&lt;h2 id=&quot;start-with-your-business-goals&quot;&gt;&lt;strong&gt;Start with Your Business Goals&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Every web development project is different. An online store has different requirements than a SaaS platform, while a corporate website has different priorities than a customer portal.&lt;/p&gt;
&lt;p&gt;Before choosing a technology, ask yourself a few questions.&lt;/p&gt;
&lt;p&gt;How many users do you expect during the first year? Will the website need third-party integrations? Are you planning to launch a mobile application later? How frequently will new features be added?&lt;/p&gt;
&lt;p&gt;The answers help determine whether your project needs rapid development, enterprise-level scalability, advanced automation, or a combination of all three.&lt;/p&gt;
&lt;p&gt;When business goals are clearly defined, selecting the right technology becomes much easier.&lt;/p&gt;
&lt;h2 id=&quot;why-programming-language-selection-matters&quot;&gt;&lt;strong&gt;Why Programming Language Selection Matters&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;The programming language behind your website influences its speed, flexibility, maintenance costs, and future scalability. Although there are many excellent backend technologies available today, PHP and Python remain among the most widely adopted choices.&lt;/p&gt;
&lt;p&gt;PHP has been a cornerstone of web development for years, powering millions of websites and popular content management systems. It offers excellent compatibility with hosting environments and is especially well suited for content-driven websites and eCommerce platforms.&lt;/p&gt;
&lt;p&gt;Python has gained tremendous popularity because of its clean syntax, rapid development capabilities, and extensive ecosystem. It is widely used for AI applications, automation, machine learning, and complex web platforms.&lt;/p&gt;
&lt;p&gt;If you're evaluating which language better suits your project, this comprehensive comparison of &lt;a rel=&quot;nofollow&quot; href=&quot;https://learnwoo.com/php-vs-python-web-development/&quot;&gt;PHP vs. Python&lt;/a&gt; explains the advantages, disadvantages, performance considerations, and ideal use cases for each technology. Reviewing both options before starting development can prevent expensive changes later.&lt;/p&gt;
&lt;h2 id=&quot;performance-depends-on-more-than-the-language&quot;&gt;&lt;strong&gt;Performance Depends on More Than the Language&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Many business owners assume that choosing a programming language automatically determines website performance. In reality, performance depends on several factors working together.&lt;/p&gt;
&lt;p&gt;Application architecture, database optimization, caching strategies, image optimization, hosting infrastructure, and code quality all contribute significantly to loading speed.&lt;/p&gt;
&lt;p&gt;Even the fastest programming language cannot compensate for poorly written code or an inefficient database design. Likewise, a well-optimized application built with mature technologies often outperforms newer frameworks that have not been implemented correctly.&lt;/p&gt;
&lt;p&gt;Businesses should focus on hiring developers who understand performance optimization rather than chasing technology trends.&lt;/p&gt;
&lt;h2 id=&quot;scalability-should-be-planned-from-day-one&quot;&gt;&lt;strong&gt;Scalability Should Be Planned from Day One&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;One common mistake businesses make is building only for current requirements.&lt;/p&gt;
&lt;p&gt;A website that serves a few hundred visitors today may need to support thousands of daily users within a couple of years. Customer expectations also continue to evolve, requiring businesses to introduce new features such as payment gateways, CRM integrations, customer dashboards, APIs, marketing automation, and analytics tools.&lt;/p&gt;
&lt;p&gt;Planning for future growth from the beginning helps avoid complete redevelopment later.&lt;br&gt;
Scalable architecture allows developers to add new features without affecting existing functionality, saving both time and development costs over the long term.&lt;/p&gt;
&lt;h2 id=&quot;finding-the-right-development-team&quot;&gt;&lt;strong&gt;Finding the Right Development Team&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Choosing the right technology is only part of the equation. The experience of your development partner often has an even greater impact on the final outcome.&lt;/p&gt;
&lt;p&gt;Professional development companies understand project planning, security, quality assurance, deployment, maintenance, and ongoing support. They also follow coding standards that make applications easier to maintain as businesses grow.&lt;/p&gt;
&lt;p&gt;If you're researching experienced agencies, this curated list of &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.wpwebelite.com/blog/web-development-companies-india/&quot;&gt;top web development companies in India&lt;/a&gt; provides a useful starting point for comparing companies based on their expertise, services, client experience, and technical capabilities.&lt;/p&gt;
&lt;p&gt;Rather than selecting a company based solely on pricing, businesses should evaluate communication quality, project management processes, technical knowledge, and post-launch support.&lt;/p&gt;
&lt;h2 id=&quot;security-must-be-built-into-every-project&quot;&gt;&lt;strong&gt;Security Must Be Built into Every Project&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Website security is no longer optional.&lt;/p&gt;
&lt;p&gt;Cyber threats continue to become more sophisticated, affecting businesses of every size. Data breaches not only result in financial losses but can also damage customer trust.&lt;/p&gt;
&lt;p&gt;Secure web development includes validating user input, protecting sensitive information, implementing proper authentication systems, encrypting data, and performing regular security testing throughout development.&lt;/p&gt;
&lt;p&gt;Developers should also follow established industry standards instead of relying solely on custom security practices. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://owasp.org/www-project-top-ten/&quot;&gt;OWASP Top 10&lt;/a&gt; is widely recognized as one of the most trusted resources for understanding common web application vulnerabilities and implementing effective security measures.&lt;/p&gt;
&lt;p&gt;Making security part of the development process from the beginning is significantly more effective than attempting to fix vulnerabilities after launch.&lt;/p&gt;
&lt;h2 id=&quot;maintenance-is-just-as-important-as-development&quot;&gt;&lt;strong&gt;Maintenance Is Just as Important as Development&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Launching a website is only the beginning of its lifecycle.&lt;/p&gt;
&lt;p&gt;Software updates, server maintenance, security patches, feature enhancements, bug fixes, and performance improvements are all ongoing responsibilities.&lt;/p&gt;
&lt;p&gt;Organizations that neglect maintenance often experience slower websites, compatibility issues, and increased security risks over time.&lt;/p&gt;
&lt;p&gt;Regular monitoring and continuous improvements ensure the application remains stable while adapting to changing business requirements and customer expectations.&lt;/p&gt;
&lt;p&gt;Businesses should include long-term maintenance planning in their initial project budget instead of treating it as an afterthought.&lt;/p&gt;
&lt;h2 id=&quot;the-future-of-web-development&quot;&gt;&lt;strong&gt;The Future of Web Development&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Web development continues to evolve rapidly. Artificial intelligence, cloud-native applications, headless architectures, progressive web apps, and API-driven ecosystems are becoming increasingly common across industries.&lt;/p&gt;
&lt;p&gt;However, successful businesses rarely adopt every new technology simply because it is popular.&lt;/p&gt;
&lt;p&gt;Instead, they evaluate whether a technology aligns with their long-term objectives, technical resources, and customer needs.&lt;/p&gt;
&lt;p&gt;A well-planned project built on reliable technologies often delivers better results than one that constantly changes direction to follow emerging trends.&lt;/p&gt;
&lt;p&gt;By combining thoughtful planning with experienced development professionals, businesses can build websites that remain competitive for many years.&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Selecting the right web development technology requires more than comparing programming languages. It involves understanding your business goals, planning for future growth, prioritizing security, and working with developers who can build scalable, maintainable solutions.&lt;/p&gt;
&lt;p&gt;Whether you choose PHP or Python, the most successful projects are those built on careful planning rather than assumptions. Taking the time to evaluate your options, learn from trusted resources, and partner with experienced professionals will help ensure your investment continues delivering value as your business grows.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23560/how-to-choose-the-right-web-development-technology-for-long-term-business-growth</guid>
<pubDate>Mon, 27 Jul 2026 06:58:24 +0000</pubDate>
</item>
<item>
<title>PHP vs. Python: Which Programming Language Is Better for Modern Web Development?</title>
<link>https://coderlegion.com/23557/php-vs-python-which-programming-language-is-better-for-modern-web-development</link>
<description>&lt;p&gt;Choosing the right programming language is one of the most important decisions when starting a web development project. The technology you select affects everything from development speed and scalability to maintenance costs and future upgrades. Among the many options available today, PHP and Python continue to dominate the web development landscape because of their maturity, large communities, and extensive frameworks.&lt;/p&gt;
&lt;p&gt;Although both languages are capable of building powerful applications, they serve different purposes depending on project requirements. Understanding their strengths and limitations can help businesses make a smarter investment and avoid unnecessary technical challenges later.&lt;/p&gt;
&lt;h2 id=&quot;why-the-choice-of-programming-language-matters&quot;&gt;&lt;strong&gt;Why the Choice of Programming Language Matters&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;A web application is expected to do much more than simply display information. Modern businesses require secure platforms, fast loading times, seamless integrations, and the ability to scale as traffic grows. The programming language used behind the scenes plays a major role in achieving these goals.&lt;/p&gt;
&lt;p&gt;Rather than selecting a language based solely on popularity, businesses should evaluate their project requirements, expected user base, available budget, and long-term business objectives. A well-informed decision often results in lower maintenance costs and a better user experience.&lt;/p&gt;
&lt;h2 id=&quot;php-a-reliable-choice-for-web-applications&quot;&gt;&lt;strong&gt;PHP: A Reliable Choice for Web Applications&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;PHP has powered websites for more than two decades and continues to be one of the most widely used server-side programming languages. Popular content management systems such as WordPress, Drupal, and Joomla are built using PHP, making it an excellent option for content-driven websites and eCommerce stores.&lt;/p&gt;
&lt;p&gt;Modern PHP frameworks like Laravel and Symfony have significantly improved developer productivity. They include built-in features for authentication, routing, database management, and security, allowing developers to build sophisticated applications much faster than before.&lt;br&gt;
PHP is especially suitable for businesses that want to launch dynamic websites quickly while keeping hosting and development costs relatively affordable.&lt;/p&gt;
&lt;h2 id=&quot;python-built-for-innovation-and-scalability&quot;&gt;&lt;strong&gt;Python: Built for Innovation and Scalability&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Python has become one of the fastest-growing programming languages because of its simplicity and versatility. Its clean syntax allows developers to write readable code while reducing development time. Beyond traditional web development, Python has become the preferred language for artificial intelligence, machine learning, automation, and data science.&lt;/p&gt;
&lt;p&gt;Frameworks such as Django and Flask provide everything needed to develop secure and scalable web applications. Django, in particular, includes numerous built-in security features that help developers create robust applications without relying heavily on third-party packages.&lt;/p&gt;
&lt;p&gt;For startups and enterprises planning to integrate AI or advanced analytics into their products, Python offers exceptional flexibility.&lt;/p&gt;
&lt;h2 id=&quot;comparing-php-and-python&quot;&gt;&lt;strong&gt;Comparing PHP and Python&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Choosing between these two technologies depends largely on the type of application you plan to build.&lt;/p&gt;
&lt;p&gt;PHP performs exceptionally well for websites, blogs, customer portals, business applications, and online stores. It has a mature ecosystem, affordable hosting options, and a massive developer community.&lt;/p&gt;
&lt;p&gt;Python is often preferred for SaaS platforms, automation tools, AI-powered products, and applications involving large amounts of data processing. Its versatility allows developers to expand projects into areas beyond traditional web development.&lt;/p&gt;
&lt;p&gt;If you're looking for a detailed comparison of &lt;a rel=&quot;nofollow&quot; href=&quot;https://learnwoo.com/php-vs-python-web-development/&quot;&gt;PHP vs. Python&lt;/a&gt;, this guide explains their differences in performance, learning curve, frameworks, scalability, and ideal use cases, making it easier to determine which language aligns with your business goals.&lt;/p&gt;
&lt;h2 id=&quot;performance-depends-on-more-than-the-language-1&quot;&gt;&lt;strong&gt;Performance Depends on More Than the Language&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Many people ask which programming language is faster. In reality, application performance depends on several technical factors working together.&lt;/p&gt;
&lt;p&gt;Database optimization, caching strategies, server configuration, application architecture, and code quality often have a greater impact than the programming language itself.&lt;/p&gt;
&lt;p&gt;Recent PHP versions have introduced substantial performance improvements, while Python frameworks continue evolving to support asynchronous processing and better scalability. When implemented properly, both technologies can deliver excellent performance for modern web applications.&lt;/p&gt;
&lt;h2 id=&quot;development-speed-and-maintenance&quot;&gt;&lt;strong&gt;Development Speed and Maintenance&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;A shorter development cycle often means lower project costs. Python's clean syntax enables developers to write fewer lines of code, making it particularly attractive for projects with complex business logic or data processing requirements.&lt;/p&gt;
&lt;p&gt;PHP, however, has also become highly efficient through frameworks like Laravel, which simplify routine development tasks. Developers can quickly build secure applications using pre-built modules for authentication, validation, and database management.&lt;/p&gt;
&lt;p&gt;From a maintenance perspective, both languages benefit from active communities that regularly release updates, security patches, and new packages.&lt;/p&gt;
&lt;h2 id=&quot;security-best-practices&quot;&gt;&lt;strong&gt;Security Best Practices&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;No programming language is automatically secure or insecure. Security depends on how an application is designed and maintained.&lt;/p&gt;
&lt;p&gt;Developers should always implement secure coding practices, including proper input validation, encrypted passwords, prepared SQL statements, access control, and regular software updates. Fortunately, both Laravel and Django provide numerous built-in security features that help reduce common vulnerabilities.&lt;/p&gt;
&lt;p&gt;Businesses should also schedule regular security audits to ensure their applications remain protected as new threats emerge.&lt;/p&gt;
&lt;h2 id=&quot;choosing-the-right-development-team&quot;&gt;&lt;strong&gt;Choosing the Right Development Team&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Selecting the right technology is only part of the equation. The expertise of the development team has an even greater influence on the success of a project.&lt;/p&gt;
&lt;p&gt;Before hiring a development company, consider their technical experience, previous projects, communication process, and post-launch support. Reviewing case studies and client feedback can provide valuable insights into their capabilities.&lt;/p&gt;
&lt;p&gt;If you're planning a Python project, exploring this list of &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.wpwebelite.com/blog/top-python-development-companies-india/&quot;&gt;top Python Development Companies in India&lt;/a&gt; is a great starting point. It highlights experienced companies known for building scalable web applications, enterprise software, AI solutions, and custom Python-based products.&lt;/p&gt;
&lt;p&gt;A knowledgeable development partner will recommend the most suitable technology based on your business needs instead of simply following industry trends.&lt;/p&gt;
&lt;h2 id=&quot;the-future-of-php-and-python&quot;&gt;&lt;strong&gt;The Future of PHP and Python&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Both PHP and Python continue evolving alongside modern web technologies.&lt;/p&gt;
&lt;p&gt;PHP remains highly relevant for CMS platforms, business websites, customer portals, and eCommerce applications. Ongoing improvements in performance and framework capabilities ensure it remains competitive for years to come.&lt;/p&gt;
&lt;p&gt;Python's popularity continues to grow because of its expanding role in artificial intelligence, cloud computing, automation, and data analytics. Organizations developing intelligent software products often choose Python because of its extensive ecosystem and long-term flexibility.&lt;/p&gt;
&lt;p&gt;In many cases, businesses combine multiple technologies within a single application. APIs and microservices allow PHP and Python to work together efficiently, enabling organizations to take advantage of each language's strengths.&lt;/p&gt;
&lt;p&gt;For developers and business owners who want to stay updated with current web standards, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.mozilla.org/en-US/docs/Learn_web_development&quot;&gt;MDN Web Docs&lt;/a&gt; provides comprehensive guidance on modern web development practices, security, and performance optimization.&lt;/p&gt;
&lt;h2 id=&quot;conclusion-1&quot;&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;The decision between PHP and Python should be based on your project's objectives rather than on which language is considered more popular.&lt;/p&gt;
&lt;p&gt;PHP continues to be an outstanding choice for websites, CMS platforms, and eCommerce development because of its mature ecosystem and cost-effective deployment. Python stands out for applications involving automation, artificial intelligence, machine learning, and advanced data processing.&lt;/p&gt;
&lt;p&gt;By understanding the strengths of each language and partnering with experienced developers, businesses can build scalable applications that support both current requirements and future growth.&lt;/p&gt;
&lt;h2 id=&quot;frequently-asked-questions&quot;&gt;&lt;strong&gt;Frequently Asked Questions&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Is PHP still a good choice in 2026?&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Yes. PHP remains one of the most widely used server-side programming languages and continues to power millions of websites and business applications worldwide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why do companies choose Python for web development?&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Python offers clean syntax, rapid development, excellent scalability, and strong support for AI, machine learning, and automation, making it ideal for modern software products.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Which language is easier for beginners?&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Python is generally considered easier because of its simple and readable syntax. However, PHP is also beginner-friendly, especially for developers interested in building websites.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Can PHP and Python be used together?&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Absolutely. Many organizations use PHP for their web interface while leveraging Python for data processing, AI models, automation, or analytics through APIs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;How should businesses choose between PHP and Python?&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Businesses should evaluate their project requirements, expected scalability, budget, technical goals, and long-term maintenance strategy before making a decision. Working with an experienced development partner can also help identify the most suitable technology stack.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23557/php-vs-python-which-programming-language-is-better-for-modern-web-development</guid>
<pubDate>Mon, 27 Jul 2026 06:38:27 +0000</pubDate>
</item>
<item>
<title>Creating Custom Elementor Widgets: A Developer's Guide</title>
<link>https://coderlegion.com/23529/creating-custom-elementor-widgets-a-developers-guide</link>
<description>&lt;p&gt;Actual PHP structure for building custom Elementor widgets — registration, controls, rendering — for when Elementor's stock widgets aren't enough.&lt;/p&gt;
&lt;p&gt;Covers the widget class structure, adding editable controls (text, color, repeaters), proper output escaping, and common mistakes (skipping escaping, not namespacing classes, building one giant widget instead of focused ones).&lt;/p&gt;
&lt;p&gt;Also flags something that trips a lot of people up: Elementor's widget registration hook name has changed across versions — always check current docs before building on a live client site.&lt;/p&gt;
&lt;p&gt;Full writeup: &lt;a rel=&quot;nofollow&quot; href=&quot;https://amanurrahman.com/blog-post/custom-elementor-widgets-guide&quot;&gt;https://amanurrahman.com/blog-post/custom-elementor-widgets-guide&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23529/creating-custom-elementor-widgets-a-developers-guide</guid>
<pubDate>Sun, 26 Jul 2026 16:39:42 +0000</pubDate>
</item>
<item>
<title>Arabic search in WordPress: the matches it silently misses</title>
<link>https://coderlegion.com/23415/arabic-search-in-wordpress-the-matches-it-silently-misses</link>
<description>&lt;p&gt;Two hundred and fifty thousand articles, and the thing editors complained about was the search box. Not the accuracy of it. The wait. Type a name into the newsroom's admin search, go and do something else, come back.&lt;/p&gt;
&lt;p&gt;That is the easy complaint, because it arrives. Someone tells you. What never arrives is the other one, and it took me far too long to notice it was missing: nobody has ever told me the search came back empty for a story we definitely published. Nobody ever will. A search that returns nothing does not look like a bug. It looks like proof you never covered it.&lt;/p&gt;
&lt;p&gt;So we fixed the loud one, and left the quiet one running for years.&lt;/p&gt;
&lt;p&gt;It is the same seam as &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;an earlier piece on Arabic slugs&lt;/a&gt;: Arabic quietly breaking an assumption WordPress never knew it was making. Last time it was the URL. This time it is the search box.&lt;/p&gt;
&lt;h3 id=&quot;what-wordpress-actually-does-with-an-arabic-query&quot;&gt;What WordPress actually does with an Arabic query&lt;/h3&gt;
&lt;p&gt;Type a term into WordPress search and &lt;code&gt;WP_Query&lt;/code&gt; builds this, near enough:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WHERE post_title   LIKE '%term%'
   OR post_excerpt LIKE '%term%'
   OR post_content LIKE '%term%'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the whole mechanism. Not a search engine, not an index, a substring test run three times per row. It knows nothing about words, and nothing at all about Arabic.&lt;/p&gt;
&lt;p&gt;We measured what that means on a throwaway WordPress 7.0.2 and MySQL 8.0.35: one post per spelling, then the obvious searches.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;you search &lt;/th&gt;&lt;th&gt;you get back&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;محمد &lt;/td&gt;&lt;td&gt;only the plain post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;مُحَمَّد (with tashkeel) &lt;/td&gt;&lt;td&gt;only the tashkeel post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;محمـــد (with tatweel) &lt;/td&gt;&lt;td&gt;only the tatweel post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;أحمد (hamza alef) &lt;/td&gt;&lt;td&gt;only the hamza post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;احمد (bare alef) &lt;/td&gt;&lt;td&gt;only the bare alef post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;مصطفى (alef maqsura) &lt;/td&gt;&lt;td&gt;only the maqsura post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;مصطفي (yaa) &lt;/td&gt;&lt;td&gt;only the yaa post&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;مكتبه (haa for taa marbuta) &lt;/td&gt;&lt;td&gt;nothing at all&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Read that right-hand column again. Every search returns only its own exact spelling, and the last returns nothing at all, even though every term on the left is the same word to a human being. A reader who types a name without diacritics never sees the article that has them. Someone who writes أحمد never finds احمد. Someone who ends a word with ه instead of ة gets an empty page.&lt;/p&gt;
&lt;p&gt;The one result that looks like a success is the exception that proves the point. Searching كتاب does return الكتاب. That is not the search understanding the definite article, it is the letters ك ت ا ب happening to sit inside الكتاب as a literal substring. Search وكتاب, the same word with a prefixed و, and you get nothing, because the bare word is no longer embedded in it.&lt;/p&gt;
&lt;p&gt;The admin post list behaves identically to the front end, which is the part that matters, because that is where your editors live.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-arabic-search-diagram-1.png&quot; alt=&quot;One word, many spellings: why LIKE misses and normalising matches&quot;&gt;&lt;br&gt;
&lt;em&gt;The same word, written three ways, and a reader means one thing by all of them. WordPress compares raw bytes with LIKE, so the three stay three different strings and none finds the others. Fold them with the same normaliser and they collapse to a single token that matches every time.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-database-already-knows-wordpress-never-asks-it-&quot;&gt;The database already knows. WordPress never asks it.&lt;/h3&gt;
&lt;p&gt;This is the part that genuinely surprised me.&lt;/p&gt;
&lt;p&gt;WordPress does not inherit your server's collation. It forces its own onto its tables, &lt;code&gt;utf8mb4_unicode_520_ci&lt;/code&gt;, whatever MySQL's default happens to be. And that collation is not naive about Arabic. Ask MySQL directly, with an equality test:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT 'مُحَمَّد' = 'محمد' COLLATE utf8mb4_unicode_520_ci;   -- 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One. True. The collation folds the diacritics away and says these are the same string. On MySQL 8's &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt; it folds tatweel as well.&lt;/p&gt;
&lt;p&gt;Now ask it the way WordPress asks it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT 'مُحَمَّد' LIKE '%محمد%' COLLATE utf8mb4_unicode_520_ci;   -- 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Zero. Same data, same collation, same server, opposite answer. Wildcard pattern matching does not apply the collation's character folding, so every piece of Arabic knowledge sitting in your database is thrown away the moment you wrap the term in &lt;code&gt;%&lt;/code&gt;. We tested four collations. Under &lt;code&gt;=&lt;/code&gt;, the good ones fold. Under &lt;code&gt;LIKE '%…%'&lt;/code&gt;, all four return zero.&lt;/p&gt;
&lt;p&gt;The capability was already in the building. The search architecture walks straight past it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-arabic-search-diagram-2.png&quot; alt=&quot;The collation folds Arabic under = but not under LIKE&quot;&gt;&lt;br&gt;
&lt;em&gt;The identical pair, the diacritic-marked spelling and the plain one, under WordPress's own collation. Compared with =, the collation folds the tashkeel away and returns 1: they are equal. Compared with LIKE and a wildcard, that folding is skipped and it returns 0. Same data, same collation, opposite answer, and WordPress search asks with LIKE.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;even-reaching-it-would-not-be-enough&quot;&gt;Even reaching it would not be enough&lt;/h3&gt;
&lt;p&gt;It is tempting to stop here and go looking for a way to make WordPress compare with &lt;code&gt;=&lt;/code&gt;. It would not save you, and the measurements show why.&lt;/p&gt;
&lt;p&gt;A collation folds &lt;strong&gt;accents&lt;/strong&gt;. Tashkeel are accents, combining marks that sit on top of a letter, which is exactly why they fold.&lt;/p&gt;
&lt;p&gt;Hamza is not an accent. أ and ا are different letters. So are ى and ي. So are ة and ه. None of them fold, in any collation we tested, under any comparison. And these are not edge cases in Arabic, they are the ordinary daily variation: the same name spelled أحمد by one editor and احمد by another, the same word ending ى in one headline and ي in the next.&lt;/p&gt;
&lt;p&gt;So the database will hand you diacritics for free if you ask it correctly, and it will never hand you the rest. Anything that actually works has to normalise the text itself, deliberately, on both sides of the comparison.&lt;/p&gt;
&lt;h3 id=&quot;what-it-costs-and-why-the-broken-searches-are-the-expensive-ones&quot;&gt;What it costs, and why the broken searches are the expensive ones&lt;/h3&gt;
&lt;p&gt;The other half of this is the half your editors do complain about.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;LIKE&lt;/code&gt; with a leading wildcard cannot use an index. Not &quot;is slow to use one&quot;, cannot use one, ever. &lt;code&gt;EXPLAIN&lt;/code&gt; on the query WordPress generates says it plainly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type: ALL     key: NULL     rows: 50013
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full scan, no key, every row. We seeded 50,000 realistic Arabic posts and timed it with a fresh PHP process per run, because repeating it inside one process makes WordPress's in-request cache report a fraction of a millisecond, which is a lie:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a term matching one post: about 215 ms&lt;/li&gt;
&lt;li&gt;a term matching nothing: about 225 ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The search that finds nothing is the slowest, and that is not a rounding artefact. &lt;code&gt;LIKE '%term%'&lt;/code&gt; gives up on a row the instant it finds the substring, so a row that matches is cheap: often the title matches and the long body is never read at all. A row that does &lt;strong&gt;not&lt;/strong&gt; match has to be read to the last byte of every field before MySQL can be certain the word is absent. Proving absence is the expensive case. And in Arabic, the searches that come back empty are precisely the variant spellings in the table above.&lt;/p&gt;
&lt;p&gt;You might expect &lt;code&gt;LIMIT 10&lt;/code&gt; to rescue this. It cannot, and MySQL says so in plain words. WordPress needs a total for pagination, so it asks for &lt;code&gt;SQL_CALC_FOUND_ROWS&lt;/code&gt;, and the query plan reports the consequence itself:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Limit: 10 row(s) (no early end due to SQL_CALC_FOUND_ROWS)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ten results on the page, every matching row enumerated to produce them.&lt;/p&gt;
&lt;p&gt;The scan cost tracks table size, so a 250,000-article archive lands somewhere around a second per search, per concurrent searcher, with no index able to help. (That figure is an extrapolation from the 50k measurement, not a measurement of a 250k table.)&lt;/p&gt;
&lt;p&gt;Now put the two halves together. The searches that silently return nothing are the same searches that scan your entire archive in order to return nothing. In Arabic, those are every variant spelling your readers and editors actually type. The complaint you get and the complaint you never get are the same query.&lt;/p&gt;
&lt;h3 id=&quot;what-we-built-and-what-it-fixed&quot;&gt;What we built, and what it fixed&lt;/h3&gt;
&lt;p&gt;We did the obvious right thing, and it worked.&lt;/p&gt;
&lt;p&gt;We built a table outside WordPress, on AWS, one row per article: id, title, body, author, categories. Every time a post was published, updated or deleted, that row was kept in step. Search stopped asking the WordPress database to scan a quarter of a million rows and started asking a store built for the job. Public search had already been handed off to a Google Custom Search Engine, so between the two, search stopped being the thing anyone raised in a meeting.&lt;/p&gt;
&lt;p&gt;(That table later grew into something much larger, a recommendation engine that learns what a reader is interested in and chooses their next article. That is a story for another day.)&lt;/p&gt;
&lt;p&gt;By every measure we were watching, we had fixed search.&lt;/p&gt;
&lt;h3 id=&quot;what-it-did-not-fix&quot;&gt;What it did not fix&lt;/h3&gt;
&lt;p&gt;We never normalised the Arabic.&lt;/p&gt;
&lt;p&gt;The new table was faster. It was still storing and matching the same raw strings, with the same orthographic variation, in the same literal way. A reader typing محمد still missed مُحَمَّد. أحمد still missed احمد. مكتبه still found nothing. We had rebuilt the entire search path, moved it to another cloud, and put a proper store underneath it, and every one of those searches failed exactly as it had before.&lt;/p&gt;
&lt;p&gt;That is the lesson, and it took us years to see it: &lt;strong&gt;the speed problem was an infrastructure problem, and the matching problem never was.&lt;/strong&gt; Scale, indexes and a store designed for querying will fix a scan. None of them will turn two spellings of the same word into the same token. Normalisation is a text problem, and if you do not solve it, it follows you to AWS.&lt;/p&gt;
&lt;p&gt;And because nobody ever complains about a search that returns nothing, nothing ever forced the issue. The loud problem got an entire re-architecture. The quiet one just carried on.&lt;/p&gt;
&lt;h3 id=&quot;the-layer-we-were-missing&quot;&gt;The layer we were missing&lt;/h3&gt;
&lt;p&gt;The fix is small, and it is the same fix wherever your index lives.&lt;/p&gt;
&lt;p&gt;Normalise both sides. Keep a normalised copy of the searchable text, index that, and put every incoming query through the identical normaliser before matching. Both halves matter: normalise only the stored text and a query carrying diacritics still misses, normalise only the query and it misses the stored variants.&lt;/p&gt;
&lt;p&gt;What the normaliser has to do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strip the diacritics.&lt;/strong&gt; Tashkeel are combining marks (U+064B to U+0652 and friends). Remove them entirely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strip tatweel&lt;/strong&gt; (U+0640). It is decoration and carries no meaning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unify the alef forms.&lt;/strong&gt; أ إ آ ٱ all become ا.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unify ى to ي&lt;/strong&gt;, and decide about &lt;strong&gt;ة to ه&lt;/strong&gt;. These are conventions, not truths. Pick one, apply it to both sides, write it down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strip the definite article&lt;/strong&gt; الـ, so الكتاب and كتاب fold to one token. Guard it, so it only comes off when enough of the word is left to still be a word, otherwise ال on the front of a short word gets eaten too. And stop there. It is tempting to strip the single-letter prefixes و ف ب ك ل as well, but those letters begin ordinary words, so بيت would lose its ب and فكرة would become كرة, a genuinely different word. So وكتاب will not fold to كتاب, and that conservative choice is the correct one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leave Latin alone.&lt;/strong&gt; Newsroom copy is full of WordPress, AWS, S3 and half-transliterated terms. A normaliser that mangles those breaks more than it fixes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Do that and the table from earlier collapses into a single row: every spelling of محمد, however it was typed, becomes one token that matches all the others. And because the normalised copy is matched through an index instead of a leading-wildcard scan, the query that used to take a second stops scanning the table at all.&lt;/p&gt;
&lt;p&gt;And none of this needs a cloud. The story above happened to run through a table we put on AWS, but that was our answer to scale, not to matching. On an ordinary single-server WordPress, the same normalised copy in the same database, with the query folded the same way, fixes both the misses and the scan with nothing external at all. That is the layer we were missing, and it would have been missing just as badly on one server as on twenty.&lt;/p&gt;
&lt;p&gt;One change, both complaints.&lt;/p&gt;
&lt;h3 id=&quot;the-plugin&quot;&gt;The plugin&lt;/h3&gt;
&lt;p&gt;The whole fix is one file. It goes in &lt;code&gt;wp-content/mu-plugins/&lt;/code&gt;, and in its default mode it needs nothing else: no cloud, no service, no configuration.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;composer require mantekio/wp-arabic-search
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;One catch: Composer installs it in a subfolder, &lt;code&gt;mu-plugins/wp-arabic-search/&lt;/code&gt;, and WordPress only loads files sitting directly in &lt;code&gt;mu-plugins/&lt;/code&gt;. So on a standard WordPress site Composer reports success and the plugin never runs. Stacks like Bedrock ship an autoloader that handles this; otherwise add the one-line loader from &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-arabic-search#install&quot;&gt;the README&lt;/a&gt;, or copy the plugin file into &lt;code&gt;mu-plugins/&lt;/code&gt; by hand instead. Not both: loading it twice is a fatal error.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;On save it folds everything searchable about a post, title, excerpt and body, into one normalised column in a shadow table with a real &lt;code&gt;FULLTEXT&lt;/code&gt; index, and rewrites WordPress search to match against that column through the identical normaliser. The public site and the wp-admin post list both run through &lt;code&gt;WP_Query&lt;/code&gt;, so both are fixed at once. Build the index over what you already have with one command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp arabic-search reindex
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Media gets the same treatment, which turned out to matter more than expected. An attachment keeps its text in four places: the title, the caption, the alt text and the filename. All four go into the same column, so a picture captioned in Arabic is findable by that caption, and &lt;code&gt;صورة-بيروت.jpg&lt;/code&gt; is findable by its own name. WordPress does not search alt text at all, so on that one the plugin is not restoring parity, it is adding something core never had. This changes what the Media Library can find, not what appears anywhere else: the plugin decides which rows &lt;em&gt;match the text&lt;/em&gt;, and WordPress still decides which post types and statuses are allowed in the results.&lt;/p&gt;
&lt;p&gt;It ships with two modes, because the normaliser is the reusable part and the index is not. The default, &lt;code&gt;index&lt;/code&gt;, is the self-contained one just described. The other, &lt;code&gt;normaliser&lt;/code&gt;, maintains nothing and rewrites nothing: it exposes the normalising function and fires an action carrying the normalised text on every save, so a pipeline that indexes somewhere else (the AWS table we built, OpenSearch, anything) can fold its stored text and its queries with the same code. That is the guarantee that actually matters, and it is the one we did not have for years: both sides normalised by identical code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;define( 'WPAS_MODE', 'normaliser' );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing it deliberately refuses to do. InnoDB does not index words shorter than three characters, which in Arabic covers من, في, ما and عن: real words, typed constantly. An index that has never seen a word cannot find it, so requiring one would return an empty page for a search plain WordPress answers perfectly well. That is exactly the failure this article is about, and it would have been ours. So the plugin drops those tokens rather than requiring them, and where a search contains nothing else it hands the query back to core's &lt;code&gt;LIKE&lt;/code&gt; untouched. Search a two-letter word and you get precisely what WordPress would have given you; search two words where only one clears the threshold and the long one still matches. The rule it follows is that where it cannot help, it stands aside rather than answer worse than the search it replaced. There is nothing to configure for this, and no reason to go changing &lt;code&gt;innodb_ft_min_token_size&lt;/code&gt; on your server: it needs a MySQL restart and a rebuild of every full-text index on the box, and it buys you nothing here.&lt;/p&gt;
&lt;p&gt;Which raises the question any search tool eventually gets asked, usually by an editor who is certain the article exists: why did this not match? Answering it used to mean reading the source, so there is now a command that just tells you.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ wp arabic-search explain &quot;من المكتبة&quot;
normalised:  من مكتبه

+--------+------+--------------------------------------------------+
| token  | used | reason                                           |
+--------+------+--------------------------------------------------+
| من     | no   | shorter than innodb_ft_min_token_size (3)        |
| مكتبه  | yes  | indexable                                        |
+--------+------+--------------------------------------------------+

matches with: MATCH(search_text) AGAINST ('+مكتبه' IN BOOLEAN MODE)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The folded form, every token with whether it was used and why not, and the exact query that runs. It is the smallest feature in the plugin and the one I would miss most, because &quot;search is broken&quot; and &quot;search is working exactly as designed on a word the index cannot hold&quot; look identical from the outside, and this is the difference between them.&lt;/p&gt;
&lt;p&gt;We eventually ran it against the real thing: a quarter of a million Arabic articles, plus half a million media items, on a copy of a live newsroom archive. One search settles the whole argument.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;searching مُحَمَّد across 250,000 articles &lt;/th&gt;&lt;th&gt;results &lt;/th&gt;&lt;th&gt;time&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;WordPress &lt;/td&gt;&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;4.8 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;with the plugin &lt;/td&gt;&lt;td&gt;&lt;strong&gt;28,238&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;0.55 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;One. Out of twenty-eight thousand articles that contain that name, plain WordPress found a single one, because only one of them happened to spell it with exactly those diacritics. The other 28,237 were in the database the whole time, and no one searching for that name would ever have seen them.&lt;/p&gt;
&lt;p&gt;The speed is the part you would have put in the ticket. The other column is the part nobody was ever going to report.&lt;/p&gt;
&lt;p&gt;The rest of the numbers come from a controlled 50,000-post rig, where the match count can be dialled precisely, before and after:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;matches, out of 50,000 &lt;/th&gt;&lt;th&gt;default WordPress &lt;/th&gt;&lt;th&gt;with the plugin&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;about 215 ms &lt;/td&gt;&lt;td&gt;5 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;500 (1%) &lt;/td&gt;&lt;td&gt;about 212 ms &lt;/td&gt;&lt;td&gt;10 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5,000 (10%) &lt;/td&gt;&lt;td&gt;about 230 ms &lt;/td&gt;&lt;td&gt;24 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;25,000 (50%) &lt;/td&gt;&lt;td&gt;about 250 ms &lt;/td&gt;&lt;td&gt;115 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;50,000 (100%) &lt;/td&gt;&lt;td&gt;about 310 ms &lt;/td&gt;&lt;td&gt;about 210 ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; tells the same story as the clock. Before, every search was the full scan:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type: ALL        key: NULL        rows: 50013
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After, the index drives it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type: fulltext   key: search_ft   rows: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We had that bottom row wrong for a while, and the way it was wrong is worth repeating, because it is a trap set for anyone who benchmarks a text search. Our first measurement said the index &lt;strong&gt;lost&lt;/strong&gt; on a term matching everything. It did not. The word we had seeded happened to sit near the &lt;em&gt;start&lt;/em&gt; of every document, and since &lt;code&gt;LIKE&lt;/code&gt; abandons a row the moment it finds a match, we were timing the scan at its luckiest: first few bytes, done, next row. Move the same word to the end of the same documents, and the result inverts.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;a term matching all 50,000 rows &lt;/th&gt;&lt;th&gt;default WordPress &lt;/th&gt;&lt;th&gt;with the plugin&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;the word sits at the start of every document &lt;/td&gt;&lt;td&gt;140 ms &lt;/td&gt;&lt;td&gt;208 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;the word sits at the end of every document &lt;/td&gt;&lt;td&gt;300 ms &lt;/td&gt;&lt;td&gt;205 ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Read the plugin column first: it barely moves, because an index does not care where in the text the word happens to sit. Now read the other one, which doubles. We had benchmarked the scan's single luckiest case and called it a fair fight.&lt;/p&gt;
&lt;p&gt;So the honest shape is a crossover, not a caveat. Against a term the scan actually has to read, the index wins across the entire range in the table above. Against a term positioned for the scan's best possible case, the index still wins until roughly &lt;strong&gt;70%&lt;/strong&gt; of the archive matches. A query that matches seventy per cent of a newsroom archive is not a search, it is a &lt;code&gt;SELECT *&lt;/code&gt; with extra steps.&lt;/p&gt;
&lt;p&gt;And the case that used to hurt most is now the cheapest. The old worst case was the search that came back empty, because proving absence meant reading every byte of every row. An index that finds nothing has nothing to read.&lt;/p&gt;
&lt;p&gt;One honest word on where it is in its life. It is &lt;strong&gt;new&lt;/strong&gt;, and it ships as &lt;code&gt;0.9.3&lt;/code&gt;. That it is already on its fourth release says something worth saying plainly: six rounds of adversarial testing found a bug every single time, and not one of them was found by reading the code.&lt;/p&gt;
&lt;p&gt;The worst was in the first release. Search a two-letter Arabic word, من or في, and it returned nothing at all. The check meant to drop words too short for the index counted &lt;em&gt;bytes&lt;/em&gt;, and an Arabic letter is two bytes, so every one of those words sailed through and became a required term the index had never held. A plugin whose entire argument is that Arabic search silently returns nothing had shipped a way to make Arabic search silently return nothing. Others followed: a health check that could not detect the condition it existed to detect, and a reindex command that announced success having written not one row.&lt;/p&gt;
&lt;p&gt;They are all fixed, and every fix is in the changelog, but the pattern is the point. Each one was invisible from the code and obvious the moment something actually ran it. If this piece has a moral it is that one, and it applies to your search too: the failure that returns nothing looks exactly like the absence of anything to find.&lt;/p&gt;
&lt;p&gt;What it has not yet had is months in a live newsroom, so it is out in the open under the GPL, on GitHub and Packagist, being hardened in daylight. The repository is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-arabic-search&quot;&gt;github.com/mantekio/wp-arabic-search&lt;/a&gt;. Same loop as the pieces before it: the article explains the thinking, the repository is the plugin, the package is the one-line install, and each points at the other two.&lt;/p&gt;
&lt;h3 id=&quot;known-limits&quot;&gt;Known limits&lt;/h3&gt;
&lt;p&gt;Being clear about what a tool does not do is part of the tool.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single-letter prefixes are deliberately not stripped.&lt;/strong&gt; Folding the definite article is safe behind a length guard; folding و ف ب ك ل is not, because they also start ordinary words, so it would turn بيت into يت and فكرة into كرة. The cost of that safety is that وكتاب does not fold to كتاب. Real prefix handling needs morphology, not a character map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Folding is not stemming.&lt;/strong&gt; It will not connect كتاب to its broken plural كتب, because Arabic plurals rewrite the word rather than append to it. That needs a root dictionary, and that is a different project.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The results are only as complete as the index.&lt;/strong&gt; Nothing walks your archive when you install it, so run &lt;code&gt;wp arabic-search reindex&lt;/code&gt; once. After that each save keeps its own row current, and &lt;code&gt;wp arabic-search status&lt;/code&gt; exits non-zero the moment the index falls behind, goes stale, or carries rows for posts that no longer exist. Drift is something you get told about rather than something a reader discovers for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It costs disk, inside the database.&lt;/strong&gt; A second normalised copy of your searchable text is still a copy. On that quarter-million-article archive the table came to about 1.1 GB, roughly 3 KB per article, though the &lt;code&gt;FULLTEXT&lt;/code&gt; index on top of it was only 17 MB. Media is nearly free: attachments were two thirds of the rows and a sixteenth of the bytes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Words the index cannot hold are correct, not fast.&lt;/strong&gt; A search that falls back gets core's behaviour entirely, including core's speed, because it is core's query. On that same archive a two-letter word took about three seconds, exactly as it always had, while an indexed search of the same corpus took well under one. Nothing is worse than before. Nothing is better either.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Normalisation is lossy on purpose.&lt;/strong&gt; Folding ة into ه, or ى into ي, will occasionally merge two words that genuinely differ. In an archive search that trade is nearly always worth it, but it is a trade: decide it deliberately and document it, rather than discovering it later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Past a certain size, use a real search engine.&lt;/strong&gt; OpenSearch ships an &lt;code&gt;arabic&lt;/code&gt; analyser that does normalisation, stemming and stopwords properly, and gives you relevance ranking a &lt;code&gt;LIKE&lt;/code&gt; will never have. If the archive is large and search matters, that is the destination. You still need the normaliser, because the same text problem travels with you. Ours did.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;A quarter of a million articles, and the only thing anyone ever told us about search was that it was slow. So we made it fast. We built the table, moved the queries to AWS, kept it in sync on every post, the complaints stopped, and for a long time we took that to mean we had finished.&lt;/p&gt;
&lt;p&gt;The searches that returned nothing never produced a single complaint, because a search that returns nothing looks exactly like a subject you never covered. The archive was full of articles nobody could find, and the silence around them read as success.&lt;/p&gt;
&lt;p&gt;That is the part worth keeping, whatever your stack: complaints tell you about the loud failures and nothing at all about the quiet ones. Someone has to go and look.&lt;/p&gt;
&lt;p&gt;That is how we run &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;WordPress on AWS&lt;/a&gt; for newsrooms: fix what people report, then go hunting for what they cannot. If your archive is in Arabic and you have never tested what your search does with a hamza, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/contact&quot;&gt;let's talk&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23415/arabic-search-in-wordpress-the-matches-it-silently-misses</guid>
<pubDate>Fri, 24 Jul 2026 23:41:33 +0000</pubDate>
</item>
<item>
<title>WordPress cron behind a load balancer: fixing 'Missed schedule'</title>
<link>https://coderlegion.com/23273/wordpress-cron-behind-a-load-balancer-fixing-missed-schedule</link>
<description>&lt;p&gt;A newsroom schedules its lead story for six in the morning. At 6:05 it is not on the homepage. Nobody did anything wrong: the post was written, scheduled, saved, and its publish time has come and gone. There is no error, no failed job, nothing in the log. The post just sits in the admin list with a small grey label beside it, &lt;strong&gt;Missed schedule&lt;/strong&gt;, as though it had quietly decided not to happen.&lt;/p&gt;
&lt;p&gt;This is the worst kind of bug, the completely silent kind. And on WordPress spread across more than one server, it is barely a bug at all. It is what the default setup does under load, and the fix nearly everyone reaches for makes it worse, in a way that is genuinely hard to see.&lt;/p&gt;
&lt;h3 id=&quot;wp-cron-is-not-cron&quot;&gt;wp-cron is not cron&lt;/h3&gt;
&lt;p&gt;The name lies. &lt;code&gt;wp-cron&lt;/code&gt; is not a system scheduler. On a default install, WordPress checks for due jobs on a normal page load: near the end of a request it fires a non-blocking loopback call to &lt;code&gt;wp-cron.php&lt;/code&gt;, which runs whatever is due. No visitors, nothing runs. A quiet site can sit for hours with nothing firing; a busy one runs its cron on the back of a random reader's page view.&lt;/p&gt;
&lt;p&gt;On one server that is merely untidy. Put it behind a load balancer and it starts to fail in ways you cannot see. The loopback goes to your own domain, which resolves to the load balancer, which hands it to some instance, not necessarily the one that fired it. If an instance cannot reach the public hostname, the loopback silently does nothing. You now have a scheduler whose reliability depends on reader traffic and internal networking, for jobs like publishing the morning's lead story.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-everyone-gives-you-and-the-one-it-hides&quot;&gt;The fix everyone gives you, and the one it hides&lt;/h3&gt;
&lt;p&gt;Search this and every answer is the same two lines: switch the loopback off, and drive cron yourself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;define( 'DISABLE_WP_CRON', true );
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;* * * * * wget -q -O - https://www.example.com/wp-cron.php?doing_wp_cron &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We ran exactly that. Then, because a crontab lives on one machine and our machines come and go, we moved the trigger to a Lambda that hit the site every minute, so nothing depended on a single box staying alive. It looked correct. The request went out every minute, on schedule, forever.&lt;/p&gt;
&lt;p&gt;And posts kept coming up &lt;strong&gt;Missed schedule&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-cron-load-balancer-diagram-1.png&quot; alt=&quot;Two ways to trigger cron, and why the HTTP one keeps missing&quot;&gt;&lt;br&gt;
&lt;em&gt;Both rows want the same thing: the due events run on time. The top row triggers cron with an HTTP request, so it inherits everything sitting in front of the origin, a cache that can answer the same fixed URL without ever running PHP, a load balancer that can hand it to a draining node, and a web request that dies on a stuck lock or a mid-run recycle. The bottom row runs cron in-process with WP-CLI, so none of that is in the path.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The mistake is buried in that one-liner, and it is the same mistake whether a crontab or a Lambda fires it: we were still triggering cron with an HTTP request. Everything wrong with that survives the move to Lambda, because it has nothing to do with who makes the request and everything to do with the request itself.&lt;/p&gt;
&lt;p&gt;Follow the path it takes. It hits your public URL, so it meets your CDN first. Our trigger was one fixed URL, &lt;code&gt;wp-cron.php?doing_wp_cron&lt;/code&gt;, the same string every minute, which is precisely what a cache exists to answer without troubling the origin. So a good share of those tidy every-minute hits were served from cache as an empty 200, and PHP never ran. The dashboards were green. Cron was not running.&lt;/p&gt;
&lt;p&gt;Past the cache it meets the load balancer, which sends it to whichever instance it likes, including one that is draining. If it does reach a live node, &lt;code&gt;wp-cron.php&lt;/code&gt; runs as an ordinary web request, under the web server's timeout, holding a lock other requests respect for sixty seconds. Let one heavy job overrun that lock, or let the instance recycle mid-run, and cron wedges or dies with the queue half done. The &lt;code&gt;publish_future_post&lt;/code&gt; event for the 6am story is somewhere in that unfinished tail.&lt;/p&gt;
&lt;p&gt;None of it surfaces as an error. The trigger fires, something returns 200, and the post sits at Missed schedule until an editor asks why.&lt;/p&gt;
&lt;h3 id=&quot;stop-hitting-a-url&quot;&gt;Stop hitting a URL&lt;/h3&gt;
&lt;p&gt;Here is the turn that fixed it, and it is smaller than it sounds: the reliable way to run cron makes no HTTP request at all.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp cron event run --due-now
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WP-CLI boots WordPress in a normal PHP process on the machine and runs the due events straight against the database. No page, so no CDN to cache it. No loopback, so no load balancer to misroute it. It runs in the CLI, which has no web timeout, so a slow job finishes instead of being cut off. Every fragile link in the HTTP path is simply not in this one.&lt;/p&gt;
&lt;p&gt;That is the whole distance between &quot;keep hitting the server so cron stays alive&quot; and &quot;run cron&quot;. Hitting a page keeps it triggered. It does not keep it finishing, on time, and finishing is the only thing a scheduled post cares about.&lt;/p&gt;
&lt;p&gt;Which leaves one question, the one that turns this from a one-liner into a fleet problem: if cron now runs in-process on a machine, which machine?&lt;/p&gt;
&lt;h3 id=&quot;which-machine-&quot;&gt;Which machine?&lt;/h3&gt;
&lt;p&gt;Cron now runs in-process, which means it runs on whichever machine the crontab sits on. In a fleet, that innocent question is the whole problem, because there are only two obvious answers and both are wrong.&lt;/p&gt;
&lt;p&gt;Pin it to one machine and you are back where you started. That machine is cattle: a deploy replaces it, a scale-in retires it, a health check recycles it, and your one crontab leaves with it. Cron stops, silently, and the 6am story misses again. A single runner is a single point of failure wearing a tidier hat.&lt;/p&gt;
&lt;p&gt;So put it on every machine, bake it into the image, and let every node run cron. Now the opposite breaks. &lt;code&gt;wp cron event run&lt;/code&gt; on each node executes the due events locally, against the shared database, in the same minute. The event that publishes the lead story fires on all of them. Readers get the newsletter three times, the post publishes and re-publishes, the feed importer runs in triplicate. The transient lock core uses for the loopback does not save you here, because each CLI process is its own world and, unless your object cache is shared, they cannot even see each other's lock.&lt;/p&gt;
&lt;h3 id=&quot;run-everywhere-execute-once&quot;&gt;Run everywhere, execute once&lt;/h3&gt;
&lt;p&gt;The answer is to keep cron on every node, so there is no single box to lose, and put one shared lock in front of the run, so only one node does the work each minute.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-cron-load-balancer-diagram-2.png&quot; alt=&quot;One runner, elected by a shared lock&quot;&gt;&lt;br&gt;
&lt;em&gt;Every instance runs the trigger, so there is no single box to lose, but each run first tries to take a shared lock (a MySQL advisory lock, or a Redis key), and only the winner executes the due events. The runner records each successful pass, so a total stall is loud instead of silent.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Every node's crontab calls the same command every minute. The first thing it does is ask for a lock. Exactly one node gets it; the rest are turned away in a millisecond and exit having done nothing. The winner runs the due events and releases the lock. Next minute the race is run again, and it does not matter who wins.&lt;/p&gt;
&lt;p&gt;For the lock you already have the one shared thing you need: the database. Every instance talks to the same MySQL, so a MySQL advisory lock is a coordinator with nothing new to deploy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT GET_LOCK('wp_fleet_cron', 0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ask for it with a zero-second timeout, run cron only if you got it, release it when you are done. There is a second reason to prefer this over the transient lock WordPress uses: an advisory lock lives on the database connection, so if the runner is killed mid-job, the connection closes and the lock releases itself. The stuck-lock failure that wedges the transient one for sixty seconds cannot happen here. If you run Redis across the fleet, a &lt;code&gt;SET key value NX PX&lt;/code&gt; key does the same job; the point is that the lock is shared, not which store holds it.&lt;/p&gt;
&lt;h3 id=&quot;make-the-silence-loud&quot;&gt;Make the silence loud&lt;/h3&gt;
&lt;p&gt;One thing is still missing, and it is the thing that started all this. Elected and locked, cron runs correctly, right up until the day it does not: a bad deploy, a lock a broken database will not grant, a crontab that quietly stopped being installed. A scheduler that fails silently is the whole reason we are here.&lt;/p&gt;
&lt;p&gt;So the runner writes down every successful pass, the time and the node that did it. That one record turns the unanswerable question, &quot;is cron running?&quot;, into a check anything can make:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp fleet-cron status || send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It exits non-zero the moment the last run is older than it should be, so an outage pages you before an editor finds it. The missed 6am story stops being a mystery someone stumbles on and becomes an alert you already handled. It is the same rule that runs through &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-email-ses&quot;&gt;our WordPress mail plugin&lt;/a&gt;: a failure you cannot see is worse than one that shouts.&lt;/p&gt;
&lt;h3 id=&quot;the-plugin-1&quot;&gt;The plugin&lt;/h3&gt;
&lt;p&gt;It is one file. Drop it into &lt;code&gt;wp-content/mu-plugins/&lt;/code&gt;, add one line to &lt;code&gt;wp-config.php&lt;/code&gt;, and run one command from system cron on every node:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;define( 'DISABLE_WP_CRON', true );
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;* * * * * cd /var/www/html &amp;amp;&amp;amp; wp fleet-cron run --quiet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then hand the health check to your monitoring, so a stall reaches you rather than an editor:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp fleet-cron status || send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or install it with Composer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;composer require mantekio/wp-fleet-cron
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;One catch: Composer installs it in a subfolder, &lt;code&gt;mu-plugins/wp-fleet-cron/&lt;/code&gt;, and WordPress only loads files sitting directly in &lt;code&gt;mu-plugins/&lt;/code&gt;. So on a standard WordPress site Composer reports success and the plugin never runs. Stacks like Bedrock ship an autoloader that handles this; otherwise add the one-line loader from &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-fleet-cron#install&quot;&gt;the README&lt;/a&gt;, or copy the plugin file into &lt;code&gt;mu-plugins/&lt;/code&gt; by hand instead. Not both: loading it twice is a fatal error.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;One honest word on where it is in its life. It is &lt;strong&gt;new&lt;/strong&gt;, and it ships as &lt;code&gt;0.9.0&lt;/code&gt;. It has been exercised end to end against a real database and WP-CLI: a single runner elected out of five firing at once, a lock that frees itself when the holder is killed mid-job, the watchdog going loud the moment a run goes stale. What it has not yet had is months in a live multi-node fleet, so it is out in the open under the GPL, on GitHub and Packagist, being hardened in daylight rather than behind a curtain. The repository is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-fleet-cron&quot;&gt;github.com/mantekio/wp-fleet-cron&lt;/a&gt;. Same loop as the pieces before it: the article explains the thinking, the repository is the working plugin, the package is the one-line install, and each points at the other two.&lt;/p&gt;
&lt;h3 id=&quot;known-limits-1&quot;&gt;Known limits&lt;/h3&gt;
&lt;p&gt;Being clear about what a tool does not do is part of the tool.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The lock assumes a single primary database.&lt;/strong&gt; That is the normal case, and it is the one thing every node in a fleet already shares. On a multi-primary topology, or behind a connection pooler that hands one session to different callers, swap in a Redis key (&lt;code&gt;SET key value NX PX&lt;/code&gt;); the plugin leaves a seam for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It assumes you can run system cron on the nodes.&lt;/strong&gt; If your host forbids that, keep the loopback, but the watchdog still tells you the instant cron stops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A long job serialises cron behind it.&lt;/strong&gt; The lock is held for the length of a run, so a job that overruns the minute makes the next tick skip rather than stack a second copy on top. That is deliberate, you never want two, but a genuinely heavy job wants its own queue, not a seat in the cron tick.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Some managed hosts already run cron correctly for a fleet.&lt;/strong&gt; Check what yours does before adding anything.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Publishing a post on time is the most solved problem in WordPress, on one server. Across a fleet, on the morning a scheduled lead has to be live at six, it turns out to be a small distributed-systems problem in a CMS costume, and the fixes are the dull, correct ones: run cron where nothing can cache or misroute it, let exactly one node act, and make the silence loud the instant it falls. None of it is clever. All of it is the distance between a scheduler you hope is running and one you know is.&lt;/p&gt;
&lt;p&gt;That is the shape of running &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;WordPress on AWS&lt;/a&gt; for a newsroom: the real problems hide inside the boring infrastructure, and the work is making the boring parts unbreakable. If a scheduled post has ever quietly failed to appear, and you would rather it never did again, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/contact&quot;&gt;let's talk&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23273/wordpress-cron-behind-a-load-balancer-fixing-missed-schedule</guid>
<pubDate>Wed, 22 Jul 2026 17:43:41 +0000</pubDate>
</item>
<item>
<title>WordPress White Screen of Death &amp; Critical Error: Complete Fix Guide</title>
<link>https://coderlegion.com/23031/wordpress-white-screen-of-death-critical-error-complete-fix-guide</link>
<description>&lt;p&gt;Debugging WordPress White Screen of Death / Critical Error — a developer's checklist&lt;/p&gt;
&lt;p&gt;Every WordPress dev eventually gets the panicked &quot;my site is just blank&quot; message. Here's the exact diagnostic order I use, from PHP fatal error basics to production-safe fixes.&lt;/p&gt;
&lt;p&gt;Root cause: always a PHP fatal error. WSOD = blank page (WP_DEBUG_DISPLAY off). &quot;Critical error&quot; message = WP 5.2+'s wrapper around the same fatal.&lt;/p&gt;
&lt;p&gt;Diagnostic order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enable WP_DEBUG_LOG, read /wp-content/debug.log for exact file + line&lt;/li&gt;
&lt;li&gt;Rename /wp-content/plugins/ via FTP to isolate plugin conflicts (no admin access needed)&lt;/li&gt;
&lt;li&gt;Rename active theme folder to rule out theme errors&lt;/li&gt;
&lt;li&gt;Check WP_MEMORY_LIMIT&lt;/li&gt;
&lt;li&gt;Re-upload core files if update was interrupted&lt;/li&gt;
&lt;li&gt;Check for a silent PHP version bump by the host&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Full writeup with cost breakdown for client work: &lt;a rel=&quot;nofollow&quot; href=&quot;https://amanurrahman.com/blog-post/wordpress-white-screen-critical-error-fix&quot;&gt;https://amanurrahman.com/blog-post/wordpress-white-screen-critical-error-fix&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23031/wordpress-white-screen-of-death-critical-error-complete-fix-guide</guid>
<pubDate>Sun, 19 Jul 2026 17:51:15 +0000</pubDate>
</item>
<item>
<title>Your WordPress says the email sent. It didn't.</title>
<link>https://coderlegion.com/22955/your-wordpress-says-the-email-sent-it-didnt</link>
<description>&lt;p&gt;A user tells you they never got the password-reset email. You check: WordPress says it sent. &lt;code&gt;wp_mail()&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt;. There is nothing in the logs, because there is nothing to log. The mail left the building and vanished.&lt;/p&gt;
&lt;p&gt;This is the worst kind of bug. It is silent, it is invisible, and it happens in the one flow where the user is already locked out and already frustrated. And on a default WordPress install, it is not a bug at all. It is the design.&lt;/p&gt;
&lt;h3 id=&quot;why-wordpress-email-is-broken-by-default&quot;&gt;Why WordPress email is broken by default&lt;/h3&gt;
&lt;p&gt;Out of the box, &lt;code&gt;wp_mail()&lt;/code&gt; hands your message to PHPMailer, which hands it to PHP's &lt;code&gt;mail()&lt;/code&gt;, which hands it to &lt;code&gt;sendmail&lt;/code&gt; on the web server. That path has no sending reputation, no SPF alignment, and no DKIM signature. To a modern mail provider, a message from a random web server, unsigned and unaligned, is indistinguishable from spam, so Gmail and Outlook quietly bin it. Often without even a bounce.&lt;/p&gt;
&lt;p&gt;And &lt;code&gt;wp_mail()&lt;/code&gt; reports none of this. Its entire failure vocabulary is a single &lt;code&gt;false&lt;/code&gt;, and on the default path it usually does not even return that: &lt;code&gt;sendmail&lt;/code&gt; accepts the message, so &lt;code&gt;wp_mail()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and the mail dies somewhere downstream where WordPress will never hear about it.&lt;/p&gt;
&lt;p&gt;So the real problem is two problems. The mail is unauthenticated, and the failure is silent. Any fix has to solve both, or it has not fixed anything.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-email-ses-diagram-1.png&quot; alt=&quot;Two paths for the same message: the default drops it, the plugin signs it&quot;&gt;&lt;br&gt;
&lt;em&gt;WordPress builds the same message either way. On the default path it goes to PHP's mail() and out unsigned, where wp_mail() still returns true. The plugin swaps only the transport: a PHPMailer subclass hands the finished message to the SES API, signed, and it arrives aligned.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-the-usual-fix-is-a-half-fix&quot;&gt;Why the usual fix is a half-fix&lt;/h3&gt;
&lt;p&gt;Search for this problem and every answer is the same: install an SMTP plugin, point it at a real mail service, done. WP Mail SMTP, Post SMTP, and a dozen others all do it, and they do fix the authentication half. Your mail now goes through a reputable server that signs it.&lt;/p&gt;
&lt;p&gt;But look at where the credentials live. An SMTP plugin hooks &lt;code&gt;phpmailer_init&lt;/code&gt;, sets &lt;code&gt;$phpmailer-&amp;gt;Host&lt;/code&gt;, &lt;code&gt;$phpmailer-&amp;gt;Username&lt;/code&gt;, and &lt;code&gt;$phpmailer-&amp;gt;Password&lt;/code&gt;, and it has to get those values from somewhere. That somewhere is the database, in &lt;code&gt;wp_options&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Sit with what that means. Your SMTP username and password, the ones that let anything send mail as your domain, are now sitting in a table that ends up in every place a database ends up. The nightly backup. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-s3-media-pipeline&quot;&gt;S3 bucket&lt;/a&gt; that backup lands in. The staging clone a contractor pulled last month. The dump you emailed yourself to debug something. Anyone who touches any copy of that database can now send mail as you, DKIM-signed by your own domain, and there is no fingerprint that says it was not you.&lt;/p&gt;
&lt;p&gt;For a brochure site, that is a small risk. For a newsroom whose domain reputation is an asset, it is the sort of thing that should keep you up at night. The half-fix traded a deliverability problem for a credential-management one, and then hid the trade in a settings screen.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture-three-moves&quot;&gt;The architecture: three moves&lt;/h3&gt;
&lt;p&gt;The fix is three decisions, and each one removes an entire class of the problem rather than mitigating it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One: send through the SES API, not SMTP.&lt;/strong&gt; SMTP means a username and a password, which means a secret at rest. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;Amazon SES&lt;/a&gt; API is a signed HTTPS request. There is no long-lived password in the flow at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two: authenticate with an IAM role, not a stored key.&lt;/strong&gt; This is the move that makes the difference. If your WordPress runs on AWS compute, EC2 or ECS, the instance or task carries an IAM role, and AWS hands short-lived credentials to the machine automatically, rotated for you, never written down. The plugin reads them from the instance metadata endpoint at send time and uses them for that one request. Nothing is stored. Not in the database, not in &lt;code&gt;wp-config.php&lt;/code&gt;, not on disk. There is no secret to leak because there is no secret at rest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Three: replace only the transport, and reimplement nothing.&lt;/strong&gt; This is the move that keeps the plugin honest, and it has a story behind it.&lt;/p&gt;
&lt;h4 id=&quot;a-seam-not-a-fork&quot;&gt;A seam, not a fork&lt;/h4&gt;
&lt;p&gt;WordPress 5.7 added a filter called &lt;code&gt;pre_wp_mail&lt;/code&gt;. Return a non-null value from it and &lt;code&gt;wp_mail()&lt;/code&gt; short-circuits, handing you the whole job. It is tempting to take it: hook the filter, build the SES call, return &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It is also a trap, and I know the shape of this trap well, because I walked into a version of it once already. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;The last must-use plugin I wrote&lt;/a&gt; forked a core function, a copy of &lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;, because core gave me no seam. That fork then drifted from core for three releases without anyone noticing, dropping an entire block of character handling, until it was mangling exactly the Arabic text it was meant to protect. The postscript on that article is a confession: a hand-copy of a core contract rots silently.&lt;/p&gt;
&lt;p&gt;Short-circuiting &lt;code&gt;pre_wp_mail&lt;/code&gt; would be the same mistake in a new costume. Take over &lt;code&gt;wp_mail()&lt;/code&gt; and you now own its entire contract: header parsing, &lt;code&gt;Cc&lt;/code&gt; and &lt;code&gt;Bcc&lt;/code&gt;, &lt;code&gt;Reply-To&lt;/code&gt;, content type, charset, attachments, and every &lt;code&gt;wp_mail_*&lt;/code&gt; filter a hundred other plugins hook into. Reimplement all of that and your copy will drift from core the moment core changes, silently, exactly like last time.&lt;/p&gt;
&lt;p&gt;So this plugin reimplements none of it. Core builds its own PHPMailer object, but only when the global is not already one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;global $phpmailer;
if ( ! ( $phpmailer instanceof PHPMailer\PHPMailer\PHPMailer ) ) {
    // ...core builds the mailer...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;if&lt;/code&gt; is the seam. Hand core a &lt;strong&gt;subclass&lt;/strong&gt; of PHPMailer before it looks, and it uses yours. Core still assembles the entire message, still honours every filter, still does everything it always did. The subclass overrides one method, &lt;code&gt;postSend()&lt;/code&gt;, the point where PHPMailer would normally talk to the transport, and ships the finished message to SES instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class WPSES_Mailer extends \PHPMailer\PHPMailer\PHPMailer {
    public function postSend() {
        $raw  = $this-&amp;gt;getSentMIMEMessage();     // core built this, not us
        $sent = wpses_send_raw( $raw, /* recipients */ );

        if ( is_wp_error( $sent ) ) {
            // Throwing lands in core's own wp_mail_failed path.
            throw new \PHPMailer\PHPMailer\Exception( $sent-&amp;gt;get_error_message() );
        }
        return true;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We take the fully assembled MIME message that core produced and put it on the wire. We replace the transport and nothing else. There is no contract to reimplement, so there is nothing to drift. The lesson from the slug plugin, learned the expensive way, is baked into the architecture of this one: &lt;strong&gt;when core gives you a real seam, take the seam, never the fork.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;fail-loudly-because-the-alternative-is-what-we-are-here-to-fix&quot;&gt;Fail loudly, because the alternative is what we are here to fix&lt;/h3&gt;
&lt;p&gt;Go back to the password reset that vanished. The reason it vanished is that the failure was silent. So the cardinal rule of this plugin is that it is never silent.&lt;/p&gt;
&lt;p&gt;When SES refuses a message, it tells you exactly why: an unverified sender, a sandbox restriction, a throttle. The plugin does not swallow that into a bare &lt;code&gt;false&lt;/code&gt;. It logs the real SES error code, fires an action other code can hook, and, by throwing, lets the failure land in core's own &lt;code&gt;wp_mail_failed&lt;/code&gt; so anything already listening for mail failures keeps working. A &lt;code&gt;false&lt;/code&gt; is not a diagnosis. The SES error is.&lt;/p&gt;
&lt;p&gt;And there is one thing the plugin pointedly refuses to do: fall back to PHP &lt;code&gt;mail()&lt;/code&gt;. A fallback feels like the safe, considerate choice, and it is the exact opposite. Falling back means the mail appears to send, &lt;code&gt;wp_mail()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and the message lands in spam, which is the silent failure we started with, dressed up as resilience. A loud failure gets noticed and fixed. A silent success that lands in spam is never even seen.&lt;/p&gt;
&lt;p&gt;The same principle governs what happens when the plugin is misconfigured. It does not shrug and let WordPress use &lt;code&gt;sendmail&lt;/code&gt;. It refuses to send, and says why, on every admin screen and in the log. A must-use plugin that quietly stands aside is worse than no plugin at all, because now you believe your mail is going through SES when it is going straight to the spam folder.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-email-ses-diagram-2.png&quot; alt=&quot;A rejection you can see, versus one you cannot&quot;&gt;&lt;br&gt;
&lt;em&gt;Same rejected send, two outcomes. The plugin throws, so the failure lands in core's own wp_mail_failed with the real SES reason and wp_mail() returns false. The path it replaces swallows the error and returns true, and the message is gone with nothing logged.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-part-every-tutorial-skips-bounces-and-complaints&quot;&gt;The part every tutorial skips: bounces and complaints&lt;/h3&gt;
&lt;p&gt;Send real mail at any volume and some of it will bounce: addresses go dead, mailboxes fill up, people hit the spam button. Amazon SES watches those two numbers, your bounce rate and your complaint rate, and it is not being advisory. Cross the thresholds and it throttles your sending; stay across them and it suspends the account outright. One careless WordPress install, cheerfully emailing a list of addresses that stopped existing years ago, is enough to put a whole domain's ability to send at risk.&lt;/p&gt;
&lt;p&gt;WordPress, left alone, will do exactly that. It has no memory of a bounce. Ask it to email a deleted address a thousand times and it will try a thousand times, because nothing in &lt;code&gt;wp_mail()&lt;/code&gt; ever hears that the last nine hundred and ninety-nine failed.&lt;/p&gt;
&lt;p&gt;So the plugin keeps the memory WordPress lacks. SES publishes every bounce and complaint to an SNS topic; the plugin exposes one endpoint for that topic, writes each dead or complaining address to a suppression table, and checks that table before every send. An address that hard-bounced or marked you as spam is simply never sent to again. That one feedback loop is most of the distance between a plugin that works on your blog and one you would run under a masthead. And if the plugin cannot record a bounce for a moment, a database blip mid-notification, it does not pretend it succeeded: it tells SNS so, and SNS holds the message and delivers it again, rather than letting a bounce quietly evaporate.&lt;/p&gt;
&lt;p&gt;There is a sharp edge here that is easy to miss, and getting it wrong is worse than not having the feature at all. That SNS endpoint is a public URL. Anything on the internet can POST to it. An endpoint that believes whatever it receives is an open invitation: forge a notification and you could add any address you liked to the suppression list, silently blocking a site's own users from ever receiving its mail. So the endpoint trusts nothing by default. It pins the single SNS topic it is allowed to act on, and it verifies Amazon's cryptographic signature on every message before one row is written. A notification that is not from your topic, or whose signature does not check out, is refused. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;The last plugin&lt;/a&gt; taught the same lesson from the other side: the input you do not validate is the one that hurts you.&lt;/p&gt;
&lt;h3 id=&quot;the-traps-worth-naming&quot;&gt;The traps worth naming&lt;/h3&gt;
&lt;p&gt;None of these are subtle once you know them, and every one has cost someone an afternoon. Named here so they cost you none:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sandbox mode.&lt;/strong&gt; A fresh SES account is sandboxed: it can only send to addresses you have verified, and the quota is tiny. Everything looks configured, and mail to real recipients is refused. The one-time fix is to request production access; the plugin's &lt;code&gt;verify&lt;/code&gt; command tells you which mode you are in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Region mismatch.&lt;/strong&gt; SES identities are per-region. Verify your domain in &lt;code&gt;eu-west-1&lt;/code&gt;, point the plugin at &lt;code&gt;us-east-1&lt;/code&gt;, and every send fails for a reason that has nothing to do with your code. Send from the region you verified in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An unverified From.&lt;/strong&gt; SES will only send as an identity you own and have verified. The &lt;code&gt;From&lt;/code&gt; domain has to be a verified identity, or the send is rejected outright, loudly, which is the whole point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alignment is DNS, not code.&lt;/strong&gt; DKIM and SPF passing is what carries you to the inbox, and both are records you add, not switches the plugin can flip. Turn on Easy DKIM (the three CNAMEs SES hands you), set a custom MAIL FROM so SPF aligns, and DMARC then passes. The plugin signs the request; your DNS is what makes the result trusted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The quotas are real.&lt;/strong&gt; SES caps both a per-second rate and a rolling twenty-four-hour volume. A blast that ignores them does not queue politely, it fails. Know your limits before you lean on them.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;verifying-it-and-why-the-checker-refuses-to-lie&quot;&gt;Verifying it, and why the checker refuses to lie&lt;/h3&gt;
&lt;p&gt;A must-use plugin that sends mail silently is the problem. A plugin that tells you it is configured when it is not is the same problem wearing a badge. So the plugin ships a command whose entire job is to refuse to lie about its own health:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wp ses verify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It signs two real requests to SES with the exact code the send path uses, so a green result is proof the signing works, not a mock of it. It reports the things that actually break delivery: which region it is talking to, where the credentials came from (a role, ideally, not a stray key shadowing it), whether the &lt;code&gt;From&lt;/code&gt; identity is verified, whether DKIM is on, whether the account is still sandboxed, the current quota, and whether the bounce topic is wired up. And it &lt;strong&gt;exits non-zero&lt;/strong&gt; when any of that is wrong, so it composes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wp ses verify || send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put that line in cron and a drifted config pages you before your users find it. This is the same discipline as &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;the verify command in the slug plugin&lt;/a&gt;: a checker earns the right to say PASS by exercising the real thing, and it reports a failure it has not disproven rather than a reassuring green it has not earned. A check that cannot fail is not a check.&lt;/p&gt;
&lt;h3 id=&quot;the-plugin-2&quot;&gt;The plugin&lt;/h3&gt;
&lt;p&gt;It is one file. Drop it into &lt;code&gt;wp-content/mu-plugins/&lt;/code&gt;, add a region and a &lt;code&gt;From&lt;/code&gt; address to &lt;code&gt;wp-config.php&lt;/code&gt;, give the machine an IAM role that can call SES, and WordPress mail goes through SES from the next send. No AWS SDK, no settings screen, no credentials in the database.&lt;/p&gt;
&lt;p&gt;One honest word on where it is in its life: it is &lt;strong&gt;new&lt;/strong&gt;. It is feature-complete and has been hardened through repeated adversarial review, with the SES signing checked against AWS's own test vectors, but I am shipping it as &lt;strong&gt;&lt;code&gt;0.9.0&lt;/code&gt;&lt;/strong&gt; while I run it end-to-end against a live SES account. So it is out in the open under the GPL, on GitHub and on Packagist, being finished in daylight rather than behind a curtain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require mantekio/wp-ses-mail
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;One catch: Composer installs it in a subfolder, &lt;code&gt;mu-plugins/wp-ses-mail/&lt;/code&gt;, and WordPress only loads files sitting directly in &lt;code&gt;mu-plugins/&lt;/code&gt;. So on a standard WordPress site Composer reports success and the plugin never runs. Stacks like Bedrock ship an autoloader that handles this; otherwise add the one-line loader from &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-ses-mail#installation&quot;&gt;the README&lt;/a&gt;, or copy the plugin file into &lt;code&gt;mu-plugins/&lt;/code&gt; by hand instead. Not both: loading it twice is a fatal error.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The repository is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-ses-mail&quot;&gt;github.com/mantekio/wp-ses-mail&lt;/a&gt;. It is the same loop as the last one: the article explains the thinking, the repository is the working plugin, the package is the one-line install, and each points at the other two.&lt;/p&gt;
&lt;h3 id=&quot;known-limits-2&quot;&gt;Known limits&lt;/h3&gt;
&lt;p&gt;Being honest about what a tool does not do is part of the tool. This one has edges worth stating plainly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is a transport, not a mailing-list engine.&lt;/strong&gt; It sends the mail WordPress already wants to send, one message at a time, well. A newsletter blast to tens of thousands of subscribers wants a queue and probably a dedicated bulk sender; leaning on this for that just meets SES throttling as a failed send.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No retry or backoff yet.&lt;/strong&gt; A send SES refuses fails loudly and stops. It does not queue and try again later. For transactional mail that is usually the right behaviour (you want to know now); if you need durable retries, that is a queue in front of the plugin, not the plugin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is a message-size ceiling.&lt;/strong&gt; SES caps the size of a single message. A mail with very large inline attachments can exceed it, and that send will fail rather than be silently truncated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alignment is yours to configure.&lt;/strong&gt; The plugin signs every message, but DKIM, SPF and DMARC alignment are DNS decisions it cannot make for you. It can tell you they are missing; it cannot add the records.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs WordPress 5.7 or newer.&lt;/strong&gt; The whole clean design rests on &lt;code&gt;pre_wp_mail&lt;/code&gt;, which arrived in 5.7. On anything older there is no seam to take: mail falls through to &lt;code&gt;sendmail&lt;/code&gt;, and the plugin can warn you but cannot stop it. If you are on a WordPress that old, the mail path is not the first thing to fix.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Sending an email from WordPress is easy. Running mail you can trust for a newsroom, where a lost password reset is a locked-out journalist and a suspended domain is every address on it going quiet, is a different job, and the difference lives entirely in the boring parts. No secret at rest, because there is no secret. A failure you can see, because silence is the bug. A memory of every bounce, because SES keeps score whether or not you do. None of it is clever. All of it is the distance between mail that works on a blog and mail that holds up under a masthead.&lt;/p&gt;
&lt;p&gt;That is the whole of how we treat &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;WordPress on AWS&lt;/a&gt;: take the seam core gives you, store nothing you would mind leaking, and fail where someone can see it. If your WordPress mail is a mystery you would rather it were not, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/contact&quot;&gt;let's talk&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22955/your-wordpress-says-the-email-sent-it-didnt</guid>
<pubDate>Sat, 18 Jul 2026 16:45:34 +0000</pubDate>
</item>
<item>
<title>Lighthouse 100: Custom PHP vs WordPress vs Shopify</title>
<link>https://coderlegion.com/21114/lighthouse-100-custom-php-vs-wordpress-vs-shopify</link>
<description>
&lt;p&gt;&amp;lt;!-- ========== SCHEMAS ========== --&amp;gt;


&amp;lt;!-- Static schemas (WebSite, LocalBusiness, Organization, etc.) &amp;ndash; keep as in your original --&amp;gt;



?&amp;gt;








&amp;lt;!-- ========== ARTICLE CONTENT ========== --&amp;gt;
&lt;/p&gt;&lt;h1&gt;Lighthouse 100: Custom PHP vs WordPress vs Shopify&lt;/h1&gt;
&lt;div class=&quot;post-meta&quot;&gt;
    &lt;span class=&quot;post-date&quot;&gt;April 2026&lt;/span&gt;
    &lt;span class=&quot;post-read&quot;&gt;12 min read&lt;/span&gt;
&lt;/div&gt;

&lt;p class=&quot;intro&quot;&gt;&lt;/p&gt;&lt;p&gt;I&amp;rsquo;m Jacob Campbell, and a perfect Lighthouse score isn&amp;rsquo;t luck &amp;mdash; it&amp;rsquo;s the natural result of shipping only the code a page needs. A custom PHP site routinely loads in around 0.6 seconds and scores 100; the same content on a plugin-heavy WordPress or app-laden Shopify theme often lands at three to four seconds. Here&amp;rsquo;s how Lighthouse actually scores performance, why the platform gap is so wide, and what it takes to reach 100.&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;Your platform choice is the largest variable. Let&amp;rsquo;s prove it with hard numbers.&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&lt;div class=&quot;comparison-table&quot;&gt;
    &lt;table style=&quot;width:100%; border-collapse: collapse; background: #0f1528; border-radius: 12px; overflow: hidden;&quot;&gt;
        &lt;thead style=&quot;background: #1a2542;&quot;&gt;
            &lt;tr&gt;
                            &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
                    &lt;/tbody&gt;
    &lt;/table&gt;
&lt;/div&gt;

&lt;h2&gt;Breaking Down the Numbers &amp;ndash; Why Such a Gap?&lt;/h2&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

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

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;table style=&quot;width:100%; border-collapse: collapse; background:#0f1528; border-radius:12px; overflow:hidden;&quot;&gt;
    &lt;thead style=&quot;background:#1a2542;&quot;&gt;
        &lt;tr&gt;
                    &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
            &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;div class=&quot;article-cta&quot;&gt;
    &lt;a href=&quot;/contact&quot;&gt;&lt;/a&gt;
&lt;/div&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/21114/lighthouse-100-custom-php-vs-wordpress-vs-shopify</guid>
<pubDate>Mon, 13 Jul 2026 07:45:45 +0000</pubDate>
</item>
<item>
<title>Malcolm X Is Not a Suffix</title>
<link>https://coderlegion.com/22370/malcolm-x-is-not-a-suffix</link>
<description>&lt;p&gt;Parse &lt;code&gt;Malcolm X&lt;/code&gt; with almost any name splitter and you get a suffix. &lt;code&gt;X&lt;/code&gt; looks like a roman numeral, roman numerals are suffixes (&lt;code&gt;John III&lt;/code&gt;, &lt;code&gt;Henry VIII&lt;/code&gt;), so &lt;code&gt;X&lt;/code&gt; becomes the suffix and the surname disappears. That's the kind of bug you only find once you already have a working parser, because it isn't a missing rule. It's two correct rules wanting the same token.&lt;/p&gt;
&lt;p&gt;Last month I wrote about the &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/casing-aware-php-name-parser&quot;&gt;casing fix in nameparser&lt;/a&gt;: the idea that a parser reading &lt;code&gt;Jane Doe DDS&lt;/code&gt; should keep &lt;code&gt;Doe&lt;/code&gt; as the surname and &lt;code&gt;DDS&lt;/code&gt; as a credential, using letter casing as the signal that lowercasing throws away. Version 1.3.0 is the follow-up, and it is almost entirely about collisions like &lt;code&gt;Malcolm X&lt;/code&gt;: tokens that two disambiguation rules both claim. nameparser is a maintained fork of the dormant &lt;code&gt;theiconic/name-parser&lt;/code&gt;, by way of &lt;code&gt;codebyzach/name-parser&lt;/code&gt;'s PHP 8.3+ modernization; the contribution here is the casing and credential layer plus a confidence pass, not the parser core.&lt;/p&gt;
&lt;h3 id=&quot;the-credential-that-is-also-a-name&quot;&gt;The credential that is also a name&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;JO ANDERSON PhD&lt;/code&gt; used to parse to first name &lt;code&gt;J&lt;/code&gt;. &lt;code&gt;Jo&lt;/code&gt; is a real short given name, but in all caps it also reads as a credential-run token, and the credential rule won. 1.3.0 keeps all-caps short given names intact beside a mixed-case salutation or credential, so &lt;code&gt;Jo&lt;/code&gt; survives.&lt;/p&gt;
&lt;p&gt;The mirror image is the credential that gets read as a surname. &lt;code&gt;Jane DDS&lt;/code&gt; (a credential-only row with no comma) and &lt;code&gt;John A. MD&lt;/code&gt; (an initial plus a credential) used to rewrite the trailing credential as the last name, because a token in the surname slot with nothing after it looks like a surname. Both now keep the credential in the suffix.&lt;/p&gt;
&lt;p&gt;The collision gets worse with more than one credential. &lt;code&gt;John Smith MD FACS&lt;/code&gt; used to keep &lt;code&gt;MD&lt;/code&gt; and leak &lt;code&gt;FACS&lt;/code&gt; into the name, because &lt;code&gt;FACS&lt;/code&gt; is not in the dictionary and an unknown trailing token defaults to a name. Now a known credential anchors the tail and pulls the unknown one in with it, so both stay in the suffix. Parenthetical credentials were their own trap: &lt;code&gt;Jane Doe (MD)&lt;/code&gt; used to parse &lt;code&gt;(MD)&lt;/code&gt; as a nickname, since parentheses mark nicknames, and now reads it as a suffix. And a credential-only segment that lands before the given name, as in &lt;code&gt;Smith, MD, John&lt;/code&gt;, is pulled out to the suffix so the first name stays &lt;code&gt;John&lt;/code&gt; rather than becoming &lt;code&gt;MD&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Casing is the tiebreaker for all of these, which means it fails exactly where casing carries no signal. Uniform all-caps registry data, or all-lowercase input, has nothing to disambiguate on, so an ambiguous trailing token defaults to being read as a credential. That's a hard limitation, not a bug, and it's why the library ships a confidence pass: &lt;code&gt;getConfidence()&lt;/code&gt; flags a casing-undecidable row as ambiguous so it can go to a human instead of silently into your database wrong.&lt;/p&gt;
&lt;h3 id=&quot;word-order-that-hides-the-surname&quot;&gt;Word order that hides the surname&lt;/h3&gt;
&lt;p&gt;Surname-first input is its own minefield when a salutation and a credential sit on the same name. &lt;code&gt;Dr. Kim Jong Un&lt;/code&gt; used to lose the surname to the title; &lt;code&gt;Kim Jong Un, MD&lt;/code&gt; used to fall back to Western order. Both now keep surname &lt;code&gt;Kim&lt;/code&gt;, and &lt;code&gt;Dr. Kim Jong Un, MD&lt;/code&gt; correctly yields surname &lt;code&gt;Kim&lt;/code&gt;, salutation &lt;code&gt;Dr.&lt;/code&gt;, and credential &lt;code&gt;MD&lt;/code&gt;. The salutation and the credential were each pulling the parser back toward a different word order, and the fix is teaching it that a leading salutation and a trailing credential-only comma tail can coexist with a surname-first name.&lt;/p&gt;
&lt;h3 id=&quot;the-comma-inside-the-nickname&quot;&gt;The comma inside the nickname&lt;/h3&gt;
&lt;p&gt;A comma in a name usually means surname-first. So a comma inside a delimited nickname is a direct collision with the rule that reads commas as name-order markers. &lt;code&gt;John (Bob, Jr) Doe&lt;/code&gt; and &lt;code&gt;John 'Bob, Jr' Doe&lt;/code&gt; used to bisect on that inner comma and lose half the name. 1.3.0 keeps the nickname &lt;code&gt;Bob, Jr&lt;/code&gt; whole, for bracketed, quoted, and custom multi-character delimiter pairs alike, and the same holds on the given-name side: &lt;code&gt;Smith, John (Jack, Robert)&lt;/code&gt; keeps &lt;code&gt;Jack, Robert&lt;/code&gt; intact.&lt;/p&gt;
&lt;h3 id=&quot;scripts-with-no-casing-at-all&quot;&gt;Scripts with no casing at all&lt;/h3&gt;
&lt;p&gt;If casing is the signal, a script without upper and lower case has none. &lt;code&gt;Wang, 李明&lt;/code&gt; used to split &lt;code&gt;李明&lt;/code&gt; into two initials, because the initials rule fires on short tokens and nothing told it these were not initials. 1.3.0 leaves caseless-script names intact as the first name. In the other direction, an elided surname particle like the one in &lt;code&gt;'t Hooft&lt;/code&gt; used to read as an unterminated nickname or an initial; it now survives as the leading particle. Dutch honorifics (&lt;code&gt;Dhr.&lt;/code&gt;, &lt;code&gt;Mevr.&lt;/code&gt;, &lt;code&gt;Mw.&lt;/code&gt;) also join the default salutation set, so &lt;code&gt;Dhr. Jan de Vries&lt;/code&gt; reads the title as a salutation rather than a first name.&lt;/p&gt;
&lt;h3 id=&quot;and-the-input-that-is-trying-to-break-you&quot;&gt;And the input that is trying to break you&lt;/h3&gt;
&lt;p&gt;A name parser sits directly on user-supplied strings, so an adversarial input is a real exposure, not a theoretical one. Kilobytes of unmatched quotes, 100 KB tokens of any case shape, megabyte-long rows: 1.3.0 parses all of them in linear time and bounded memory. Invalid-UTF-8 delimiter keys and empty whitespace sets that used to emit a compile warning on every parse are now ignored quietly.&lt;/p&gt;
&lt;h3 id=&quot;turning-a-wrong-split-into-a-review-queue&quot;&gt;Turning a wrong split into a review queue&lt;/h3&gt;
&lt;p&gt;Every collision above resolves on casing, and casing runs out of signal on uniform-case data. So the honest design isn't to pretend the parser is always right; it's to say when it's guessing. The confidence pass reads the same input the parser does and flags a row whose split hinges on a signal that is not there.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use Iliaal\NameParser\Parser;

$name = (new Parser())-&amp;gt;parse('SMITH, JM');
$name-&amp;gt;getConfidence();   // ['ambiguous' =&amp;gt; true, 'notes' =&amp;gt; [...]]
$name-&amp;gt;getSource();       // 'SMITH, JM'  (the normalized input it judged)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SMITH, JM&lt;/code&gt; is exactly the case with no answer: with mixed case, &lt;code&gt;Smith, JM&lt;/code&gt; splits &lt;code&gt;JM&lt;/code&gt; into the initials &lt;code&gt;J&lt;/code&gt; and &lt;code&gt;M&lt;/code&gt;, because the mixed-case surname proves those are initials; all-caps &lt;code&gt;SMITH, JM&lt;/code&gt; keeps &lt;code&gt;Jm&lt;/code&gt; as a first name, because the caps could equally be a short given name. The parser has to pick one, and the confidence flag records that it had to. &lt;code&gt;getSource()&lt;/code&gt;, new in 1.3.0, returns the normalized string the confidence was assessed against, so a batch importer can log precisely what it judged. The flag is advisory and opt-in; it never changes what &lt;code&gt;parse()&lt;/code&gt; returns. On a million-row import, that is the difference between silently corrupting an unknown fraction of records and routing a known set of them to a person.&lt;/p&gt;
&lt;p&gt;One caution the README states and this post repeats: &lt;code&gt;ambiguous =&amp;gt; false&lt;/code&gt; on an all-caps row is not a correctness guarantee. Clean credentials that are not also names (&lt;code&gt;RN&lt;/code&gt;, &lt;code&gt;PT&lt;/code&gt;, &lt;code&gt;OD&lt;/code&gt;) are left unflagged on purpose, to keep review volume manageable, so a false there means &quot;no name-colliding token,&quot; not &quot;definitely right.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-seams-where-cases-meet&quot;&gt;The seams where cases meet&lt;/h3&gt;
&lt;p&gt;None of these are new features. They're the boundaries between rules that were already there, the places where two disambiguation heuristics both reach for the same token. That's what this round of work looks like: not more cases, but the seams where cases meet. Casing decides most of them. Where it cannot, the confidence flag says so, and the row goes to review instead of into your data wrong.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;composer require iliaal/nameparser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/nameparser&quot;&gt;github.com/iliaal/nameparser&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22370/malcolm-x-is-not-a-suffix</guid>
<pubDate>Sun, 12 Jul 2026 14:09:14 +0000</pubDate>
</item>
<item>
<title>Schema Markup: The Hidden SEO Weapon</title>
<link>https://coderlegion.com/21113/schema-markup-the-hidden-seo-weapon</link>
<description>
&lt;p&gt;&amp;lt;!-- ========== SCHEMAS ========== --&amp;gt;

&amp;lt;!-- FAQPage schema (generated from translation data) --&amp;gt;


&amp;lt;!-- WebSite schema --&amp;gt;


&amp;lt;!-- LocalBusiness schema --&amp;gt;



?&amp;gt;


&amp;lt;!-- Organization schema --&amp;gt;


&amp;lt;!-- BreadcrumbList schema (headline from translation) --&amp;gt;


&amp;lt;!-- CommunicateAction schema --&amp;gt;


&amp;lt;!-- ========== ARTICLE CONTENT ========== --&amp;gt;
&lt;/p&gt;&lt;h1&gt;Schema Markup: The Hidden SEO Weapon Most Sites Ignore&lt;/h1&gt;
&lt;div class=&quot;post-meta&quot;&gt;
    &lt;span class=&quot;post-date&quot;&gt;March 2026&lt;/span&gt;
    &lt;span class=&quot;post-read&quot;&gt;14 min read&lt;/span&gt;
&lt;/div&gt;

&lt;p class=&quot;intro&quot;&gt;&lt;/p&gt;&lt;p&gt;I&amp;rsquo;m Jacob Campbell, and if I had to name the most underused SEO tactic, it&amp;rsquo;s schema markup. Most sites never add it, which is exactly why it&amp;rsquo;s a quiet competitive edge: the same ranking, but a richer, more clickable listing. Structured data tells Google what your content &lt;em&gt;is&lt;/em&gt; so it can show stars, FAQs and prices right in the results. Here&amp;rsquo;s why it works and how to start &amp;mdash; with Google&amp;rsquo;s own numbers.&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

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

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script type=&quot;application/ld+json&quot;&amp;gt;
{
  &quot;@context&quot;: &quot;&lt;a href=&quot;https://schema.org&quot; rel=&quot;nofollow&quot;&gt;https://schema.org&lt;/a&gt;&quot;,
  &quot;@type&quot;: &quot;LocalBusiness&quot;,
  &quot;name&quot;: &quot;BuiltToWinWeb&quot;,
  &quot;image&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/logo.webp&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/logo.webp&lt;/a&gt;&quot;,
  &quot;address&quot;: {
    &quot;@type&quot;: &quot;PostalAddress&quot;,
    &quot;streetAddress&quot;: &quot;7322 Ashley Shores Circle&quot;,
    &quot;addressLocality&quot;: &quot;Lake Worth&quot;,
    &quot;addressRegion&quot;: &quot;FL&quot;,
    &quot;postalCode&quot;: &quot;33467&quot;,
    &quot;addressCountry&quot;: &quot;US&quot;
  },
  &quot;telephone&quot;: &quot;+15613017130&quot;,
  &quot;openingHours&quot;: &quot;Mo-Fr 09:00-17:00&quot;,
  &quot;aggregateRating&quot;: {
    &quot;@type&quot;: &quot;AggregateRating&quot;,
    &quot;ratingValue&quot;: &quot;4.9&quot;,
    &quot;reviewCount&quot;: &quot;7&quot;
  },
  &quot;priceRange&quot;: &quot;$$&quot;
}
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script type=&quot;application/ld+json&quot;&amp;gt;
{
  &quot;@context&quot;: &quot;&lt;a href=&quot;https://schema.org&quot; rel=&quot;nofollow&quot;&gt;https://schema.org&lt;/a&gt;&quot;,
  &quot;@type&quot;: &quot;FAQPage&quot;,
  &quot;mainEntity&quot;: [
    {
      &quot;@type&quot;: &quot;Question&quot;,
      &quot;name&quot;: &quot;How much does a custom PHP website cost?&quot;,
      &quot;acceptedAnswer&quot;: {
        &quot;@type&quot;: &quot;Answer&quot;,
        &quot;text&quot;: &quot;BuiltToWinWeb offers three flat&amp;#8209;fee packages: Business Pro Website at $1,750, Ecommerce Website at $5,600, and SaaS/Web Application at $10,000.&quot;
      }
    },
    {
      &quot;@type&quot;: &quot;Question&quot;,
      &quot;name&quot;: &quot;Why does custom PHP outrank WordPress?&quot;,
      &quot;acceptedAnswer&quot;: {
        &quot;@type&quot;: &quot;Answer&quot;,
        &quot;text&quot;: &quot;WordPress loads 847KB of JavaScript on average; custom PHP serves only what's needed. That means LCP scores under 1s vs WordPress&amp;rsquo;s 3.8s, which directly improves rankings.&quot;
      }
    }
  ]
}
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;@type&quot;: &quot;Product&quot;,
  &quot;name&quot;: &quot;Custom Ecommerce Website Development&quot;,
  &quot;image&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/ecommerce.webp&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/ecommerce.webp&lt;/a&gt;&quot;,
  &quot;description&quot;: &quot;Full custom PHP ecommerce store with Stripe checkout. One flat fee, no monthly fees.&quot;,
  &quot;offers&quot;: {
    &quot;@type&quot;: &quot;Offer&quot;,
    &quot;price&quot;: &quot;5600&quot;,
    &quot;priceCurrency&quot;: &quot;USD&quot;,
    &quot;availability&quot;: &quot;&lt;a href=&quot;https://schema.org/InStock&quot; rel=&quot;nofollow&quot;&gt;https://schema.org/InStock&lt;/a&gt;&quot;
  },
  &quot;aggregateRating&quot;: {
    &quot;@type&quot;: &quot;AggregateRating&quot;,
    &quot;ratingValue&quot;: &quot;5.0&quot;,
    &quot;reviewCount&quot;: &quot;7&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;@context&quot;: &quot;&lt;a href=&quot;https://schema.org&quot; rel=&quot;nofollow&quot;&gt;https://schema.org&lt;/a&gt;&quot;,
  &quot;@type&quot;: &quot;BreadcrumbList&quot;,
  &quot;itemListElement&quot;: [
    { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 1, &quot;name&quot;: &quot;Home&quot;, &quot;item&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/&lt;/a&gt;&quot; },
    { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 2, &quot;name&quot;: &quot;Services&quot;, &quot;item&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/services/&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/services/&lt;/a&gt;&quot; },
    { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 3, &quot;name&quot;: &quot;Custom PHP Website&quot;, &quot;item&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/services/custom-php-website/&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/services/custom-php-website/&lt;/a&gt;&quot; }
  ]
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;@type&quot;: &quot;Article&quot;,
  &quot;headline&quot;: &quot;Schema Markup: The Hidden SEO Weapon&quot;,
  &quot;datePublished&quot;: &quot;2026-03-15&quot;,
  &quot;author&quot;: {
    &quot;@type&quot;: &quot;Person&quot;,
    &quot;name&quot;: &quot;Jacob Campbell&quot;
  },
  &quot;publisher&quot;: {
    &quot;@type&quot;: &quot;Organization&quot;,
    &quot;name&quot;: &quot;BuiltToWinWeb&quot;,
    &quot;logo&quot;: { &quot;@type&quot;: &quot;ImageObject&quot;, &quot;url&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/logo.webp&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/logo.webp&lt;/a&gt;&quot; }
  },
  &quot;mainEntityOfPage&quot;: &quot;&lt;a href=&quot;https://built2winweb.com/blog/schema-markup&quot; rel=&quot;nofollow&quot;&gt;https://built2winweb.com/blog/schema-markup&lt;/a&gt;&quot;
}&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

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

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ol&gt;
    &lt;/ol&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function getProductSchema($product) {
    return [
        &quot;@context&quot; =&amp;gt; &quot;&lt;a href=&quot;https://schema.org&quot; rel=&quot;nofollow&quot;&gt;https://schema.org&lt;/a&gt;&quot;,
        &quot;@type&quot; =&amp;gt; &quot;Product&quot;,
        &quot;name&quot; =&amp;gt; $product['name'],
        &quot;description&quot; =&amp;gt; $product['description'],
        &quot;offers&quot; =&amp;gt; [
            &quot;@type&quot; =&amp;gt; &quot;Offer&quot;,
            &quot;price&quot; =&amp;gt; $product['price'],
            &quot;priceCurrency&quot; =&amp;gt; &quot;USD&quot;,
            &quot;availability&quot; =&amp;gt; $product['in_stock'] ? &quot;&lt;a href=&quot;https://schema.org/InStock&quot; rel=&quot;nofollow&quot;&gt;https://schema.org/InStock&lt;/a&gt;&quot; : &quot;&lt;a href=&quot;https://schema.org/OutOfStock&quot; rel=&quot;nofollow&quot;&gt;https://schema.org/OutOfStock&lt;/a&gt;&quot;
        ]
    ];
}

// Then output as JSON
$schema = getProductSchema($product);
echo '&amp;lt;script type=&quot;application/ld+json&quot;&amp;gt;' . json_encode($schema, JSON_UNESCAPED_SLASHES) . '&amp;lt;/script&amp;gt;';&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;div class=&quot;article-cta&quot;&gt;
    &lt;a href=&quot;/contact&quot;&gt;&lt;/a&gt;
&lt;/div&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/21113/schema-markup-the-hidden-seo-weapon</guid>
<pubDate>Sun, 12 Jul 2026 07:45:45 +0000</pubDate>
</item>
<item>
<title>Failing Closed: What Shipped Across Seven PHP Extensions</title>
<link>https://coderlegion.com/22216/failing-closed-what-shipped-across-seven-php-extensions</link>
<description>&lt;p&gt;&lt;code&gt;ExcelBook::save()&lt;/code&gt; to a &lt;code&gt;file://&lt;/code&gt; path used to truncate your existing file before it finished writing the new one. If the write was short or got interrupted, you were left with neither the old file nor a complete new one. php_excel 2.4.0 fixes that: the write goes to a temp file and renames into place, so a failed save leaves your original intact.&lt;/p&gt;
&lt;p&gt;That fix doesn't make a good changelog headline. It's also most of what shipped across seven of my PHP extensions in the last month. Some of it is new capability, a new Markdown engine, twelve more chart types, a columnar serializer, native JSON columns in ClickHouse. Most of it is the other kind of work: rejecting a bad value instead of storing it, throwing on the untrusted path instead of trusting it, failing a write cleanly instead of half-committing it. This is the second roundup in the series; the &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/lowering-the-php-floor-what-shipped-across-five-extensions&quot;&gt;June one&lt;/a&gt; covered the version-floor drops. Here is what landed since.&lt;/p&gt;
&lt;h3 id=&quot;mdparser-0-4-3-a-new-engine-under-the-same-api&quot;&gt;mdparser 0.4.3: a new engine under the same API&lt;/h3&gt;
&lt;p&gt;mdparser swapped its entire parsing backend in 0.4.0. cmark-gfm is gone, replaced by md4c, a single-file streaming CommonMark parser compiled directly into the extension. The four render methods and the &lt;code&gt;Options&lt;/code&gt; surface are unchanged; only the engine underneath is new. The swap brings clean CommonMark 0.31 conformance (652 of 652 spec examples) and drops the external cmark-gfm dependency entirely.&lt;/p&gt;
&lt;p&gt;md4c also supports a set of dialects cmark-gfm did not, each opt-in and off by default: LaTeX math (&lt;code&gt;$inline$&lt;/code&gt; and &lt;code&gt;\(block\)&lt;/code&gt;), wiki links (&lt;code&gt;[[target]]&lt;/code&gt;), spoilers, &lt;code&gt;==highlight==&lt;/code&gt;, super and subscript, and GitHub-style admonition blocks (&lt;code&gt;&amp;gt; [!NOTE]&lt;/code&gt;) added in 0.4.1. Each surfaces as its own node type in &lt;code&gt;toXml()&lt;/code&gt; and &lt;code&gt;toAst()&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use MdParser\{Parser, Options};

$html = (new Parser(new Options(latexMath: true, wikiLinks: true)))-&amp;gt;toHtml($source);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hardening that came with the rewrite is the part I care about more. 0.4.2 fixed a stack buffer overflow in the CommonMark XML serializer: an ordered list with a wide start number could truncate the tag, embed a NUL byte, and at the widest input read past a fixed 64-byte stack buffer. 0.4.3 closed an &lt;code&gt;alt=&quot;...&quot;&lt;/code&gt; breakout where raw HTML in image alt text was not attribute-escaped under &lt;code&gt;unsafe: true&lt;/code&gt;. And the in-stream heading-anchor rewrite resolved the 0.3.0 limitation where a raw &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; and a Markdown &lt;code&gt;# heading&lt;/code&gt; could fight over the same &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;One honest trade: md4c exposes no source positions, so &lt;code&gt;sourcepos&lt;/code&gt; and a handful of old cmark-only options are accepted for compatibility but inert.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/mdparser&quot;&gt;github.com/iliaal/mdparser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastchart-1-6-0-from-26-chart-types-to-38&quot;&gt;fastchart 1.6.0: from 26 chart types to 38&lt;/h3&gt;
&lt;p&gt;fastchart 1.4.0 added twelve chart classes in one release, taking the catalog from 26 types to 38. The new set is mostly hierarchy and relationship charts: dendrogram, partition (icicle), chord diagram, network graph, arc diagram, circle packing, Venn diagram, violin plot, population pyramid, word cloud, pictogram, and a serpentine timeline. The two hierarchy charts feed from one &lt;code&gt;setHierarchy()&lt;/code&gt; call.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$chart = new FastChart\Dendrogram(900, 600);
$chart-&amp;gt;setHierarchy($root);
$chart-&amp;gt;renderToFile('tree.svg');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;1.5.0 was the performance release. A 4096-point scatter with per-point colors renders about 6x faster now that SVG color handles resolve through a hash table instead of a linear scan; &lt;code&gt;renderPng()&lt;/code&gt; is about 40% faster after switching from adaptive to a fixed UP row filter at effectively unchanged file size; and dense SVG documents emit about 40% faster through dedicated integer color formatters. 1.4.0 also added seven &lt;code&gt;StockChart&lt;/code&gt; indicators (VWAP, ZigZag, ATR, CCI, Williams %R, Aroon, standard deviation).&lt;/p&gt;
&lt;p&gt;The vector PDF backend from 1.3.0 is still a v1. This round's PDF work was hardening, fully transparent colors that used to paint opaque, band-gradient alpha that got discarded, rather than the gradient support it still lacks.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastchart&quot;&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-clickhouse-0-10-0-json-bool-and-ipv6-columns-and-a-faster-decoder&quot;&gt;php_clickhouse 0.10.0: JSON, Bool, and IPv6 columns, and a faster decoder&lt;/h3&gt;
&lt;p&gt;Three releases since 0.8.7. 0.8.8 added native &lt;code&gt;JSON&lt;/code&gt;, &lt;code&gt;Bool&lt;/code&gt;, and &lt;code&gt;IPv4&lt;/code&gt;/&lt;code&gt;IPv6&lt;/code&gt; column read and write; a &lt;code&gt;JSON&lt;/code&gt; insert takes a PHP array, object, or raw JSON string, and reads return the string or decode with &lt;code&gt;JSON_AS_ARRAY&lt;/code&gt; / &lt;code&gt;JSON_AS_OBJECT&lt;/code&gt;. 0.9.0 rewrote the decoder and insert-builder hot paths. 0.10.0 tightened correctness.&lt;/p&gt;
&lt;p&gt;The perf work in 0.9.0 is the kind you only find with a profiler. Result decoding used to downcast each scalar column through &lt;code&gt;dynamic_pointer_cast&lt;/code&gt;, an atomic refcount round-trip plus an RTTI walk; a callgrind attributed about 18% of decode instructions to that one cast. The integer, float, and string paths now use a direct static cast keyed on the column's type code. Numeric inserts build straight from the row-major input instead of transposing into a temporary PHP array first. Wide integer &lt;code&gt;SELECT&lt;/code&gt;s decode about 25% faster; numeric-heavy inserts land 30 to 44% faster.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// FixedString payloads (IPv6, hash digests) read back at full width, trailing NULs kept
$rows = $ch-&amp;gt;select('SELECT ip FROM sessions', [], ClickHouse::FIXEDSTRING_BINARY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0.10.0's correctness pass is the fail-closed theme again: inserting PHP &lt;code&gt;null&lt;/code&gt; into a non-&lt;code&gt;Nullable&lt;/code&gt; &lt;code&gt;JSON&lt;/code&gt; column now throws instead of silently storing an empty &lt;code&gt;{}&lt;/code&gt;, and a clean server error (bad SQL, missing table) no longer reconnects and quietly discards your session's temp tables and &lt;code&gt;SET&lt;/code&gt; settings.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phpser-0-4-0-a-columnar-wire-format-for-rowsets&quot;&gt;phpser 0.4.0: a columnar wire format for rowsets&lt;/h3&gt;
&lt;p&gt;phpser's wire format v2 (0.3.0) added a columnar layout for homogeneous rowsets. Instead of writing each row's keys and values, &lt;code&gt;TAG_TABLE&lt;/code&gt; writes the column schema once, then each column as a typed run: packed longs, doubles, strings, or mixed. For the array-of-uniform-rows shape that cache and queue payloads usually are, that drops the per-row key overhead entirely. v1 payloads still decode.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$blob = phpser_serialize($rows);   // columnar automatically when rows share a schema
$rows = phpser_unserialize($blob);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0.4.0 made table encode a single row-major pass instead of an O(rows × cols²) per-column rescan (about 20% faster rowset encode on aarch64), and rowset decode about 24% faster by resolving dictionary strings against the engine's interned-string table. It also closed two issues on the untrusted decode path. A &lt;code&gt;TAG_OBJECT_SLOTS&lt;/code&gt; payload now routes through &lt;code&gt;__unserialize()&lt;/code&gt; when the class defines it, instead of installing raw property slots and running &lt;code&gt;__wakeup()&lt;/code&gt;, closing an invariant-rebuild bypass. And a crafted &lt;code&gt;__PHP_Incomplete_Class_Name&lt;/code&gt; property can no longer resurrect an attacker-chosen class name on a later decode.&lt;/p&gt;
&lt;p&gt;The columnar path is automatic; you don't opt in. It only engages for homogeneous string-keyed rowsets, and a single row or a mismatched schema falls back to the general encoding.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastjson-0-6-0-json-pointer-you-can-write-to-and-prebuilt-binaries&quot;&gt;fastjson 0.6.0: JSON Pointer you can write to, and prebuilt binaries&lt;/h3&gt;
&lt;p&gt;The June roundup covered &lt;code&gt;fastjson_pointer_get()&lt;/code&gt; and merge-patch. 0.5.0 completed the RFC 6901 surface with &lt;code&gt;fastjson_pointer_set()&lt;/code&gt;, which sets a value by pointer and returns the re-serialized document, and &lt;code&gt;fastjson_pointer_exists()&lt;/code&gt;, which distinguishes present-but-null from absent without materializing anything into PHP.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$updated = fastjson_pointer_set($json, '/user/email', '*Emails are not allowed*');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0.5.0 also started publishing prebuilt &lt;code&gt;.so&lt;/code&gt; binaries for Linux glibc (x86_64, arm64) and macOS (arm64) on PHP 8.4 and 8.5 as release assets, so PIE installs the binary instead of source-building on those targets. 0.6.0 was hardening: a use-after-free when encoding an array that a nested &lt;code&gt;JsonSerializable&lt;/code&gt; mutates through an aliasing reference (the array is now copied first, as &lt;code&gt;ext/json&lt;/code&gt; does), and stack-exhausting nesting in &lt;code&gt;pointer_set&lt;/code&gt; rejected up front instead of crashing.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastjson&quot;&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-excel-2-4-0-bulk-reads-and-a-hardening-pass&quot;&gt;php_excel 2.4.0: bulk reads and a hardening pass&lt;/h3&gt;
&lt;p&gt;2.3.0 added bulk read paths. &lt;code&gt;readRange()&lt;/code&gt; pulls a rectangular block of cells into a 2D array in one call instead of per-cell PHP dispatch, &lt;code&gt;readSparseRow()&lt;/code&gt; and &lt;code&gt;readSparseCol()&lt;/code&gt; return only the occupied cells keyed by their original index, and &lt;code&gt;loadPartially()&lt;/code&gt; reads a single sheet's row slice without loading the rest of the workbook.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$block = $sheet-&amp;gt;readRange(0, 999, 0, 25);   // a 1000 x 26 region in one call
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2.4.0 is the atomic-save fix from the top of this post plus the rest of the fail-closed pass: &lt;code&gt;load()&lt;/code&gt; rejects a buffer above &lt;code&gt;UINT_MAX&lt;/code&gt; instead of truncating a payload over 4 GiB, &lt;code&gt;writeRow()&lt;/code&gt; and &lt;code&gt;writeCol()&lt;/code&gt; validate every value before writing any cell instead of committing half the row and then failing, and inverted ranges (first &amp;gt; last) are rejected instead of stored as garbage coordinates. One security tightening: &lt;code&gt;excel.license_key&lt;/code&gt; and &lt;code&gt;excel.license_name&lt;/code&gt; are now &lt;code&gt;PHP_INI_SYSTEM&lt;/code&gt;, so a per-request &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt; can no longer overwrite the commercial license in a shared pool.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_excel&quot;&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fast-uuid-0-4-0-binary-and-batch-generators-and-ramsey-uuid-parity&quot;&gt;fast_uuid 0.4.0: binary and batch generators, and ramsey/uuid parity&lt;/h3&gt;
&lt;p&gt;0.3.0 added binary and batch generation. The binary generators (&lt;code&gt;uuid_v4_bin()&lt;/code&gt;, &lt;code&gt;uuid_v7_bin()&lt;/code&gt;, and the rest) return the raw 16 bytes with no canonical formatting, for a &lt;code&gt;BINARY(16)&lt;/code&gt; column or a wire format with no string round-trip. The batch generators build many at once and amortize the per-call cost; v7 batches stay monotonic within the call.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$ids = uuid_v7_bin_batch(1000);   // 1000 raw 16-byte v7 UUIDs, monotonic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of 0.3.0 and 0.4.0, though, is &lt;code&gt;ramsey/uuid&lt;/code&gt; compatibility correctness. The compat &lt;code&gt;TimestampFirstCombCodec&lt;/code&gt; used a byte rotation instead of &lt;code&gt;ramsey/uuid&lt;/code&gt;'s first-6/last-6 swap, so a COMB written by &lt;code&gt;ramsey/uuid&lt;/code&gt; decoded to a different UUID; the layout now matches, and COMB columns interop. &lt;code&gt;Fields::getTimestamp()&lt;/code&gt; truncated v1 and v6 timestamps on 32-bit PHP; it now rebuilds them exactly as a hex string. Canonical parsing also runs about twice as fast through a 256-entry nibble lookup table.&lt;/p&gt;
&lt;p&gt;One breaking change to note if you catch it: &lt;code&gt;UnsupportedOperationException&lt;/code&gt; now extends &lt;code&gt;\LogicException&lt;/code&gt; instead of &lt;code&gt;\RuntimeException&lt;/code&gt;, matching &lt;code&gt;ramsey/uuid&lt;/code&gt; 4.x.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fast_uuid&quot;&gt;github.com/iliaal/fast_uuid&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-unglamorous-majority&quot;&gt;The unglamorous majority&lt;/h3&gt;
&lt;p&gt;The features are the headline: a new Markdown engine, twelve more chart types, a columnar serializer, native JSON in ClickHouse. But count the lines in these seven changelogs and most of them are the other kind: a value rejected instead of stored, a write that fails cleanly instead of half-committing, a throw on the untrusted path instead of a silent coercion. That work never reads well in release notes. It's the difference between an extension you benchmark once and one you leave running.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22216/failing-closed-what-shipped-across-seven-php-extensions</guid>
<pubDate>Fri, 10 Jul 2026 13:26:46 +0000</pubDate>
</item>
<item>
<title>How to Add Stripe Checkout to a Custom PHP Website (No Plugins)</title>
<link>https://coderlegion.com/21111/how-to-add-stripe-checkout-to-a-custom-php-website-no-plugins</link>
<description>
&lt;p&gt;&amp;lt;!-- ========== SCHEMAS ========== --&amp;gt;






&amp;lt;!-- ========== ARTICLE CONTENT ========== --&amp;gt;
&lt;/p&gt;&lt;h1&gt;How to Add Stripe Checkout to a Custom PHP Website (No Plugins)&lt;/h1&gt;
&lt;div class=&quot;post-meta&quot;&gt;
    &lt;span class=&quot;post-date&quot;&gt;September 2025&lt;/span&gt;
    &lt;span class=&quot;post-read&quot;&gt;14 min read&lt;/span&gt;
    &lt;span class=&quot;post-author&quot;&gt;by Jacob Campbell&lt;/span&gt;
&lt;/div&gt;

&lt;p class=&quot;intro&quot;&gt;&lt;/p&gt;&lt;p&gt;I&amp;rsquo;m Jacob Campbell, and you don&amp;rsquo;t need WooCommerce or a pile of plugins to take payments &amp;mdash; plain PHP and Stripe Checkout do it cleanly. Stripe hosts the secure payment page, handles card validation and 100+ payment methods, and hands you back a verified result. Your PHP just creates a session and listens for a webhook. Here&amp;rsquo;s how the flow works end to end and why the no-plugin approach is leaner and safer.&lt;/p&gt;

&amp;lt;!-- WHY --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&amp;lt;!-- STEP 1 &amp;mdash; INSTALL --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;/code&gt;&lt;/pre&gt;

&amp;lt;!-- STEP 2 &amp;mdash; SESSION --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;/code&gt;&lt;/pre&gt;

&amp;lt;!-- STEP 3 &amp;mdash; FRONTEND --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;&lt;/code&gt;&lt;/pre&gt;

&amp;lt;!-- STEP 4 &amp;mdash; WEBHOOK --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;

&amp;lt;!-- STEP 5 &amp;mdash; EMAIL --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;/code&gt;&lt;/pre&gt;

&amp;lt;!-- COMPARISON TABLE --&amp;gt;
&lt;h2&gt;Platform Comparison &amp;mdash; Custom PHP vs Shopify vs WooCommerce&lt;/h2&gt;
&lt;div class=&quot;comparison-table&quot;&gt;
    &lt;table style=&quot;width:100%;border-collapse:collapse;background:#0f1528;border-radius:12px;overflow:hidden;&quot;&gt;
        &lt;thead style=&quot;background:#1a2542;&quot;&gt;
            &lt;tr&gt;
                            &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
                    &lt;/tbody&gt;
    &lt;/table&gt;
&lt;/div&gt;

&amp;lt;!-- IMPACT --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&amp;lt;!-- SECURITY --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&amp;lt;!-- FAQ --&amp;gt;
&lt;h2&gt;Frequently Asked Questions&lt;/h2&gt;
    &lt;h3&gt;Can I take payments in PHP without WooCommerce?&lt;/h3&gt;
    &lt;p&gt;Yes. Stripe Checkout needs only the Stripe PHP library: create a Checkout Session, redirect to Stripe&amp;rsquo;s hosted page, and confirm via webhook.&lt;/p&gt;
    &lt;h3&gt;What is a Checkout Session?&lt;/h3&gt;
    &lt;p&gt;A server-created object that returns a URL to Stripe&amp;rsquo;s hosted payment page, where Stripe securely collects and validates the payment.&lt;/p&gt;
    &lt;h3&gt;Why use webhooks for fulfilment?&lt;/h3&gt;
    &lt;p&gt;Because customers can close the tab before the redirect. The checkout.session.completed webhook reliably confirms payment regardless of what the browser does.&lt;/p&gt;
    &lt;h3&gt;Is a no-plugin checkout secure?&lt;/h3&gt;
    &lt;p&gt;Yes &amp;mdash; Stripe hosts the card entry and you verify webhook signatures. There is less code and a smaller attack surface than a full e-commerce plugin.&lt;/p&gt;
    &lt;h3&gt;Does Stripe Checkout support subscriptions?&lt;/h3&gt;
    &lt;p&gt;Yes. Set the session mode to subscription to take recurring payments with the same hosted flow.&lt;/p&gt;

&amp;lt;!-- CTA --&amp;gt;
&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;div class=&quot;article-cta&quot;&gt;
    &lt;a href=&quot;/contact&quot;&gt;&lt;/a&gt;
&lt;/div&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/21111/how-to-add-stripe-checkout-to-a-custom-php-website-no-plugins</guid>
<pubDate>Fri, 10 Jul 2026 07:45:45 +0000</pubDate>
</item>
<item>
<title>Migrate WordPress to Custom PHP Without Losing SEO</title>
<link>https://coderlegion.com/21110/migrate-wordpress-to-custom-php-without-losing-seo</link>
<description>
&lt;p&gt;&amp;lt;!-- ========== SCHEMAS ========== --&amp;gt;

&amp;lt;!-- FAQPage schema (generated from translation data) --&amp;gt;


&amp;lt;!-- WebSite schema --&amp;gt;


&amp;lt;!-- LocalBusiness schema --&amp;gt;



?&amp;gt;


&amp;lt;!-- Organization schema --&amp;gt;


&amp;lt;!-- BreadcrumbList schema (headline from translation) --&amp;gt;


&amp;lt;!-- CommunicateAction schema --&amp;gt;


&amp;lt;!-- ========== ARTICLE CONTENT ========== --&amp;gt;
&lt;/p&gt;&lt;h1&gt;Migrate WordPress to Custom PHP Without Losing SEO&lt;/h1&gt;
&lt;div class=&quot;post-meta&quot;&gt;
    &lt;span class=&quot;post-date&quot;&gt;December 2025&lt;/span&gt;
    &lt;span class=&quot;post-read&quot;&gt;16 min read&lt;/span&gt;
&lt;/div&gt;

&lt;p class=&quot;intro&quot;&gt;&lt;/p&gt;&lt;p&gt;I&amp;rsquo;m Jacob Campbell, and I move sites off WordPress for a living without losing rankings &amp;mdash; here&amp;rsquo;s the technical playbook. WordPress is fine until the plugin weight, security patching and speed become a tax you pay forever. Custom PHP removes that tax, and if you migrate carefully, Google never notices the switch. This is the step-by-step from export to launch, grounded in Google&amp;rsquo;s own site-move guidance.&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ol&gt;
    &lt;/ol&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

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

&lt;h3&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;ol&gt;
    &lt;/ol&gt;

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

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ul&gt;
    &lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;

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

&lt;h3&gt;&lt;/h3&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&lt;h3&gt;&lt;/h3&gt;
&lt;ul&gt;
    &lt;/ul&gt;

&lt;h2&gt;&lt;/h2&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;

&lt;div class=&quot;article-cta&quot;&gt;
    &lt;a href=&quot;/contact&quot;&gt;&lt;/a&gt;
&lt;/div&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/21110/migrate-wordpress-to-custom-php-without-losing-seo</guid>
<pubDate>Thu, 09 Jul 2026 07:45:45 +0000</pubDate>
</item>
<item>
<title>Error, loss functions, and why they are needed</title>
<link>https://coderlegion.com/22109/error-loss-functions-and-why-they-are-needed</link>
<description>&lt;h2 id=&quot;2-1-error-loss-functions-and-why-they-are-needed&quot;&gt;2.1 Error, loss functions, and why they are needed&lt;/h2&gt;
&lt;h4 id=&quot;error-loss-functions-and-why-we-need-them&quot;&gt;Error, Loss Functions, and Why We Need Them&lt;/h4&gt;
&lt;p&gt;Any machine learning model comes down to a simple idea: it tries to describe reality using some function. That means there will always be a mismatch between what actually exists and what the model predicts. We call this mismatch an error.&lt;/p&gt;
&lt;p&gt;It is important to understand one key thing: the model does not know what is &quot;good&quot; or &quot;bad&quot;. It does not understand the meaning of the task. During training, all it does is try to reduce a special number calculated from its errors. This number is called the loss function.&lt;/p&gt;
&lt;p&gt;Formally, error is the deviation between the real value \(y\) and the model prediction \(\hat{y}\), while loss is a function that converts this deviation into a number convenient for optimization – a number the model tries to minimize during training.&lt;/p&gt;
&lt;p&gt;Imagine a simple example. Suppose a model has to predict the price of an apartment.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Actual price: y = 200 000
Model prediction: ŷ = 180 000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model made a mistake. The only question is how exactly we measure this error.&lt;/p&gt;
&lt;h5 id=&quot;error-as-distance&quot;&gt;Error as Distance&lt;/h5&gt;
&lt;p&gt;Suppose we have a real value \(y\) and a model prediction \(\hat{y}\). The most natural thing to do is to look at the difference:&lt;/p&gt;
&lt;p&gt;\[
error = y - \hat{y}
\]&lt;/p&gt;
&lt;p&gt;But this value by itself is inconvenient for training a model. It can be both negative and positive. If we have many observations, positive and negative errors can cancel each other out.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+10
-10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The average error would be zero, even though the model is clearly making mistakes.&lt;/p&gt;
&lt;p&gt;That is why we introduce a loss function – a function that transforms the error into a non-negative number suitable for optimization. This is the number the model tries to minimize during training.&lt;/p&gt;
&lt;p&gt;Geometrically, it looks like this: we measure how far the prediction is from the real value on the number line.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12068303981331053917&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Figure 2.1-1. The distance between y and ŷ on the number line&lt;/p&gt;
&lt;p&gt;Essentially, we want to convert the deviation into a number that behaves like a distance. Let us explain why. In regression tasks, it is convenient to interpret error as the distance between the real value and the prediction – that is, a number that is always non-negative and grows as the error increases.&lt;/p&gt;
&lt;p&gt;However, not every loss function is a distance in the strict mathematical sense:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In MSE – yes, it is the squared Euclidean distance between the prediction and the real value&lt;/li&gt;
&lt;li&gt;In log loss (logarithmic loss) – it is no longer a metric distance but a divergence&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This immediately leads us to an important idea: error should be non-negative.&lt;/p&gt;
&lt;h5 id=&quot;squared-error-as-a-penalty-for-missing-the-target&quot;&gt;Squared Error as a Penalty for Missing the Target&lt;/h5&gt;
&lt;p&gt;The simplest way to get rid of the sign is to take the absolute value of the error.&lt;/p&gt;
&lt;p&gt;\[
L = |y - ŷ|
\]&lt;/p&gt;
&lt;p&gt;But the absolute value is not differentiable at point 0, which complicates optimization. That is why ML usually uses squared error instead:&lt;/p&gt;
&lt;p&gt;\[
L = (y - \hat{y})^2
\]&lt;/p&gt;
&lt;p&gt;Why?&lt;/p&gt;
&lt;p&gt;First, squaring makes the function smooth and differentiable everywhere, which is critically important for gradient optimization and makes model training easier.&lt;/p&gt;
&lt;p&gt;Second, squaring strongly amplifies large errors.&lt;/p&gt;
&lt;p&gt;If the error increases by a factor of 2, the penalty increases by a factor of 4:&lt;/p&gt;
&lt;p&gt;\[
(2e)^2 = 4e^2
\]&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error   →    Squared error
-----------------------------
 1                   1
 2                   4
 5                  25
10                 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because of this property, MSE is especially sensitive to large errors and outliers.&lt;/p&gt;
&lt;p&gt;This is an important feature: we tell the model in advance that rare but large mistakes are worse than many small ones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mean Squared Error (MSE)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If we have many observations, we calculate the squared error for each of them and then average the results:&lt;/p&gt;
&lt;p&gt;\[
\text{MSE} = \frac{1}{n} \sum\_{i=1}^{n} (y\_i - \hat{y}\_i)^2
\]&lt;/p&gt;
&lt;p&gt;From a geometric point of view, MSE is the average squared distance between real values and predictions – in other words, the squared vertical deviations of points from the model.&lt;/p&gt;
&lt;p&gt;If we imagine the data as points on a plane and the model as a line or surface, MSE measures how far the points are from that surface.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12976142097919682678&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Figure 2.1-2. Data points and regression line, vertical bars are errors&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A Bit of Useful Math&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Why is MSE used so often? Because the minimum of MSE behaves very predictably.&lt;/p&gt;
&lt;p&gt;If the model is linear:&lt;/p&gt;
&lt;p&gt;\[
\hat{y} = wx + b
\]&lt;/p&gt;
&lt;p&gt;then MSE as a function of parameters \(w\) and \(b\) is convex with respect to the model parameters.&lt;/p&gt;
&lt;p&gt;This means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;it has one global minimum&lt;/li&gt;
&lt;li&gt;the anti-gradient (the direction opposite to the gradient) points toward reducing the error function&lt;/li&gt;
&lt;li&gt;training is stable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6470648912497842927&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Figure 2.1-3. Graph of a convex loss function with a single minimum&lt;/p&gt;
&lt;p&gt;This is one of the reasons why linear regression is such a fundamental and reliable tool.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Connection Between MSE and the Normal Distribution&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is another important but often implicit fact. Minimizing MSE is equivalent to maximizing likelihood if we assume the errors are normally distributed:&lt;/p&gt;
&lt;p&gt;\[
\varepsilon = (y - \hat{y}) \sim \mathcal{N}(0, \sigma^2)
\]&lt;/p&gt;
&lt;p&gt;In this case, minimizing MSE is equivalent to maximizing likelihood.&lt;/p&gt;
&lt;p&gt;In other words, MSE is not just a convenient formula. By using it, we implicitly assume that the noise \(\varepsilon\) follows a normal distribution – the familiar &quot;Gaussian bell curve&quot;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why MSE Is Not Suitable for Classification&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So far we have discussed regression tasks, where the model predicts a numerical value. But there is another type of task – classification – where the model has to choose one of several possible answers.&lt;/p&gt;
&lt;p&gt;Imagine a binary classification problem with two possible outputs: &quot;yes&quot; and &quot;no&quot;. For example, spam or not spam.&lt;/p&gt;
&lt;p&gt;Real value:&lt;/p&gt;
&lt;p&gt;\[
y \in {0,1}
\]&lt;/p&gt;
&lt;p&gt;Model prediction:&lt;/p&gt;
&lt;p&gt;\[
\hat{p} \in \[0, 1]
\]&lt;/p&gt;
&lt;p&gt;If we use MSE, the difference between probabilities 0.99 and 0.51 does not appear very large, even though intuitively these predictions are of completely different quality when the correct answer is 1 (that is, spam).&lt;/p&gt;
&lt;p&gt;In general, MSE does a poor job distinguishing confidence levels and does not match the probabilistic nature of the problem.&lt;/p&gt;
&lt;p&gt;What matters is not only whether the model guessed correctly, but also how confident it is in its answer.&lt;/p&gt;
&lt;h5 id=&quot;log-loss-as-the-cost-of-confidence&quot;&gt;Log Loss as the Cost of Confidence&lt;/h5&gt;
&lt;p&gt;Log loss solves exactly this problem. The idea behind this function is very simple: it heavily penalizes the model when it is confidently wrong.&lt;/p&gt;
&lt;p&gt;Suppose the correct answer is 1 (spam), meaning \(y = 1\).&lt;/p&gt;
&lt;p&gt;If the model predicts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;p = 0.9 → small error
p = 0.6 → larger error
p = 0.01 → enormous error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In other words, the more confidently the model is wrong, the stronger the penalty should be.&lt;/p&gt;
&lt;p&gt;This idea is mathematically expressed by the log loss function.&lt;/p&gt;
&lt;p&gt;For a single observation:&lt;/p&gt;
&lt;p&gt;\[
LogLoss = - \left( y \log(\hat{p}) + (1 - y) \log(1 - \hat{p}) \right)
\]&lt;/p&gt;
&lt;p&gt;If \(y = 1\), only this term remains:&lt;/p&gt;
&lt;p&gt;\[
-\log(\hat{p})
\]&lt;/p&gt;
&lt;p&gt;Let us look at a few examples.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Probability  →    Loss
-----------------------
0.9               0.105
0.6               0.51
0.01              4.6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As we can see, being confidently wrong is extremely expensive.&lt;/p&gt;
&lt;p&gt;In practice, the model makes predictions for many observations at once. Therefore, the total loss function is the average log loss across all observations:&lt;/p&gt;
&lt;p&gt;\[
LogLoss = -\frac{1}{n} \sum\_{i=1}^{n} \left(y\_i \log(\hat{p}\_i) + (1 - y\_i) \log(1 - \hat{p}\_i)\right)
\]&lt;/p&gt;
&lt;p&gt;Consider a simple example with three observations.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;y         p̂         loss
-------------------------
1        0.9        0.105
0        0.2        0.223
1        0.6        0.511
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The average log loss will be:&lt;/p&gt;
&lt;p&gt;\[
\frac{0.105 + 0.223 + 0.511}{3} \approx 0.28
\]&lt;/p&gt;
&lt;p&gt;This is the value the model tries to minimize during training.&lt;/p&gt;
&lt;p&gt;Now let us look at the graph of this function – it is very revealing.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15262185139752668881&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Figure 2.1-4. Plot of -log(p) as p → 0 and p → 1&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;when \(\hat{p} \to 1\), the error approaches zero&lt;/li&gt;
&lt;li&gt;when \(\hat{p} \to 0\), the error approaches infinity&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the mathematical expression of the idea:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Being confidently wrong is almost a crime.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Geometric Meaning of Log Loss&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Log loss can be interpreted as a measure of divergence between the real distribution and the predicted probability distribution.&lt;/p&gt;
&lt;p&gt;Formally, it is a special case of cross-entropy:&lt;/p&gt;
&lt;p&gt;\[
H(p, q) = - \sum p \log(q)
\]&lt;/p&gt;
&lt;p&gt;Where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;\(p\) – the true distribution&lt;/li&gt;
&lt;li&gt;\(q\) – the model distribution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=13200333278875913129&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Figure 2.1-5. Two probability distributions and the distance between them&lt;/p&gt;
&lt;p&gt;This makes log loss a natural choice for probabilistic models.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intuitive Comparison Between MSE and Log Loss&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MSE asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;How far off were we in value?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Log loss asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;How wrong were we in our confidence?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is why the following practical rule works well in most real-world cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;regression → MSE&lt;/li&gt;
&lt;li&gt;classification → log loss&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;final-thought&quot;&gt;Final Thought&lt;/h5&gt;
&lt;p&gt;A loss function is the language we use to communicate with the model. Through it, we explain what we consider an error, which mistakes are especially bad, and which ones are acceptable.&lt;/p&gt;
&lt;p&gt;The model knows nothing about money, spam, or the meaning of text. It only knows one thing: where to move in order to reduce the loss.&lt;/p&gt;
&lt;p&gt;In the next chapter, we will see how minimizing loss turns into a concrete learning algorithm – through gradients and parameter updates.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22109/error-loss-functions-and-why-they-are-needed</guid>
<pubDate>Wed, 08 Jul 2026 10:34:00 +0000</pubDate>
</item>
<item>
<title>I objected to deprecating metaphone(). Then I read the RFC.</title>
<link>https://coderlegion.com/21712/i-objected-to-deprecating-metaphone-then-i-read-the-rfc</link>
<description>&lt;p&gt;A while back a proposal landed on the PHP internals list: &lt;a rel=&quot;nofollow&quot; href=&quot;https://discourse.thephp.foundation/t/php-dev-rfc-deprecate-metaphone-function/5391&quot;&gt;deprecate &lt;code&gt;metaphone()&lt;/code&gt;&lt;/a&gt;. My first reaction was the reflex of someone who has spent years as a PHP release master. Leave the old string functions alone, people depend on them, deprecation churn is its own tax. I was ready to argue against it.&lt;/p&gt;
&lt;p&gt;Then I read the reasoning and went looking at what phonetic name matching is actually supposed to do in 2026. I changed my mind. &lt;code&gt;metaphone()&lt;/code&gt; should go. What surprised me was not that the function is dated. It was how far the field had moved while PHP core stood still, and how weak the replacement path the RFC points at really is. So I built the replacement I wish it had recommended.&lt;/p&gt;
&lt;h3 id=&quot;metaphone-is-the-oldest-least-accurate-version-of-an-idea-that-kept-evolving&quot;&gt;metaphone() is the oldest, least accurate version of an idea that kept evolving&lt;/h3&gt;
&lt;p&gt;The RFC is right, and here is the short version of why. The &lt;code&gt;metaphone()&lt;/code&gt; in core is the original 1990 algorithm: English-only, single-key, tuned for one accent of one language. It was superseded twice. First by Double Metaphone (Lawrence Philips, 2000), which emits a primary and an alternate key so ambiguous pronunciations still match. Then by Metaphone 3, which corrects hundreds more edge cases.&lt;/p&gt;
&lt;p&gt;Core shipped the first version and never moved. &lt;code&gt;soundex()&lt;/code&gt; is older still, a 1918 patent. Both encode a single English-centric key, and both fall over the moment a name crosses a language boundary or carries a transliteration variant. For the one job these functions exist to do, collapsing names that sound alike but are spelled differently, they are the weakest tools in the drawer.&lt;/p&gt;
&lt;p&gt;So the deprecation is defensible. Maintaining the least accurate member of a whole family of algorithms, in C, in core, when most applications can do better elsewhere, is not a good use of anyone's time. I came around to that part.&lt;/p&gt;
&lt;h3 id=&quot;-use-a-userland-library-is-the-wrong-replacement&quot;&gt;&quot;Use a userland library&quot; is the wrong replacement&lt;/h3&gt;
&lt;p&gt;Here is where I part ways with the RFC. Its answer to &quot;what do I use instead&quot; is that there are actively maintained Composer libraries implementing Double Metaphone. That is true, and it is also the wrong instinct.&lt;/p&gt;
&lt;p&gt;Phonetic encoding is a hot inner-loop operation. You run it over every name in a dataset, sometimes millions of them, to build a match index you can query later. A pure-PHP implementation pays interpreter overhead on every character of every name. The honest replacement for a native C string function is another native C string function, not a userland reimplementation that is correct but an order of magnitude slower. That performance gap is the entire reason the code lived in core to begin with.&lt;/p&gt;
&lt;p&gt;That is what moved me from &quot;argue against the RFC&quot; to &quot;agree with the RFC, then go build the thing it should have recommended.&quot; The result is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phonetic&quot;&gt;&lt;code&gt;phonetic&lt;/code&gt;&lt;/a&gt;, a native extension that ships the five encoders core never had, plus the comparison helpers that answer the only question most people actually ask.&lt;/p&gt;
&lt;h3 id=&quot;double-metaphone-the-successor-you-can-actually-ship&quot;&gt;Double Metaphone: the successor you can actually ship&lt;/h3&gt;
&lt;p&gt;Double Metaphone is the algorithm the RFC's own rationale reaches for first, and it is the natural default. It returns two keys, a primary and an alternate, so a name with more than one plausible pronunciation matches on either.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;double_metaphone(&quot;Schwarzenegger&quot;);   // ['primary' =&amp;gt; 'XRSN', 'alternate' =&amp;gt; 'XFRT']
double_metaphone(&quot;Catherine&quot;, 3);     // ['primary' =&amp;gt; 'K0R',  'alternate' =&amp;gt; 'KTR']
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The RFC names Metaphone 3 as the other successor. Worth knowing before you reach for it: the Metaphone 3 reference implementation is &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.amorphics.com/buy_metaphone3.html&quot;&gt;a commercial product&lt;/a&gt;, sold as source for $240, not free software. An older 2009 build sits under a BSD license inside OpenRefine, minus years of accuracy corrections. Double Metaphone is the newest variant you can actually vendor into an open project, which is why it, and not Metaphone 3, is what belongs within reach of every PHP app. My implementation is clean-room from the published algorithm, with &lt;a rel=&quot;nofollow&quot; href=&quot;https://commons.apache.org/proper/commons-codec/&quot;&gt;Apache Commons Codec&lt;/a&gt; used only as a parity oracle for the test vectors.&lt;/p&gt;
&lt;h3 id=&quot;beider-morse-matching-across-languages-and-the-gpl-trap-i-had-to-dodge&quot;&gt;Beider-Morse: matching across languages, and the GPL trap I had to dodge&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Moskowitz&lt;/code&gt; and &lt;code&gt;Moskovitz&lt;/code&gt; are one surname through two transliterations. A Cyrillic name like the Russian &lt;em&gt;Ivanov&lt;/em&gt; is one most encoders will not touch. Beider-Morse Phonetic Matching handles both, because it is language-aware. It detects, or is told, the source language family, then applies the right transliteration rules for Slavic, Germanic, Hebrew, and Romance names.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;bmpm(&quot;Garcia&quot;, BMPM_SEPHARDIC, BMPM_EXACT);   // &quot;garsia|gartSa&quot;
bmpm_match(&quot;Moskowitz&quot;, &quot;Moskovitz&quot;);          // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hard part is not the code, it is the data. BMPM is thousands of rules, and every obvious source for those rules is GPL. The canonical Beider-Morse PHP reference is GPL-3.0. abydos, the popular Python phonetic library, is GPL-3.0 specifically because it ported that same rule data. Copy either into a BSD project and the whole project turns GPL with it.&lt;/p&gt;
&lt;p&gt;The escape is Apache Commons Codec, which ships the identical rule tables under Apache-2.0. I vendored the data from there, kept its license header, and added an Apache-2.0 section to the extension's LICENSE. Same data, clean license, and Commons Codec doubles as the parity oracle, so I know the output matches the de-facto reference. The lesson generalizes past this one extension: with phonetic algorithms, the &lt;em&gt;data&lt;/em&gt; carries the license, not the code you wrap around it.&lt;/p&gt;
&lt;p&gt;One honest caveat. BMPM is slow. It runs language detection and three rule passes over the input, so it costs roughly 60 times a Double Metaphone call, around 91,000 names a second on my machine. You pick it for recall, not throughput. When you already know the language, passing it explicitly skips detection and buys some of that back.&lt;/p&gt;
&lt;h3 id=&quot;daitch-mokotoff-soundex-the-genealogy-standard&quot;&gt;Daitch-Mokotoff Soundex: the genealogy standard&lt;/h3&gt;
&lt;p&gt;If you are matching Eastern-European or Ashkenazi surnames, this is the field standard, and core never had it. Daitch-Mokotoff was built for exactly that problem, and it is what genealogy databases actually run. It emits a set of six-digit codes, branching on ambiguous letters so one name can carry several codes at once.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;dm_soundex(&quot;Auerbach&quot;);                     // ['097400', '097500']
dm_soundex_match(&quot;Moskowitz&quot;, &quot;Moskovitz&quot;); // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Its rule data comes from Apache Commons Codec too, for the same licensing reason as BMPM.&lt;/p&gt;
&lt;h3 id=&quot;nysiis-and-match-rating-two-lighter-english-encoders&quot;&gt;NYSIIS and Match Rating: two lighter English encoders&lt;/h3&gt;
&lt;p&gt;Both are cheap single-key encoders for American and English names, useful as a second opinion or an alternate index key. NYSIIS (New York State Identification and Intelligence System) is tuned for American surnames and returns one key. Match Rating Approach (Western Airlines, 1977) produces a compact codex and, unusually, ships its own similarity test instead of relying on key equality.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;nysiis(&quot;Larson&quot;);            // &quot;LARSAN&quot;
nysiis(&quot;Larsen&quot;);            // &quot;LARSAN&quot;  (same key)
match_rating(&quot;Catherine&quot;);   // &quot;CTHRN&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both are clean-room from their published specs. They are the fastest encoders in the set, cheap enough to run as a second key alongside Double Metaphone when you want a little more recall without paying BMPM's cost.&lt;/p&gt;
&lt;h3 id=&quot;the-part-i-use-most-the-comparison-helpers&quot;&gt;The part I use most: the comparison helpers&lt;/h3&gt;
&lt;p&gt;The real day-to-day question is never &quot;encode this name.&quot; It is &quot;do these two names sound alike,&quot; and every algorithm answers it differently. That difference is where userland code quietly gets phonetic matching wrong.&lt;/p&gt;
&lt;p&gt;Double Metaphone gives two keys; you match on primary agreement, weaker if only an alternate crosses. Daitch-Mokotoff and BMPM give sets; you match on intersection, not equality. Match Rating has a length-and-rating threshold that plain codex comparison skips entirely. Get that logic wrong and you either miss real matches or wave through garbage. So the extension ships one comparison helper per algorithm, each encoding the correct test for that encoder:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;double_metaphone_match(&quot;Catherine&quot;, &quot;Kathryn&quot;);   // 2  (primary keys agree)
double_metaphone_match(&quot;Vagner&quot;, &quot;Wagner&quot;);        // 1  (only an alternate crosses)
dm_soundex_match(&quot;Moskowitz&quot;, &quot;Moskovitz&quot;);        // true
bmpm_match(&quot;Peterson&quot;, &quot;Petersen&quot;);                // true
nysiis_match(&quot;Smith&quot;, &quot;Schmit&quot;);                   // true  (both SNAT)
match_rating_compare(&quot;Catherine&quot;, &quot;Kathryn&quot;);      // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;double_metaphone_match()&lt;/code&gt; returns 2, 1, or 0, so you can rank match strength instead of treating it as a coin flip. The set-based helpers return a bool on intersection. &lt;code&gt;match_rating_compare()&lt;/code&gt; applies the threshold the algorithm actually specifies. You call one function and get the right answer for that encoder, rather than reimplementing set intersection in a loop and getting the edge cases subtly wrong.&lt;/p&gt;
&lt;p&gt;For a one-off check, that is the whole API. For repeated lookups against a fixed corpus, encode once and index the keys, then query by encoded value instead of comparing pair by pair:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$index = [];
foreach ($records as $id =&amp;gt; $name) {
    foreach (dm_soundex($name) as $code) {   // index every code in the set
        $index[$code][] = $id;
    }
}
$hits = $index[dm_soundex(&quot;Moskovitz&quot;)[0]] ?? [];
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;which-one-do-i-reach-for-&quot;&gt;Which one do I reach for?&lt;/h3&gt;
&lt;p&gt;Double Metaphone as the fast general-purpose default. BMPM when names cross languages or scripts and you can afford the cost. Daitch-Mokotoff for Eastern-European and Jewish genealogy, where it is the standard. NYSIIS or Match Rating as a cheap second key. The relative costs matter when you are encoding at scale:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;encoder &lt;/th&gt;&lt;th&gt;relative speed &lt;/th&gt;&lt;th&gt;strongest for&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;match_rating()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;fastest (0.24x) &lt;/td&gt;&lt;td&gt;English names; ships its own similarity test&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;nysiis()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;fast (0.42x) &lt;/td&gt;&lt;td&gt;American and English surnames&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;double_metaphone()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;fast (1.0x baseline) &lt;/td&gt;&lt;td&gt;general Latin-script names&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;dm_soundex()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;middle (~2.3x) &lt;/td&gt;&lt;td&gt;Eastern-European and Ashkenazi surnames&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;bmpm()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;slowest (~60x) &lt;/td&gt;&lt;td&gt;cross-language and transliteration variants&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two limits worth stating plainly. These are heuristic, culture-bound encoders. They target Latin-script names, and for BMPM and Daitch-Mokotoff, specific language families. They are not a universal global-name solution. And Greek-script input has a known limitation: capitals are not lowercased, because the context-sensitive final-sigma rule cannot be expressed as a point-wise case map, so Greek names need to be passed lowercased or romanized.&lt;/p&gt;
&lt;h3 id=&quot;where-i-landed&quot;&gt;Where I landed&lt;/h3&gt;
&lt;p&gt;I set out to defend &lt;code&gt;metaphone()&lt;/code&gt; and finished by agreeing it should be deprecated. The function is the weakest version of an idea that produced far better tools over the thirty years since. The flaw in the deprecation story is not the deprecation. It is pointing at a slower userland library as the replacement for a native one. Phonetic encoding runs in a hot loop, so it belongs in C.&lt;/p&gt;
&lt;p&gt;The five encoders core never shipped are now one native extension, with the &quot;do these sound alike&quot; helpers most callers actually need:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;pie install iliaal/phonetic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phonetic&quot;&gt;github.com/iliaal/phonetic&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21712/i-objected-to-deprecating-metaphone-then-i-read-the-rfc</guid>
<pubDate>Wed, 01 Jul 2026 13:56:49 +0000</pubDate>
</item>
</channel>
</rss>