<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jaafar Abazid</title>
    <description>The latest articles on DEV Community by Jaafar Abazid (@jaafarabazid).</description>
    <link>https://dev.to/jaafarabazid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3987998%2F272a2289-3e99-49db-98ad-4fc16535555f.jpg</url>
      <title>DEV Community: Jaafar Abazid</title>
      <link>https://dev.to/jaafarabazid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jaafarabazid"/>
    <language>en</language>
    <item>
      <title>Arabic search in WordPress: the matches it silently misses</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Fri, 24 Jul 2026 23:34:45 +0000</pubDate>
      <link>https://dev.to/mantekio/arabic-search-in-wordpress-the-matches-it-silently-misses-3m7e</link>
      <guid>https://dev.to/mantekio/arabic-search-in-wordpress-the-matches-it-silently-misses-3m7e</guid>
      <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 href="https://www.mantek.io/insights/wordpress-arabic-slug-truncation" rel="noopener noreferrer"&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;h2&gt;
  
  
  What WordPress actually does with an Arabic query
&lt;/h2&gt;

&lt;p&gt;Type a term into WordPress search and &lt;code&gt;WP_Query&lt;/code&gt; builds this, near enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_title&lt;/span&gt;   &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%term%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;post_excerpt&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%term%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%term%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;div class="table-wrapper-paragraph"&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;/div&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ulz1eu60sv029i4k5wb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ulz1eu60sv029i4k5wb.png" alt="One word, many spellings: why LIKE misses and normalising matches" width="800" height="318"&gt;&lt;/a&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;h2&gt;
  
  
  The database already knows. WordPress never asks it.
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'مُحَمَّد'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'محمد'&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;utf8mb4_unicode_520_ci&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'مُحَمَّد'&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%محمد%'&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;utf8mb4_unicode_520_ci&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F79grli3v5irgdtngag5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F79grli3v5irgdtngag5q.png" alt="The collation folds Arabic under = but not under LIKE" width="800" height="318"&gt;&lt;/a&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;h2&gt;
  
  
  Even reaching it would not be enough
&lt;/h2&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;h2&gt;
  
  
  What it costs, and why the broken searches are the expensive ones
&lt;/h2&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 "is slow to use one", cannot use one, ever. &lt;code&gt;EXPLAIN&lt;/code&gt; on the query WordPress generates says it plainly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type: ALL     key: NULL     rows: 50013
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit: 10 row(s) (no early end due to SQL_CALC_FOUND_ROWS)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  What we built, and what it fixed
&lt;/h2&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;h2&gt;
  
  
  What it did not fix
&lt;/h2&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;h2&gt;
  
  
  The layer we were missing
&lt;/h2&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;h2&gt;
  
  
  The plugin
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require mantekio/wp-arabic-search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp arabic-search reindex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WPAS_MODE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'normaliser'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="n"&gt;wp&lt;/span&gt; &lt;span class="n"&gt;arabic&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;search&lt;/span&gt; &lt;span class="k"&gt;explain&lt;/span&gt; &lt;span class="nv"&gt;"من المكتبة"&lt;/span&gt;
&lt;span class="n"&gt;normalised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="err"&gt;من&lt;/span&gt; &lt;span class="err"&gt;مكتبه&lt;/span&gt;

&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;--------+------+--------------------------------------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;                                           &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;--------+------+--------------------------------------------------+&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="err"&gt;من&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;no&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;shorter&lt;/span&gt; &lt;span class="k"&gt;than&lt;/span&gt; &lt;span class="n"&gt;innodb_ft_min_token_size&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="err"&gt;مكتبه&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;indexable&lt;/span&gt;                                        &lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="c1"&gt;--------+------+--------------------------------------------------+&lt;/span&gt;

&lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;AGAINST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+مكتبه'&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 "search is broken" and "search is working exactly as designed on a word the index cannot hold" 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;div class="table-wrapper-paragraph"&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;/div&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;div class="table-wrapper-paragraph"&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;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type: ALL        key: NULL        rows: 50013
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After, the index drives it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type: fulltext   key: search_ft   rows: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;div class="table-wrapper-paragraph"&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;/div&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 href="https://github.com/mantekio/wp-arabic-search" rel="noopener noreferrer"&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;h2&gt;
  
  
  Known limits
&lt;/h2&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;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 href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&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 href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>arabic</category>
      <category>mysql</category>
      <category>php</category>
    </item>
    <item>
      <title>WordPress cron behind a load balancer: fixing 'Missed schedule'</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Wed, 22 Jul 2026 17:33:33 +0000</pubDate>
      <link>https://dev.to/mantekio/wordpress-cron-behind-a-load-balancer-fixing-missed-schedule-2184</link>
      <guid>https://dev.to/mantekio/wordpress-cron-behind-a-load-balancer-fixing-missed-schedule-2184</guid>
      <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;h2&gt;
  
  
  wp-cron is not cron
&lt;/h2&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;h2&gt;
  
  
  The fix everyone gives you, and the one it hides
&lt;/h2&gt;

&lt;p&gt;Search this and every answer is the same two lines: switch the loopback off, and drive cron yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISABLE_WP_CRON'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; wget &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-O&lt;/span&gt; - https://www.example.com/wp-cron.php?doing_wp_cron &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9ybrx2mu50qtaeeudc3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9ybrx2mu50qtaeeudc3.png" alt="Two ways to trigger cron, and why the HTTP one keeps missing" width="800" height="318"&gt;&lt;/a&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;h2&gt;
  
  
  Stop hitting a URL
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 "keep hitting the server so cron stays alive" and "run cron". 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;h2&gt;
  
  
  Which machine?
&lt;/h2&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;h2&gt;
  
  
  Run everywhere, execute once
&lt;/h2&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fihslcv3dplzgav7cfos3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fihslcv3dplzgav7cfos3.png" alt="One runner, elected by a shared lock" width="800" height="318"&gt;&lt;/a&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;GET_LOCK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_fleet_cron'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  Make the silence loud
&lt;/h2&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, "is cron running?", into a check anything can make:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp fleet-cron status &lt;span class="o"&gt;||&lt;/span&gt; send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://www.mantek.io/insights/wordpress-email-ses" rel="noopener noreferrer"&gt;our WordPress mail plugin&lt;/a&gt;: a failure you cannot see is worse than one that shouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plugin
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISABLE_WP_CRON'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/html &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wp fleet-cron run &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then hand the health check to your monitoring, so a stall reaches you rather than an editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp fleet-cron status &lt;span class="o"&gt;||&lt;/span&gt; send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or install it with Composer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require mantekio/wp-fleet-cron
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/mantekio/wp-fleet-cron" rel="noopener noreferrer"&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;h2&gt;
  
  
  Known limits
&lt;/h2&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;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 href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&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 href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>aws</category>
      <category>php</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your WordPress says the email sent. It didn't.</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:01:04 +0000</pubDate>
      <link>https://dev.to/mantekio/your-wordpress-says-the-email-sent-it-didnt-38m4</link>
      <guid>https://dev.to/mantekio/your-wordpress-says-the-email-sent-it-didnt-38m4</guid>
      <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;h2&gt;
  
  
  Why WordPress email is broken by default
&lt;/h2&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdprk9wp0my3v4kzdz3n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdprk9wp0my3v4kzdz3n.png" alt="Two paths for the same message: the default drops it, the plugin signs it" width="800" height="318"&gt;&lt;/a&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;h2&gt;
  
  
  Why the usual fix is a half-fix
&lt;/h2&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 href="https://www.mantek.io/insights/wordpress-s3-media-pipeline" rel="noopener noreferrer"&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;h2&gt;
  
  
  The architecture: three moves
&lt;/h2&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 href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&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;h3&gt;
  
  
  A seam, not a fork
&lt;/h3&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 href="https://www.mantek.io/insights/wordpress-arabic-slug-truncation" rel="noopener noreferrer"&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$phpmailer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$phpmailer&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;PHPMailer\PHPMailer\PHPMailer&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...core builds the mailer...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WPSES_Mailer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;\PHPMailer\PHPMailer\PHPMailer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;postSend&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$raw&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSentMIMEMessage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// core built this, not us&lt;/span&gt;
        &lt;span class="nv"&gt;$sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wpses_send_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* recipients */&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$sent&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Throwing lands in core's own wp_mail_failed path.&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\PHPMailer\PHPMailer\Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$sent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_error_message&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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;h2&gt;
  
  
  Fail loudly, because the alternative is what we are here to fix
&lt;/h2&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcrwuuzl6o1afsoez3uyo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcrwuuzl6o1afsoez3uyo.png" alt="A rejection you can see, versus one you cannot" width="800" height="318"&gt;&lt;/a&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;h2&gt;
  
  
  The part every tutorial skips: bounces and complaints
&lt;/h2&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 href="https://www.mantek.io/insights/wordpress-arabic-slug-truncation" rel="noopener noreferrer"&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;h2&gt;
  
  
  The traps worth naming
&lt;/h2&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;h2&gt;
  
  
  Verifying it, and why the checker refuses to lie
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp ses verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp ses verify &lt;span class="o"&gt;||&lt;/span&gt; send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://www.mantek.io/insights/wordpress-arabic-slug-truncation" rel="noopener noreferrer"&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;h2&gt;
  
  
  The plugin
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require mantekio/wp-ses-mail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository is &lt;a href="https://github.com/mantekio/wp-ses-mail" rel="noopener noreferrer"&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;h2&gt;
  
  
  Known limits
&lt;/h2&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;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 href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&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 href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>aws</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The day YouTube deleted our channel</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Sat, 04 Jul 2026 22:39:51 +0000</pubDate>
      <link>https://dev.to/mantekio/the-day-youtube-deleted-our-channel-1c3j</link>
      <guid>https://dev.to/mantekio/the-day-youtube-deleted-our-channel-1c3j</guid>
      <description>&lt;p&gt;It took a single notice. The YouTube channel that held years of a newsroom's&lt;br&gt;
video was gone in one action, and every video embed across the site was suddenly&lt;br&gt;
pointing at something that no longer existed. On a publication that runs video on&lt;br&gt;
most of its articles, that is the kind of morning that empties an archive.&lt;/p&gt;

&lt;p&gt;It was a non-event. We changed one setting, the whole site began playing the same&lt;br&gt;
videos from our own storage, and readers never saw a gap. This is the story of why&lt;br&gt;
a deleted channel was a configuration change rather than a catastrophe, and the one&lt;br&gt;
decision, made years earlier, that made the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platforms can delete you, and you do not get a vote
&lt;/h2&gt;

&lt;p&gt;Channels and accounts get removed for all sorts of reasons, and most of them are&lt;br&gt;
not a fair fight: an automated copyright match against music in the background, a&lt;br&gt;
strike from a report, a policy you did not know had changed, a regional block, an&lt;br&gt;
API quietly deprecated, or an entire product shut down (the internet is littered&lt;br&gt;
with services that were permanent right up until they were not).&lt;/p&gt;

&lt;p&gt;The common thread is that the decision is theirs, the timing is theirs, and the&lt;br&gt;
content you put on their platform was never fully in your hands. For a newsroom,&lt;br&gt;
video on a third-party platform is best treated as reach, not as custody.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish to platforms, never store on them
&lt;/h2&gt;

&lt;p&gt;The decision that turned the incident into a shrug was made on the very first&lt;br&gt;
upload: every video goes to our own S3 bucket first. The original lives with us.&lt;br&gt;
Pushing a copy out to YouTube, Dailymotion or Vimeo is a distribution step, for&lt;br&gt;
reach and free bandwidth, not the place the file actually lives.&lt;/p&gt;

&lt;p&gt;It is the same discipline behind &lt;a href="https://www.mantek.io/insights/wordpress-s3-media-pipeline" rel="noopener noreferrer"&gt;the rest of our media handling&lt;/a&gt;: the original is&lt;br&gt;
offloaded to storage we control on upload, and everything downstream is a copy or&lt;br&gt;
a rendition of it. So when a platform pulls a video, or an entire channel, nothing&lt;br&gt;
is actually lost. The master is still sitting in the bucket exactly where it was&lt;br&gt;
put.&lt;/p&gt;

&lt;h2&gt;
  
  
  One video, four sources
&lt;/h2&gt;

&lt;p&gt;On top of that, each video is a single logical asset with up to four play sources&lt;br&gt;
linked behind it: YouTube, Dailymotion, Vimeo, and Amazon (the file served from&lt;br&gt;
our own S3 through CloudFront).&lt;/p&gt;

&lt;p&gt;The important word is &lt;em&gt;source&lt;/em&gt;. Where a video plays is a stored setting, not an&lt;br&gt;
embed baked into the post. That one distinction is the whole game. If the player&lt;br&gt;
reads a source field, you can change where every video comes from without editing&lt;br&gt;
a single article. If instead every post contains a literal YouTube iframe,&lt;br&gt;
changing the source later is a database migration under pressure, which is exactly&lt;br&gt;
when you do not want one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhuw37gyfqs2o9qg05n7n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhuw37gyfqs2o9qg05n7n.png" alt="The play source is a dial, not a hard-coded embed" width="799" height="370"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The original lives in S3 and is published out to YouTube, Dailymotion and Vimeo. Where a video actually plays is a stored setting, a site-wide default with a per-video override, so any source can be the active one.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two dials: site-wide and per video
&lt;/h2&gt;

&lt;p&gt;The source has two levels of control.&lt;/p&gt;

&lt;p&gt;Site-wide, you set the default that every video plays from. We ran on YouTube,&lt;br&gt;
because that is where the audience and the bandwidth were.&lt;/p&gt;

&lt;p&gt;Per video, you can override that default, and in a newsroom that is not a rare&lt;br&gt;
event. Long videos that carry clips or background music from other sources get&lt;br&gt;
flagged by YouTube's automated Content ID constantly, often even when you hold the&lt;br&gt;
rights, and clearing a strike means filing an appeal and waiting days. So those&lt;br&gt;
videos are set, right there on the upload screen, to play from Dailymotion, Vimeo&lt;br&gt;
or S3 instead, while the rest of the library stays on YouTube. The content most&lt;br&gt;
likely to be struck simply sidesteps the platform that polices it hardest, with no&lt;br&gt;
downtime and no appeal queue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2mcgy1redzxvuy4ugwd1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2mcgy1redzxvuy4ugwd1.png" alt="Two levels of control: the site default and the per-video override" width="799" height="377"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The site-wide default sets the source for every video at once. A per-video override pins a single clip to another source: the long documentary full of licensed music is set to Vimeo on the upload screen, so it sidesteps the platform most likely to strike it, while everything else stays on the default.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The day the whole channel went
&lt;/h2&gt;

&lt;p&gt;Then the version of the problem you cannot patch one video at a time: the entire&lt;br&gt;
channel was deleted at once. Every video that defaulted to YouTube would fail in&lt;br&gt;
the player, all at the same moment, across the whole archive.&lt;/p&gt;

&lt;p&gt;Because the play source was a single site-wide setting, and because every original&lt;br&gt;
was already sitting in S3, the fix was to change the default from YouTube to Amazon&lt;br&gt;
and save. Every embed on the site re-pointed to the S3 copies, served through&lt;br&gt;
CloudFront, in one action. The outage was as long as it takes to change a dropdown.&lt;/p&gt;

&lt;p&gt;Compare that to the alternative if YouTube had been the storage rather than a&lt;br&gt;
distribution target: the archive would simply be gone, and the "recovery" would be&lt;br&gt;
re-sourcing and re-uploading thousands of videos that no longer existed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn21wmjf5l37h9otkh5ar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn21wmjf5l37h9otkh5ar.png" alt="One setting, the whole archive back" width="799" height="359"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;When the channel was deleted, every video that defaulted to YouTube failed at once. Because the play source is a single site-wide setting and the originals were already in S3, changing the default to Amazon restored the entire archive in one click.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback is not a downgrade
&lt;/h2&gt;

&lt;p&gt;It is worth being clear that playing from your own infrastructure is not a degraded&lt;br&gt;
emergency mode. The S3 source plays through CloudFront like any other CDN-backed&lt;br&gt;
asset. When you want adaptive streaming at scale, you put it in front: an upload&lt;br&gt;
event triggers AWS Elemental MediaConvert to produce HLS renditions, CloudFront&lt;br&gt;
caches the segments, and a standard player streams quality that adapts to each&lt;br&gt;
viewer's connection.&lt;/p&gt;

&lt;p&gt;In other words, falling back to your own storage is not making do. For many sites&lt;br&gt;
it is the best source they have. The platforms were the convenience, not the&lt;br&gt;
foundation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is really about: custody
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and the principle is simple. Own the original, and treat&lt;br&gt;
every external platform as interchangeable reach you opt into, never as the home of&lt;br&gt;
your media. In practice that is three habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the master somewhere you control (for us, S3).&lt;/li&gt;
&lt;li&gt;Make playback a setting, not a hard-coded embed, so the source is a dial.&lt;/li&gt;
&lt;li&gt;Publish to platforms for reach, but never let one become your only copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do that, and platform risk (a strike, a deleted channel, a shut-down service, a&lt;br&gt;
pricing change you cannot stomach) stops being an existential event and becomes a&lt;br&gt;
switch you flip. The same thinking applies anywhere you syndicate: images to a CDN,&lt;br&gt;
articles to third-party readers, audio to podcast directories. Keep the master,&lt;br&gt;
keep the destination swappable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A two-minute gut check for your own setup
&lt;/h2&gt;

&lt;p&gt;Three questions worth answering honestly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If your video platform closed your account tonight, do the originals exist
anywhere else?&lt;/strong&gt; If the only copy is on the platform, that is the first thing to
fix, before anything clever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is playback a per-post embed, or a source you can change centrally?&lt;/strong&gt; If it is
baked into content, switching later is a migration, not a setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you publish to platforms, or store on them?&lt;/strong&gt; Publishing is healthy.
Storing is the trap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers make you uncomfortable, you already know where to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;We keep newsrooms fast and unbreakable on &lt;a href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&gt;WordPress and AWS&lt;/a&gt;, and&lt;br&gt;
this is the resilience side of the same coin as keeping the media pipeline lean:&lt;br&gt;
the originals never leave your control, so neither a platform nor a traffic spike&lt;br&gt;
can take the archive down.&lt;/p&gt;

&lt;p&gt;If your video lives on someone else's platform and losing it would hurt,&lt;br&gt;
&lt;a href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/mediaconvert/" rel="noopener noreferrer"&gt;AWS Elemental MediaConvert&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aws.amazon.com/s3/" rel="noopener noreferrer"&gt;Amazon S3&lt;/a&gt; and &lt;a href="https://aws.amazon.com/cloudfront/" rel="noopener noreferrer"&gt;Amazon CloudFront&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>aws</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Make WordPress do less: offloading media to S3 and the edge</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Mon, 29 Jun 2026 23:10:24 +0000</pubDate>
      <link>https://dev.to/mantekio/make-wordpress-do-less-offloading-media-to-s3-and-the-edge-5f72</link>
      <guid>https://dev.to/mantekio/make-wordpress-do-less-offloading-media-to-s3-and-the-edge-5f72</guid>
      <description>&lt;p&gt;A default WordPress install does a surprising amount of work every time someone&lt;br&gt;
uploads an image. It generates a stack of resized copies on the spot (thumbnail,&lt;br&gt;
medium, medium_large, large, a couple of extra-large sizes, plus whatever the&lt;br&gt;
theme registers), writes every one of them to disk, and records them in the&lt;br&gt;
attachment metadata. Most are never requested. The CMS we replaced for one&lt;br&gt;
newsroom generated more than &lt;strong&gt;twenty&lt;/strong&gt; versions of every single image and stored&lt;br&gt;
all of them. Multiply that across an archive of hundreds of thousands of photos&lt;br&gt;
and you are paying, in storage and in upload-time CPU, for work nobody asked for.&lt;/p&gt;

&lt;p&gt;Video is the opposite extreme. A single original broadcast clip can run to several&lt;br&gt;
gigabytes, and pushing that upload through the web server (PHP memory limits,&lt;br&gt;
request timeouts, a reverse proxy in the middle) is a reliable way to fall over.&lt;/p&gt;

&lt;p&gt;The media pipeline we run for &lt;a href="https://www.mantek.io/nuz" rel="noopener noreferrer"&gt;NUZ&lt;/a&gt; takes both problems off the server. On&lt;br&gt;
the way in, the browser uploads large files straight to S3. On the way out, the&lt;br&gt;
edge produces exactly the image sizes that are actually requested, and nothing&lt;br&gt;
else. WordPress goes back to doing what it is good at, which is editorial, not&lt;br&gt;
image processing. This is the same WordPress-on-AWS work we have written about&lt;br&gt;
&lt;a href="https://www.mantek.io/insights/breaking-news-wordpress-aws-architecture" rel="noopener noreferrer"&gt;surviving breaking-news spikes&lt;/a&gt;&lt;br&gt;
with, viewed from the storage side.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Two ideas carry the whole design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Store little, derive on demand.&lt;/strong&gt; For images, keep two copies in S3: the
pristine original in cold storage, and one working master in hot storage. Every
size a visitor sees is produced on the fly at the CDN and lives only in the
CDN cache, never in S3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the heavy lifting to the ends.&lt;/strong&gt; Large uploads go from the browser
directly to S3. Transformations happen at the edge. The origin server stays
out of the byte path in both directions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything below is an application of those two rules, plus the WordPress glue&lt;br&gt;
that makes them invisible to editors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f3htrakejygn1a9hfpl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f3htrakejygn1a9hfpl.png" alt="The media pipeline: one upload, two copies, every size from the edge" width="800" height="354"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One upload becomes two stored objects: a cold original you keep, and a hot working master the edge resizes on demand. Every size a visitor sees is produced by the Image Handler and cached at the CDN, never stored in S3.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Stop WordPress from making thumbnails
&lt;/h2&gt;

&lt;p&gt;This is the change that surprises people, so it goes first: we turn off&lt;br&gt;
WordPress's intermediate image generation entirely. If the edge can produce any&lt;br&gt;
size on request, pre-generating a fixed menu of sizes on upload is pure waste:&lt;br&gt;
CPU and I/O at the worst possible moment (while the editor waits), and a pile of&lt;br&gt;
files that mostly never get served.&lt;/p&gt;

&lt;p&gt;In the NUZ theme it is a couple of filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stop WordPress generating intermediate sizes on upload.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'intermediate_image_sizes_advanced'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_empty_array'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Keep the true original: don't let WordPress downscale large uploads to 2560px.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'big_image_size_threshold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_false'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one catch worth planning for: once WordPress stops creating named sizes,&lt;br&gt;
any code that asks for &lt;code&gt;the_post_thumbnail( 'medium' )&lt;/code&gt; or a registered size has&lt;br&gt;
to resolve to the edge instead. So the same theme layer maps each requested size&lt;br&gt;
to an edge URL with the right dimensions. Skip that step and you get broken&lt;br&gt;
images where a &lt;code&gt;medium&lt;/code&gt; used to be. Handle it once, in the hooks that build image&lt;br&gt;
URLs, and editors never notice the difference.&lt;/p&gt;
&lt;h2&gt;
  
  
  Resize at the edge, not on upload
&lt;/h2&gt;

&lt;p&gt;The hot working master is the only image S3 serves to the transformer, and the&lt;br&gt;
transformer is the &lt;a href="https://aws.amazon.com/solutions/implementations/serverless-image-handler/" rel="noopener noreferrer"&gt;AWS Serverless Image&lt;br&gt;
Handler&lt;/a&gt;:&lt;br&gt;
CloudFront in front of a Lambda that runs &lt;a href="https://sharp.pixelplumbing.com/" rel="noopener noreferrer"&gt;Sharp&lt;/a&gt;. A request describes the&lt;br&gt;
transformation it wants in the URL, the Lambda produces it once, and CloudFront&lt;br&gt;
caches the result. The URL form is configurable; the Thumbor-style path is easy&lt;br&gt;
to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cdn.example.com/fit-in/800x0/filters:format(webp):quality(80)/20250727-1753632511534.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Format negotiation.&lt;/strong&gt; Ask for &lt;code&gt;format(auto)&lt;/code&gt; and modern browsers get AVIF or
WebP by their &lt;code&gt;Accept&lt;/code&gt; header, older ones get JPEG, from one master.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute only on a miss.&lt;/strong&gt; The Lambda runs the first time a given size is
requested. After that CloudFront serves it. Sizes nobody requests cost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing extra in S3.&lt;/strong&gt; Derivatives live in the CDN cache, not the bucket. The
bucket holds two objects per image, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Two copies, two storage classes
&lt;/h3&gt;

&lt;p&gt;The original and the master have different jobs, so they get different homes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Copy&lt;/th&gt;
&lt;th&gt;Storage tier&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Original&lt;/strong&gt; (full-resolution, as shot)&lt;/td&gt;
&lt;td&gt;Cold · S3 Glacier Instant Retrieval&lt;/td&gt;
&lt;td&gt;Rarely read, but you may need it fast one day; millisecond retrieval at a fraction of Standard's price&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Working master&lt;/strong&gt; (high-quality, max served dimension)&lt;/td&gt;
&lt;td&gt;Hot · S3 Standard&lt;/td&gt;
&lt;td&gt;The transformer's source; read often, downscaled to every served size&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A lifecycle policy moves originals to the cold class automatically. We avoid&lt;br&gt;
Glacier Deep Archive precisely because retrieval there takes hours, and the whole&lt;br&gt;
point of keeping the original is to be able to re-derive from it. The master is not the raw original. It is a web-optimised copy: sized to the&lt;br&gt;
largest dimension you will ever serve (no 4K masters when nothing on the page ever&lt;br&gt;
renders past, say, 2048px) and stored in an efficient format (WebP for images,&lt;br&gt;
WebM for video). Optimise the dimensions and the format, but keep the quality&lt;br&gt;
near-lossless, so every derivative downscales from a clean source rather than&lt;br&gt;
inheriting compression artefacts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Clean keys, by generation not by accident
&lt;/h2&gt;

&lt;p&gt;We never store a file under the name the user uploaded. On upload we generate an&lt;br&gt;
ASCII-safe, time-based key with a millisecond stamp and a short random suffix,&lt;br&gt;
something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20250727-1753632511534
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator is small: a UTC date, a millisecond timestamp, and a short random&lt;br&gt;
suffix against same-millisecond collisions, with the extension carried over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;nuz_media_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$ext&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Ymd'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$stamp&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;wp_generate_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$ext&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// e.g. 20250727-1753632511534-h7k2qp.jpg&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an Arabic newsroom this is not cosmetic. Original filenames arrive full of&lt;br&gt;
Arabic characters and spaces, which become percent-encoded mojibake the moment&lt;br&gt;
they enter a URL or an object key, the same class of encoding trap we dissected&lt;br&gt;
in &lt;a href="https://www.mantek.io/insights/wordpress-arabic-slug-truncation" rel="noopener noreferrer"&gt;the 200-byte slug problem&lt;/a&gt;.&lt;br&gt;
Generating the key sidesteps all of it: keys stay short, ASCII, and predictable.&lt;/p&gt;

&lt;p&gt;There is a quiet bonus. Because every upload gets a unique key, &lt;strong&gt;replacing an&lt;br&gt;
asset produces a new URL&lt;/strong&gt;, so there is no stale-cache problem to solve at the&lt;br&gt;
CDN. You never have to invalidate; the old object simply stops being referenced.&lt;br&gt;
Clean keys and cache-busting turn out to be the same decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  The WordPress glue
&lt;/h2&gt;

&lt;p&gt;None of this should leak into the editing experience, and the seam that keeps it&lt;br&gt;
invisible is URL generation. The NUZ theme overrides WordPress's default asset&lt;br&gt;
handling with hooks rather than rewriting the database, so the content stays&lt;br&gt;
portable and the behaviour is reversible. The functions that build image URLs&lt;br&gt;
(&lt;code&gt;wp_get_attachment_url&lt;/code&gt;, &lt;code&gt;wp_get_attachment_image_src&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;wp_calculate_image_srcset&lt;/code&gt; and friends) return edge URLs at render time. In&lt;br&gt;
practice that is one filter per entry point, each mapping the requested size to a&lt;br&gt;
transformation the edge understands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Point a requested size at the Serverless Image Handler instead of a local file.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_get_attachment_image_src'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$h&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nuz_dimensions_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 'medium' =&amp;gt; [800, 0], etc.&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_wp_attached_file'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'https://cdn.example.com/fit-in/%dx%d/filters:format(auto):quality(80)/%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;📦 &lt;strong&gt;Prefer it ready-made?&lt;/strong&gt; The image-delivery half of this (disabling intermediate sizes and rewriting image URLs to the Image Handler, with a synthesized &lt;code&gt;srcset&lt;/code&gt;) is open-source as &lt;a href="https://github.com/mantekio/wp-edge-images" rel="noopener noreferrer"&gt;&lt;code&gt;mantekio/wp-edge-images&lt;/code&gt;&lt;/a&gt;, or &lt;code&gt;composer require mantekio/wp-edge-images&lt;/code&gt;. It is URL layer only, so it pairs with any S3 offload.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are migrating an existing library, one caveat is worth stating plainly:&lt;br&gt;
the URLs that actually serve images live in &lt;code&gt;post_content&lt;/code&gt; (embedded when an&lt;br&gt;
image is inserted) and are generated from the &lt;code&gt;_wp_attached_file&lt;/code&gt; meta, &lt;strong&gt;not&lt;/strong&gt; in&lt;br&gt;
the &lt;code&gt;guid&lt;/code&gt; column. The guid looks like the file URL but is an identifier, and&lt;br&gt;
WordPress documentation is explicit that you should not rewrite it. So a one-time&lt;br&gt;
migration targets &lt;code&gt;post_content&lt;/code&gt;; ongoing serving is best handled with the&lt;br&gt;
runtime hooks above.&lt;/p&gt;
&lt;h2&gt;
  
  
  Big video: let the browser do the upload
&lt;/h2&gt;

&lt;p&gt;Images are small and cheap to transform, so we generate them on demand. Video is&lt;br&gt;
neither, so it plays by opposite rules.&lt;/p&gt;

&lt;p&gt;The problem is the upload itself. Original television files are large, and they&lt;br&gt;
should not travel through WordPress at all. So the editor works in a custom&lt;br&gt;
upload screen that asks our admin endpoint for a short-lived &lt;strong&gt;presigned S3 URL&lt;/strong&gt;,&lt;br&gt;
then sends the file from the browser straight to the bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1) ask the server for a presigned URL, scoped to one key + content type, short-lived&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/nuz/presign?type=video/mp4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// 2) PUT the file directly to S3 from the browser, with a real progress bar&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;putWithProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// XHR upload events drive the bar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server the presign is a few lines with the AWS SDK, scoped to one key, one&lt;br&gt;
content type, and a short expiry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$s3&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Aws\S3\S3Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'region'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'me-central-1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'latest'&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'videos/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;nuz_media_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'mp4'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$s3&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createPresignedRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$s3&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'PutObject'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'Bucket'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'nuz-media-hot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Key'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'ContentType'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'video/mp4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'+15 minutes'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;wp_send_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server signs a tightly scoped, expiring URL and never touches the bytes.&lt;br&gt;
The browser handles the transfer, the upload screen shows a progress bar (a&lt;br&gt;
multi-gigabyte broadcast file can take a while, and editors need to see it&lt;br&gt;
moving), and the same screen collects the title, the cover image, and the rest of&lt;br&gt;
the metadata. From there, one click distributes the video to YouTube, Dailymotion&lt;br&gt;
or Vimeo, streamed directly from S3 to each platform's resumable-upload API. The&lt;br&gt;
editor never uploads again: the file already lives in S3, so distribution is a&lt;br&gt;
server-to-server transfer, not a second upload of the file. The newsroom publishes&lt;br&gt;
once and distributes everywhere, without a gigabyte ever passing through PHP.&lt;/p&gt;

&lt;p&gt;For on-site playback you extend the same pattern: an S3 upload event triggers&lt;br&gt;
&lt;strong&gt;AWS Elemental MediaConvert&lt;/strong&gt;, which transcodes the source into adaptive-bitrate&lt;br&gt;
renditions (HLS or DASH) plus a poster frame, stores the renditions in the hot&lt;br&gt;
bucket, and serves them through CloudFront to an &lt;code&gt;hls.js&lt;/code&gt; player. Note the&lt;br&gt;
inversion from images: video transformation is expensive, so you do it &lt;strong&gt;once on&lt;br&gt;
upload and store the result&lt;/strong&gt;, rather than per request. Storing little works for&lt;br&gt;
images precisely because resizing is cheap; storing renditions works for video&lt;br&gt;
precisely because transcoding is not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fljn9wrz7w4erxbakk6gx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fljn9wrz7w4erxbakk6gx.png" alt="Images and video play by opposite rules" width="799" height="359"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Images are cheap to resize, so the edge generates each size on demand and stores nothing extra. Video is expensive to transcode, so you do it once on upload and store the renditions. Opposite strategies, same bucket.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it saves
&lt;/h2&gt;

&lt;p&gt;No hard numbers to share here, but the shape of the win is consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; Two image objects instead of twenty-plus. Originals on a cold
class that costs a fraction of Standard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server load.&lt;/strong&gt; No thumbnail jobs competing for CPU on every upload, and no
multi-gigabyte uploads occupying PHP workers, memory and connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery.&lt;/strong&gt; Variants are produced once at the edge and cached, so origin and
Lambda costs scale with unique sizes requested, not with traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is "let each layer do what it is best at": the browser moves bytes,&lt;br&gt;
S3 stores them, the edge transforms them, MediaConvert handles the expensive&lt;br&gt;
video work, and WordPress orchestrates editorial without carrying the files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock the buckets.&lt;/strong&gt; Neither bucket is public. CloudFront reads via Origin
Access Control; the transformer reads via its execution role. Public buckets are
how media libraries leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presigned uploads need guardrails.&lt;/strong&gt; Scope each URL to a single key, a content
type and a short expiry, and configure S3 CORS for the admin origin. A
presigned URL is a capability; keep it narrow and short-lived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't compound compression.&lt;/strong&gt; A lossy working master makes every derivative
worse. Keep the master near-lossless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the named-size mapping.&lt;/strong&gt; The day you disable WordPress sizes, audit the
theme and plugins for hard-coded size names and make sure they resolve to the
edge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The deeper point
&lt;/h2&gt;

&lt;p&gt;The instinct with a media library is to make the CMS responsible for everything:&lt;br&gt;
storing, resizing, naming, serving. The leaner design is to make the CMS&lt;br&gt;
responsible for almost none of it, and to let purpose-built layers each take one&lt;br&gt;
job. WordPress stays fast and boring, which is exactly what you want from the&lt;br&gt;
system your newsroom logs into every day.&lt;/p&gt;

&lt;p&gt;This is the kind of architecture we build and run on&lt;br&gt;
&lt;a href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&gt;WordPress + AWS&lt;/a&gt;. If you are wrestling a heavy media library on&lt;br&gt;
WordPress, &lt;a href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/solutions/implementations/serverless-image-handler/" rel="noopener noreferrer"&gt;AWS Serverless Image Handler&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aws.amazon.com/s3/storage-classes/glacier/instant-retrieval/" rel="noopener noreferrer"&gt;Amazon S3 Glacier Instant Retrieval&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" rel="noopener noreferrer"&gt;S3 lifecycle configuration&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html" rel="noopener noreferrer"&gt;Uploading objects with presigned URLs&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html" rel="noopener noreferrer"&gt;S3 CORS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/mediaconvert/" rel="noopener noreferrer"&gt;AWS Elemental MediaConvert&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;WordPress: &lt;a href="https://developer.wordpress.org/reference/hooks/intermediate_image_sizes_advanced/" rel="noopener noreferrer"&gt;&lt;code&gt;intermediate_image_sizes_advanced&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.wordpress.org/reference/hooks/big_image_size_threshold/" rel="noopener noreferrer"&gt;&lt;code&gt;big_image_size_threshold&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.wordpress.org/reference/functions/wp_get_attachment_url/" rel="noopener noreferrer"&gt;&lt;code&gt;wp_get_attachment_url&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>aws</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Short links that outlive the vendor: a newsroom URL shortener on WordPress and AWS</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:32:21 +0000</pubDate>
      <link>https://dev.to/mantekio/short-links-that-outlive-the-vendor-a-newsroom-url-shortener-on-wordpress-and-aws-gja</link>
      <guid>https://dev.to/mantekio/short-links-that-outlive-the-vendor-a-newsroom-url-shortener-on-wordpress-and-aws-gja</guid>
      <description>&lt;p&gt;An editor hits publish on a breaking story and pushes it to a million followers.&lt;br&gt;
The headline is sharp. The link beneath it is forty characters of&lt;br&gt;
&lt;code&gt;%D8%A7%D9%84%D8%B0...&lt;/code&gt;, unreadable, often cut off by the platform, telling the&lt;br&gt;
reader nothing about where the tap will take them.&lt;/p&gt;

&lt;p&gt;That is what a long URL looks like on social when the slug is Arabic. WordPress&lt;br&gt;
stores and emits the slug percent-encoded, so a clean Arabic headline turns into&lt;br&gt;
a wall of &lt;code&gt;%XX&lt;/code&gt; bytes the moment it leaves your site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/الذكاء-الاصطناعي  →  /%D8%A7%D9%84%D8%B0%D9%83%D8%A7%D8%A1-%D8%A7%D9%84%D8%A7%D8%B5%D8%B7%D9%86%D8%A7%D8%B9%D9%8A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(We have written before about &lt;a href="https://www.mantek.io/insights/wordpress-arabic-slug-truncation" rel="noopener noreferrer"&gt;how those Arabic slugs get truncated in the first&lt;br&gt;
place&lt;/a&gt;. This is the other half of the&lt;br&gt;
story: what happens to them out in the world.)&lt;/p&gt;

&lt;p&gt;Every serious newsroom solves this the same way, with branded short links:&lt;br&gt;
&lt;code&gt;sho.rt/9Kx2a&lt;/code&gt; instead of the wall of bytes. Short, on brand, and the same length&lt;br&gt;
whether the article is Arabic, English, or anything else. The only real question&lt;br&gt;
is whether you buy that capability from a service like Bitly or TinyURL, or build&lt;br&gt;
it yourself.&lt;/p&gt;

&lt;p&gt;We did both, on purpose. Here is why, and how.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why both
&lt;/h2&gt;

&lt;p&gt;A short link, once shared, is effectively permanent. It lives in tweets, Telegram&lt;br&gt;
channels, WhatsApp forwards, and printed QR codes for years. So the day a vendor&lt;br&gt;
raises its prices, retires a feature, or simply shuts down, every one of those&lt;br&gt;
links dies, and the traffic with them. For a newsroom whose archive is its&lt;br&gt;
capital, that is not an acceptable dependency.&lt;/p&gt;

&lt;p&gt;So we set one rule: &lt;strong&gt;the ability to resolve our own short links must never depend&lt;br&gt;
on a company we do not control.&lt;/strong&gt; Buy the convenience, own the lifeline.&lt;/p&gt;

&lt;p&gt;In practice that means Bitly serves the short domain today, because it gives the&lt;br&gt;
newsroom polished click analytics and a link UI editors like. But sitting behind&lt;br&gt;
it, fully in sync, is an in-house resolver running on our own AWS edge, ready to&lt;br&gt;
take over with a single DNS change. The reader never sees the seam, because the&lt;br&gt;
short codes are identical on both fronts. This runs on the same WordPress and AWS&lt;br&gt;
stack we have &lt;a href="https://www.mantek.io/insights/breaking-news-wordpress-aws-architecture" rel="noopener noreferrer"&gt;written about before&lt;/a&gt;:&lt;br&gt;
WordPress on EC2 behind an Application Load Balancer, Aurora for the database, and&lt;br&gt;
CloudFront at the edge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feed59017z286mcp63swo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feed59017z286mcp63swo.png" alt="The two interchangeable fronts" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One short code, two interchangeable fronts. DNS decides who answers, so failover is a single DNS change, and the reader always lands on the same canonical article.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The one decision everything hangs on
&lt;/h2&gt;

&lt;p&gt;Before any infrastructure, there is a single design choice that makes the rest&lt;br&gt;
possible: &lt;strong&gt;one short ID, minted once in WordPress, reused everywhere.&lt;/strong&gt; The same&lt;br&gt;
string is the key in our database, the key in DynamoDB, and the custom back-half&lt;br&gt;
on Bitly.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;9Kx2a&lt;/code&gt; is literally the same value in all three places, switching who&lt;br&gt;
answers the short domain is invisible to anyone holding the link. There is no&lt;br&gt;
remapping, no lookup table between vendors, no links left behind. That one&lt;br&gt;
decision is what turns "own the lifeline" from a slogan into a DNS change.&lt;br&gt;
Everything below serves it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Generating the ID
&lt;/h2&gt;

&lt;p&gt;The ID is drawn from a 64-character, URL-safe alphabet: &lt;code&gt;A-Z&lt;/code&gt;, &lt;code&gt;a-z&lt;/code&gt;, &lt;code&gt;0-9&lt;/code&gt;, plus&lt;br&gt;
&lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt;. Those 64 characters all live in a URL path without encoding, so the&lt;br&gt;
short link stays clean no matter what.&lt;/p&gt;

&lt;p&gt;We generate it &lt;strong&gt;only when a post is published&lt;/strong&gt;, never on draft. That keeps the&lt;br&gt;
keyspace clean (no IDs burned on posts that never go live), avoids leaking a URL&lt;br&gt;
for unpublished work, and means a short link never resolves to a 404 or a private&lt;br&gt;
draft. A short link should only ever exist for something a reader can actually&lt;br&gt;
read.&lt;/p&gt;

&lt;p&gt;The generator uses a cryptographic random source, not &lt;code&gt;rand()&lt;/code&gt;, so codes are not&lt;br&gt;
guessable or enumerable, and it leans on a &lt;code&gt;UNIQUE&lt;/code&gt; constraint to stay correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SHORTLINK_ALPHABET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SHORTLINK_LENGTH&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 64^5 ≈ 1.07B. Use 6 for ≈ 68.7B of headroom.&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mantek_generate_short_id&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$alphabet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SHORTLINK_ALPHABET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$max&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$alphabet&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$id&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;SHORTLINK_LENGTH&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$alphabet&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nb"&gt;random_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$max&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;  &lt;span class="c1"&gt;// CSPRNG: not guessable&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Runs on the publish transition only. The UNIQUE index on short_id turns a&lt;/span&gt;
&lt;span class="c1"&gt;// collision into a failed INSERT, so we simply re-mint and try again.&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;mantek_assign_short_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'short_links'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$short_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mantek_generate_short_id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"INSERT INTO &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (short_id, post_id, full_url, provider, status, created_at)
             VALUES (%s, %d, %s, %s, 'active', NOW())"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$short_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_permalink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;mantek_active_provider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$inserted&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$short_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// clean insert: the ID was free&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Duplicate key (MySQL 1062): the ID was taken. Loop and re-mint.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"Could not mint a unique short_id for post &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fair question: with random generation, will codes collide? Yes, and you must&lt;br&gt;
plan for it rather than hope. The maths is the birthday problem, not the raw&lt;br&gt;
keyspace: at five characters you have a better-than-even chance of &lt;em&gt;some&lt;/em&gt;&lt;br&gt;
collision once you have minted roughly 38,000 links, which a busy newsroom passes&lt;br&gt;
within a year. That is exactly why the &lt;code&gt;UNIQUE&lt;/code&gt; constraint and the retry loop are&lt;br&gt;
not optional.&lt;/p&gt;

&lt;p&gt;What that maths does &lt;em&gt;not&lt;/em&gt; mean is that the retry fires often. The chance that any&lt;br&gt;
single new code clashes is the number of live links divided by the keyspace, so&lt;br&gt;
even at a million live links roughly one insert in a thousand needs a second try,&lt;br&gt;
and a third try is a one-in-a-million event. Five characters with the retry is&lt;br&gt;
comfortable essentially forever; six characters (about 68.7 billion) makes the&lt;br&gt;
retry all but theoretical, at the cost of one extra character. Pick five unless&lt;br&gt;
you want the longest possible runway.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the mapping lives
&lt;/h2&gt;

&lt;p&gt;The short code needs a home in WordPress. The tempting shortcut is the existing&lt;br&gt;
&lt;code&gt;guid&lt;/code&gt; column on &lt;code&gt;wp_posts&lt;/code&gt;, since it already holds something URL-shaped. Resist&lt;br&gt;
it (more on why in a moment). We use a dedicated table instead:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;short_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the code itself, with a &lt;code&gt;UNIQUE&lt;/code&gt; index for the retry loop and the reverse lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the local post this code is attached to, used to refresh &lt;code&gt;full_url&lt;/code&gt;; instance-local, never used for resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;full_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the article's current canonical URL, a pointer we refresh when it changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;which backend was active when the code was minted (&lt;code&gt;bitly&lt;/code&gt;, &lt;code&gt;inhouse&lt;/code&gt;); a historical marker, since the DNS is the real router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;lifecycle: &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;pending&lt;/code&gt;, or &lt;code&gt;gone&lt;/code&gt;, so the resolver knows what to do when a post is unpublished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;created_at&lt;/code&gt; / &lt;code&gt;updated_at&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;timestamps for reconciliation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A dedicated table owns its own schema, is properly indexed for the reverse lookup,&lt;br&gt;
and has zero blast radius on the rest of WordPress.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not just reuse the &lt;code&gt;guid&lt;/code&gt; column?&lt;/strong&gt; Because the GUID is not a usable URL,&lt;br&gt;
despite looking like one. Its real job is to be a permanent, never-changed&lt;br&gt;
identifier that feed readers use to decide whether an item is new. Overwrite it&lt;br&gt;
and you risk re-notifying every subscriber, breaking the WXR importer's&lt;br&gt;
de-duplication (and newsrooms migrate often), and corrupting media references,&lt;br&gt;
since for attachments the GUID &lt;em&gt;is&lt;/em&gt; the real file URL. Indexing it also means&lt;br&gt;
altering a core table. A dedicated table avoids all of that for almost nothing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Resolving at the edge: redirect, not proxy
&lt;/h2&gt;

&lt;p&gt;Here is the most important architectural decision, and the one most people get&lt;br&gt;
subtly wrong. A short link should &lt;strong&gt;redirect&lt;/strong&gt;. It resolves the code to the&lt;br&gt;
article's URL and returns a &lt;code&gt;301&lt;/code&gt;, and the reader's browser then loads the real&lt;br&gt;
article from the normal stack. The short-link layer never fetches or serves the&lt;br&gt;
article's HTML itself.&lt;/p&gt;

&lt;p&gt;The alternative, serving the article's HTML &lt;em&gt;under&lt;/em&gt; the short URL, is a trap. It&lt;br&gt;
splits every article across two URLs, which confuses search engines and forces&lt;br&gt;
canonical-tag gymnastics, it leaves the reader looking at a &lt;code&gt;sho.rt&lt;/code&gt; address&lt;br&gt;
instead of your trusted domain, and it doubles your cache, since CloudFront now&lt;br&gt;
stores the full article twice. The redirect keeps the short-link layer trivially&lt;br&gt;
small: the cached object is a few hundred bytes, and it points everyone at the one&lt;br&gt;
canonical article.&lt;/p&gt;

&lt;p&gt;That also dissolves a performance worry worth naming. Resolving a short link is&lt;br&gt;
&lt;strong&gt;one&lt;/strong&gt; key lookup followed by a redirect. It never disassembles the full URL into&lt;br&gt;
date, category, and slug, and it never runs a &lt;code&gt;WP_Query&lt;/code&gt;. WordPress already&lt;br&gt;
resolves its permalinks once, for &lt;em&gt;all&lt;/em&gt; traffic, and CloudFront caches the result.&lt;br&gt;
The short link does not add that cost; it just prepends a tiny redirect hop in&lt;br&gt;
front of it.&lt;/p&gt;

&lt;p&gt;The in-house path works like this. The short domain's DNS points at CloudFront. On&lt;br&gt;
a cache hit, CloudFront returns the cached &lt;code&gt;301&lt;/code&gt; immediately, with no compute at&lt;br&gt;
all. On a miss, it runs a small Lambda@Edge function that does a single DynamoDB&lt;br&gt;
lookup on the short code, builds the redirect, and caches it for next time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Lambda@Edge, viewer-request on the short domain's CloudFront distribution.&lt;/span&gt;
&lt;span class="c1"&gt;// Resolves /&amp;lt;short_id&amp;gt; to a 301 toward the article's current URL.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GetItemCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@aws-sdk/client-dynamodb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ddb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Records&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shortId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;shortId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetItemCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short_links&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;short_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shortId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;ProjectionExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;full_url, #s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ExpressionAttributeNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;goneOrNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;full_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isOwnDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// open-redirect guard&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;301&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;statusDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Moved Permanently&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;appendUtm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cache-control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public, max-age=86400&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A DynamoDB lookup on the partition key is single-digit milliseconds, and most hits&lt;br&gt;
never reach it anyway, because CloudFront is serving the cached redirect. One&lt;br&gt;
detail to know: a Lambda@Edge function is authored in &lt;code&gt;us-east-1&lt;/code&gt; and replicated to&lt;br&gt;
every edge, and its DynamoDB call reaches the table's home region, so for global&lt;br&gt;
low latency you either replicate the table with DynamoDB Global Tables or front it&lt;br&gt;
with DAX. In practice it rarely bites, precisely because the cache absorbs the vast&lt;br&gt;
majority of requests.&lt;/p&gt;

&lt;p&gt;A few decisions baked into that function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;301, not 302.&lt;/strong&gt; We want search engines to consolidate the article's ranking
signals on the canonical URL, and they treat the &lt;code&gt;301&lt;/code&gt; status itself as that
signal. The explicit &lt;code&gt;Cache-Control&lt;/code&gt; keeps the redirect from being pinned
forever in browser caches, so a later URL change is still picked up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-redirect guard.&lt;/strong&gt; The resolver only ever sends readers to our own
domains. An unknown or tampered target returns a plain 404, so the shortener can
never be abused to bounce users to a phishing page. Random codes make scanning
for valid links low-yield too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Gateway, honestly.&lt;/strong&gt; You will see URL shorteners put API Gateway in this
path. Its only real job there is to expose a regional Lambda to CloudFront as an
origin. For a pure redirect you usually do not need it: Lambda@Edge resolves
directly, and where a regional function is preferred, a Lambda Function URL is a
lighter front door. (This architecture already runs API Gateway, but for
&lt;a href="https://www.mantek.io/insights/breaking-news-wordpress-aws-architecture" rel="noopener noreferrer"&gt;headless apps&lt;/a&gt;, not for the
shortener.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzou5do9xcz99ule0t7z7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzou5do9xcz99ule0t7z7.png" alt="In-house resolution and the full stack" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Resolving the short link is one cached redirect at the edge, with a DynamoDB lookup only on a cache miss. The browser then follows the 301 to the article through the normal stack, exactly like any other reader.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Migration-proof by design
&lt;/h2&gt;

&lt;p&gt;Notice what we actually resolve by. The durable handle is the &lt;code&gt;short_id&lt;/code&gt; itself,&lt;br&gt;
the value we mint, control, and put out into the world. The &lt;code&gt;full_url&lt;/code&gt; is just a&lt;br&gt;
pointer we keep current beside it. That pairing is what lets a short link outlast&lt;br&gt;
not only a vendor but your own URL scheme.&lt;/p&gt;

&lt;p&gt;It is worth being precise about why, because the obvious candidate for a permanent&lt;br&gt;
identity, the WordPress &lt;code&gt;post_id&lt;/code&gt;, is not actually stable. Post IDs are&lt;br&gt;
auto-increment integers local to one database; migrate to a fresh WordPress (or to&lt;br&gt;
anything else) and they get reassigned. So we never resolve by &lt;code&gt;post_id&lt;/code&gt;. We&lt;br&gt;
resolve by the &lt;code&gt;short_id&lt;/code&gt;, and we carry the &lt;code&gt;short_id → full_url&lt;/code&gt; mapping across&lt;br&gt;
every migration, refreshing &lt;code&gt;full_url&lt;/code&gt; to wherever the article now lives. Newsrooms&lt;br&gt;
re-platform, restructure categories, and change permalink patterns over the years,&lt;br&gt;
and each time the old URLs change. A short link keyed on the ID we own does not&lt;br&gt;
care: share &lt;code&gt;sho.rt/9Kx2a&lt;/code&gt; once, and it keeps landing on the right article through&lt;br&gt;
every future migration, because resolution ends at a code we control, never at a&lt;br&gt;
database's internal ID or a frozen URL string.&lt;/p&gt;

&lt;p&gt;The one rule that keeps this true: &lt;strong&gt;when an article's URL changes, update the&lt;br&gt;
&lt;code&gt;full_url&lt;/code&gt; in DynamoDB and invalidate the cached redirect.&lt;/strong&gt; Active invalidation is&lt;br&gt;
the primary mechanism, so the change is picked up at once, and the bounded&lt;br&gt;
&lt;code&gt;Cache-Control&lt;/code&gt; is a safety net that heals anything an invalidation missed within a&lt;br&gt;
day. For an everyday slug edit, invalidate the single path. For a mass migration of&lt;br&gt;
thousands of URLs at once, let a short TTL expire instead, since firing thousands&lt;br&gt;
of individual invalidations is slower and costlier. This is also why the serving&lt;br&gt;
mapping lives in DynamoDB rather than only inside WordPress: it sits outside the&lt;br&gt;
CMS, so it survives any migration by definition. After a re-platform you refresh the&lt;br&gt;
&lt;code&gt;full_url&lt;/code&gt; values to the new URLs, and every link already in the wild keeps&lt;br&gt;
resolving.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keeping the stores in sync at publish
&lt;/h2&gt;

&lt;p&gt;When a post goes live, three things happen in order: WordPress mints the ID into&lt;br&gt;
its own table, upserts the mapping into DynamoDB, and calls the active shortener.&lt;br&gt;
Each store has a clear, non-overlapping role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WordPress (&lt;code&gt;wp_short_links&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Authoring source of truth: mints the ID, knows the canonical URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB&lt;/td&gt;
&lt;td&gt;Serving source of truth at the edge, and the vendor-neutral warm standby&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitly&lt;/td&gt;
&lt;td&gt;Downstream replica: the active analytics front today, and swappable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest complication with writing to three places is partial failure: the&lt;br&gt;
DynamoDB write succeeds but the Bitly call times out, say. So the external call&lt;br&gt;
goes through a small queue (SQS) with retries, so a transient blip never silently&lt;br&gt;
loses a mapping, and a periodic reconciliation job diffs WordPress against&lt;br&gt;
DynamoDB and Bitly to catch any drift. WordPress is always the tie-breaker.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffdaxiu8y3kg15709etol.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffdaxiu8y3kg15709etol.png" alt="The publish write-path" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;On publish, WordPress mints the id and writes its own table, then upserts DynamoDB and enqueues the Bitly call through SQS, so a transient failure never loses a mapping. WordPress stays the source of truth.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Bitly, and how we keep it swappable
&lt;/h2&gt;

&lt;p&gt;The call to the shortener does not name Bitly. It goes through an interface, so&lt;br&gt;
Bitly is one implementation behind a contract the rest of the code never has to&lt;br&gt;
think about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ShortLinkProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$shortId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$fullUrl&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ShortLinkResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;updateTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$shortId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$newUrl&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ShortLinkResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$shortId&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?ShortLinkStats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// if the vendor exposes it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BitlyProvider&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShortLinkProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$shortId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$fullUrl&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ShortLinkResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/v4/bitlinks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'long_url'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fullUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'domain'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;brandedDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// our custom short domain&lt;/span&gt;
            &lt;span class="s1"&gt;'custom_bitlink'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;brandedDomain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$shortId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// our ID as the back-half&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Bitly may SILENTLY fall back to a random back-half if ours is somehow&lt;/span&gt;
        &lt;span class="c1"&gt;// taken, instead of failing. Never accept that: it would break the&lt;/span&gt;
        &lt;span class="c1"&gt;// "same ID everywhere" invariant. Assert it returned exactly what we asked.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$res&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;backHalf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$shortId&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShortLinkCollision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$shortId&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// caller re-mints and retries&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ShortLinkResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$res&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;link&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// updateTarget(), stats() ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things we confirmed against Bitly's own documentation before relying on them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom back-halves are unique per branded domain, not globally.&lt;/strong&gt; On our own
short domain, any code that is unique in our database is automatically unique on
Bitly, so our &lt;code&gt;UNIQUE&lt;/code&gt; constraint is the single source of truth. No second
uniqueness problem to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bitly links are case-sensitive,&lt;/strong&gt; which is why our mixed-case alphabet is safe
and why &lt;code&gt;9Kx2a&lt;/code&gt; and &lt;code&gt;9kX2A&lt;/code&gt; are different links.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one trap, captured in the code above: if you request a custom back-half that is&lt;br&gt;
already in use, Bitly may return a link with a &lt;em&gt;random&lt;/em&gt; back-half rather than an&lt;br&gt;
error. Accept that blindly and your database says &lt;code&gt;9Kx2a&lt;/code&gt; while Bitly serves&lt;br&gt;
something else. The integration must assert the returned back-half matches the&lt;br&gt;
requested one, and treat a mismatch as a collision.&lt;/p&gt;

&lt;p&gt;While Bitly is the active front, our DynamoDB table is effectively write-only,&lt;br&gt;
since the short domain points at Bitly and our resolver is never triggered. That is&lt;br&gt;
not waste, it is a warm standby. Storing a few hundred bytes per link and doing the&lt;br&gt;
occasional write costs almost nothing, and it is exactly what makes failover a DNS&lt;br&gt;
change rather than a frantic rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: failover is a DNS change
&lt;/h2&gt;

&lt;p&gt;Put it together and the failover story is almost boring, which is the point. The&lt;br&gt;
short domain points at Bitly today. To take it back in-house, you repoint its DNS&lt;br&gt;
to our CloudFront distribution. Every link already in the wild keeps working,&lt;br&gt;
because the codes are identical on both fronts and our DynamoDB table already holds&lt;br&gt;
every mapping.&lt;/p&gt;

&lt;p&gt;The one operational honesty here: that cutover is only as fast as DNS lets it be,&lt;br&gt;
and the TTL is a knob you manage, not a constant. In steady state, pointing at&lt;br&gt;
Bitly with no change in sight, a normal TTL (say an hour) is fine; the record is&lt;br&gt;
not changing, so there is nothing to propagate faster. The TTL only matters around&lt;br&gt;
a switch. For a planned move off Bitly, lower it to 60 to 300 seconds a day or two&lt;br&gt;
ahead (long enough for the old, higher TTL to age out everywhere), make the change,&lt;br&gt;
then raise it back. If instead you want to be ready for an unplanned failover, say&lt;br&gt;
Bitly itself goes down, keep the TTL permanently low as cheap insurance, since the&lt;br&gt;
cost on modern anycast DNS is negligible. One caveat: some resolvers clamp very low&lt;br&gt;
values, so do not go below about 60 seconds or treat the TTL as an absolute&lt;br&gt;
guarantee.&lt;/p&gt;

&lt;p&gt;With DNS control on your side, the short-link layer survives a vendor shutdown, a&lt;br&gt;
price hike, a removed feature, or a full CMS re-platform. The links keep resolving&lt;br&gt;
regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: analytics
&lt;/h2&gt;

&lt;p&gt;If the in-house resolver can do everything, why pay Bitly at all? Analytics. Bitly&lt;br&gt;
gives a newsroom per-link click counts, geographic and referrer breakdowns, an&lt;br&gt;
editor-friendly UI for creating and tracking links, and the bot-filtering that&lt;br&gt;
makes those numbers trustworthy. That is real product work, and it is the reason to&lt;br&gt;
buy rather than build.&lt;/p&gt;

&lt;p&gt;Could you build it? Yes, and the edge resolver is the perfect place to tap, because&lt;br&gt;
every redirect is already an event. There are two complementary ways to capture it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-side&lt;/strong&gt;, by having the redirect append a marker (UTM parameters) to the
destination, so the analytics already on the article page (GA4, or newsroom tools
like Chartbeat and Parse.ly) attribute the visit. This counts human readers and
naturally excludes bots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side&lt;/strong&gt;, by having the resolver send a GA4 Measurement Protocol event on
each resolution, which counts every hit including the ones that never render a
page, closer to how Bitly counts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One correction worth making, since it is a common assumption: GA cannot read the&lt;br&gt;
request headers of a redirect, and the redirect runs no JavaScript. The signal that&lt;br&gt;
reaches your analytics is the marker parameter or the server-side event, not a&lt;br&gt;
header.&lt;/p&gt;

&lt;p&gt;So the honest line is not "GA cannot track short links." It can. The gap is that&lt;br&gt;
matching Bitly's per-link dashboards, editor UI, and bot filtering is a product you&lt;br&gt;
would own and maintain, and for many newsrooms paying for that polish is the right&lt;br&gt;
call. We built the durable core so we are never locked in, and we let Bitly handle&lt;br&gt;
the analytics, with our own edge waiting as the hot standby. Buy the convenience,&lt;br&gt;
own the lifeline.&lt;/p&gt;




&lt;p&gt;That is the whole shape of it: build the part that must never disappear, the&lt;br&gt;
resolution and the mapping, buy the convenience layer on top, and keep them&lt;br&gt;
interchangeable so a vendor decision is never an existential one. A short link your&lt;br&gt;
newsroom shared years ago should still land tomorrow, no matter who is answering&lt;br&gt;
the domain.&lt;/p&gt;

&lt;p&gt;This is the kind of WordPress and AWS engineering &lt;a href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&gt;our practice&lt;/a&gt; is&lt;br&gt;
built on. If you run a publication that lives on social and cannot afford to lose&lt;br&gt;
the links it has shared, &lt;a href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html" rel="noopener noreferrer"&gt;Lambda@Edge&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html" rel="noopener noreferrer"&gt;CloudFront cache invalidation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GlobalTables.html" rel="noopener noreferrer"&gt;DynamoDB Global Tables&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DAX.html" rel="noopener noreferrer"&gt;DAX&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.bitly.com/docs/tutorials/shorten-customize-links/" rel="noopener noreferrer"&gt;Bitly: shorten and customise links&lt;/a&gt; and &lt;a href="https://support.bitly.com/hc/en-us/articles/360056593371-Can-I-use-the-same-back-half-in-multiple-groups" rel="noopener noreferrer"&gt;custom back-half uniqueness&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/analytics/devguides/collection/protocol/ga4" rel="noopener noreferrer"&gt;GA4 Measurement Protocol&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>aws</category>
      <category>architecture</category>
      <category>serverless</category>
    </item>
    <item>
      <title>A WordPress update can silently truncate your long Arabic URLs — the bug's in core, in 3 places. I traced it and wrote a fix that survives every update:

https://dev.to/jafarabazid/the-200-byte-trap-why-wordpress-core-updates-break-arabic-urls-5d5k</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Sun, 21 Jun 2026 13:44:27 +0000</pubDate>
      <link>https://dev.to/jaafarabazid/a-wordpress-update-can-silently-truncate-your-long-arabic-urls-the-bugs-in-core-in-3-places-i-4g6n</link>
      <guid>https://dev.to/jaafarabazid/a-wordpress-update-can-silently-truncate-your-long-arabic-urls-the-bugs-in-core-in-3-places-i-4g6n</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://dev.to/mantekio/the-200-byte-trap-why-wordpress-core-updates-break-arabic-urls-5d5k" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fie7bsgab4gydy9zoijvv.png" height="336" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://dev.to/mantekio/the-200-byte-trap-why-wordpress-core-updates-break-arabic-urls-5d5k" rel="noopener noreferrer" class="c-link"&gt;
            The 200-byte trap: why WordPress core updates break Arabic URLs - DEV Community
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            You update WordPress on a quiet afternoon, a routine release, the kind you've installed a hundred...
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8j7kvp660rqzt99zui8e.png" width="300" height="299"&gt;
          dev.to
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>php</category>
      <category>webdev</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The 200-byte trap: why WordPress core updates break Arabic URLs</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Sun, 21 Jun 2026 12:47:31 +0000</pubDate>
      <link>https://dev.to/mantekio/the-200-byte-trap-why-wordpress-core-updates-break-arabic-urls-5d5k</link>
      <guid>https://dev.to/mantekio/the-200-byte-trap-why-wordpress-core-updates-break-arabic-urls-5d5k</guid>
      <description>&lt;p&gt;You update WordPress on a quiet afternoon, a routine release, the kind you've&lt;br&gt;
installed a hundred times. The dashboard says everything went fine. Then the&lt;br&gt;
404s start: not a handful, but every long-headlined article in your archive, all&lt;br&gt;
at once, all in Arabic.&lt;/p&gt;

&lt;p&gt;Nothing in the update log mentions it. No plugin changed. And the cruel part:&lt;br&gt;
the data that made those URLs work is already gone, shaved off inside a&lt;br&gt;
database-upgrade routine that ran for a few milliseconds and reported success.&lt;br&gt;
This isn't a freak accident or a broken plugin. It's three separate assumptions&lt;br&gt;
baked into WordPress core, each hard-coding the same number, &lt;strong&gt;200&lt;/strong&gt;, and&lt;br&gt;
Arabic sites are almost uniquely exposed to all three. We hit this running&lt;br&gt;
WordPress for Arabic newsrooms, the same high-traffic publishing we've written&lt;br&gt;
about &lt;a href="https://www.mantek.io/insights/breaking-news-wordpress-aws-architecture" rel="noopener noreferrer"&gt;surviving breaking-news spikes&lt;/a&gt;.&lt;br&gt;
We traced it to the exact lines in core and built a fix that survives every&lt;br&gt;
future update. Here's the whole story.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Arabic URLs hit a wall English never does
&lt;/h2&gt;

&lt;p&gt;WordPress stores a post's slug in the &lt;code&gt;post_name&lt;/code&gt; column of &lt;code&gt;wp_posts&lt;/code&gt;, and a&lt;br&gt;
category or tag slug in the &lt;code&gt;slug&lt;/code&gt; column of &lt;code&gt;wp_terms&lt;/code&gt;. Both are &lt;code&gt;VARCHAR(200)&lt;/code&gt;&lt;br&gt;
by default: room for 200 characters, which for an English headline is generous.&lt;br&gt;
&lt;em&gt;"Everything you need to know about our new pricing"&lt;/em&gt; is barely fifty. You'd have&lt;br&gt;
to write a paragraph to run out.&lt;/p&gt;

&lt;p&gt;Arabic is a different arithmetic, because of what WordPress actually stores in&lt;br&gt;
that column. It doesn't keep the raw Arabic text; it stores the&lt;br&gt;
&lt;strong&gt;percent-encoded&lt;/strong&gt; form, the same &lt;code&gt;%XX&lt;/code&gt; sequence that travels in the URL. Take a&lt;br&gt;
single word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;الذكاء  →  %d8%a7%d9%84%d8%b0%d9%83%d8%a7%d8%a1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Arabic letter is two bytes in UTF-8, and every byte becomes a&lt;br&gt;
three-character &lt;code&gt;%XX&lt;/code&gt; token. So &lt;strong&gt;one Arabic character costs about six characters&lt;br&gt;
of column space.&lt;/strong&gt; Do the division: &lt;code&gt;VARCHAR(200)&lt;/code&gt; holds roughly &lt;strong&gt;33 Arabic&lt;br&gt;
characters.&lt;/strong&gt; A normal news headline, &lt;em&gt;"القبض على المتهمين في قضية الاحتيال&lt;br&gt;
الإلكتروني"&lt;/em&gt;, blows past that before it's halfway done.&lt;/p&gt;

&lt;p&gt;So Arabic publishers learn early to widen the columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="n"&gt;post_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wp_terms&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VARCHAR(1024)&lt;/code&gt; holds about 170 Arabic characters, comfortable. Permalinks&lt;br&gt;
resolve, the newsroom publishes, and the problem looks solved.&lt;/p&gt;

&lt;p&gt;It isn't. It's about &lt;strong&gt;one-third&lt;/strong&gt; solved, and the other two-thirds are waiting&lt;br&gt;
for the next core update. (This isn't only an Arabic problem, either: any&lt;br&gt;
non-Latin script that percent-encodes (Persian, Urdu, Hebrew, Greek) hits the&lt;br&gt;
same wall. Arabic publishers just hit it first and hardest.)&lt;/p&gt;
&lt;h2&gt;
  
  
  The number 200 lives in three places, not one
&lt;/h2&gt;

&lt;p&gt;Here's the insight that turns this from a recurring mystery into something you&lt;br&gt;
can actually close out. WordPress hard-codes "200" in &lt;strong&gt;three independent&lt;br&gt;
places&lt;/strong&gt;, and widening the column only touches the first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it caps at 200&lt;/th&gt;
&lt;th&gt;Does widening the column fix it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1, Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the column that physically stores the slug&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;, but a core update silently undoes it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2, Generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;every new slug, the moment it is created&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, it's PHP; needs a code override&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3, De-duplication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;slugs that collide and need a numeric suffix&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, but this only fires on collisions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your &lt;code&gt;ALTER&lt;/code&gt; fixes Layer 1's &lt;em&gt;storage&lt;/em&gt;. But Layer 2 means that even with a&lt;br&gt;
1024-wide column, every new slug is still cut to 200 bytes &lt;em&gt;before it's ever&lt;br&gt;
saved&lt;/em&gt;, unless something on your site already overrode it. (If long Arabic&lt;br&gt;
slugs save correctly for you today, a plugin, your theme, or a past developer&lt;br&gt;
already patched Layer 2, which means you have an undocumented dependency you&lt;br&gt;
didn't know you were relying on.) And Layer 1, the storage you so carefully&lt;br&gt;
widened, is exactly the one a core update quietly reverts. Let's take each in&lt;br&gt;
turn.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 1: how a core update erases your column width
&lt;/h2&gt;

&lt;p&gt;WordPress keeps a canonical description of its own database schema, and on every&lt;br&gt;
major update it reconciles the live database against that description. The chain&lt;br&gt;
is short and deterministic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A new release bumps &lt;code&gt;$wp_db_version&lt;/code&gt; in &lt;code&gt;version.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On the next admin request, &lt;code&gt;wp_upgrade()&lt;/code&gt; notices the bump and runs the
database upgrade.&lt;/li&gt;
&lt;li&gt;That calls &lt;code&gt;make_db_current_silent()&lt;/code&gt;, which calls
&lt;code&gt;dbDelta( wp_get_db_schema() )&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wp_get_db_schema()&lt;/code&gt; is the canonical schema, and it says, verbatim:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;post_name&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;dbDelta()&lt;/code&gt; then runs a &lt;code&gt;DESCRIBE&lt;/code&gt; on every live table, compares each column to&lt;br&gt;
the canonical definition, and for any mismatch emits an &lt;code&gt;ALTER TABLE … CHANGE&lt;br&gt;
COLUMN&lt;/code&gt; to "correct" it. Your &lt;code&gt;post_name&lt;/code&gt; is &lt;code&gt;VARCHAR(1024)&lt;/code&gt;; the canonical says&lt;br&gt;
&lt;code&gt;VARCHAR(200)&lt;/code&gt;; they don't match, so dbDelta dutifully issues a &lt;code&gt;CHANGE COLUMN&lt;/code&gt;&lt;br&gt;
back to 200, MySQL rebuilds the table, and every byte past the 200th is&lt;br&gt;
discarded.&lt;/p&gt;

&lt;p&gt;Here's the detail that makes it unrecoverable. Years ago, core added &lt;em&gt;downsize&lt;br&gt;
protection&lt;/em&gt; to dbDelta, but &lt;strong&gt;only for &lt;code&gt;TEXT&lt;/code&gt; and &lt;code&gt;BLOB&lt;/code&gt; columns&lt;/strong&gt; (it was&lt;br&gt;
&lt;a href="https://core.trac.wordpress.org/ticket/36748" rel="noopener noreferrer"&gt;Trac #36748&lt;/a&gt;, born from exactly&lt;br&gt;
this kind of data loss). &lt;code&gt;VARCHAR&lt;/code&gt; got no such guard. So a &lt;code&gt;TEXT&lt;/code&gt; column is safe&lt;br&gt;
from accidental shrinking; your &lt;code&gt;VARCHAR(1024)&lt;/code&gt; slug column is not. dbDelta&lt;br&gt;
shrinks it without hesitation, and restoring the column to 1024 afterwards brings&lt;br&gt;
back the width but &lt;strong&gt;not the data&lt;/strong&gt;; those bytes were handed to MySQL and&lt;br&gt;
dropped.&lt;/p&gt;

&lt;p&gt;That's why your fix keeps coming undone: always right after an update, always&lt;br&gt;
silently. It isn't a bug aimed at you; it's core doing exactly what it was&lt;br&gt;
designed to do, to a column you were never expected to change.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For accuracy: a separate routine, &lt;code&gt;pre_schema_upgrade()&lt;/code&gt;, also runs hand-written&lt;br&gt;
&lt;code&gt;ALTER&lt;/code&gt;s, but those are version-gated to historic migrations and won't touch a&lt;br&gt;
modern site's &lt;code&gt;post_name&lt;/code&gt;. The reversion you're seeing is dbDelta.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The fix: don't repair the damage, prevent the shrink
&lt;/h2&gt;

&lt;p&gt;The instinct is to write something that detects the reverted column and&lt;br&gt;
re-widens it after each update. Resist it. By the time you can detect the shrink,&lt;br&gt;
the data is already gone; you'd just be putting an empty wider column back over&lt;br&gt;
truncated slugs. The only fix that actually &lt;em&gt;saves the data&lt;/em&gt; is one that stops&lt;br&gt;
the shrink from ever happening.&lt;/p&gt;

&lt;p&gt;And core hands you the seam to do it. dbDelta exposes a filter,&lt;br&gt;
&lt;code&gt;dbdelta_create_queries&lt;/code&gt;, that receives the canonical &lt;code&gt;CREATE TABLE&lt;/code&gt; statements&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; they're compared to the live database. Rewrite the canonical&lt;br&gt;
&lt;code&gt;post_name varchar(200)&lt;/code&gt; to &lt;code&gt;varchar(1024)&lt;/code&gt; in that filter, and dbDelta's idea of&lt;br&gt;
"correct" now matches what's actually on disk. It sees no mismatch. It emits no&lt;br&gt;
&lt;code&gt;CHANGE COLUMN&lt;/code&gt;. There is nothing to truncate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * Plugin Name: Arabic Slug Schema Guard (must-use)
 * Description: Keeps wp_posts.post_name and wp_terms.slug at VARCHAR(1024) across
 *              core DB upgrades, so long Arabic slugs are never truncated.
 *              Prevention-first: dbDelta never even tries to shrink them.
 */&lt;/span&gt;
&lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SLUG_COLUMN_LEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Rewrite the canonical schema dbDelta diffs against, so "desired" already&lt;/span&gt;
&lt;span class="c1"&gt;// equals the live 1024-wide column — no CHANGE COLUMN is ever emitted.&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dbdelta_create_queries'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$queries&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'post_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'/(\b'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'\s+varchar\()\s*200\s*(\))/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'${1}'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;SLUG_COLUMN_LEN&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'${2}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single filter is the whole prevention story for Layer 1, and it's robust in&lt;br&gt;
a way an after-the-fact repair can never be: it runs &lt;em&gt;inside&lt;/em&gt; dbDelta, so it&lt;br&gt;
covers &lt;strong&gt;every&lt;/strong&gt; path that triggers a schema reconcile: the admin "database&lt;br&gt;
update required" screen, background auto-updates, and &lt;code&gt;wp core update-db&lt;/code&gt; on the&lt;br&gt;
CLI alike.&lt;/p&gt;

&lt;p&gt;Where this code lives matters as much as what it does. Put it in a &lt;strong&gt;must-use&lt;br&gt;
plugin&lt;/strong&gt; (a single &lt;code&gt;.php&lt;/code&gt; file in &lt;code&gt;wp-content/mu-plugins/&lt;/code&gt;), not a regular&lt;br&gt;
plugin. MU-plugins always load, can't be deactivated from the dashboard, and load&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; the upgrade routine runs. And because a core update only ever replaces&lt;br&gt;
&lt;code&gt;wp-admin&lt;/code&gt;, &lt;code&gt;wp-includes&lt;/code&gt; and the root PHP files (it never touches &lt;code&gt;wp-content&lt;/code&gt;),&lt;br&gt;
your guard is guaranteed to still be in place at the exact moment the upgrade&lt;br&gt;
fires. A regular plugin could be deactivated the one time it mattered; don't bet&lt;br&gt;
your archive on a toggle.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 2: stop new slugs truncating at birth
&lt;/h2&gt;

&lt;p&gt;Prevention on the column keeps your &lt;em&gt;existing&lt;/em&gt; slugs safe. But Layer 2 is still&lt;br&gt;
cutting every &lt;em&gt;new&lt;/em&gt; slug to 200 bytes at the moment of creation, inside&lt;br&gt;
&lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;. The relevant line, unchanged in current core, is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;utf8_uri_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That function is attached to the &lt;code&gt;sanitize_title&lt;/code&gt; filter, so you can cleanly swap&lt;br&gt;
it for an identical copy that uses a larger byte budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Layer 2: replace core's slug generator with a byte-for-byte copy whose only&lt;/span&gt;
&lt;span class="c1"&gt;// change is the encoding cap. The copy self-tests against core (see below), so a&lt;/span&gt;
&lt;span class="c1"&gt;// future core rewrite of this function is caught instead of drifting silently.&lt;/span&gt;
&lt;span class="nf"&gt;remove_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title_with_dashes'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'asg_sanitize_title_with_dashes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;asg_sanitize_title_with_dashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$raw_title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'display'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// … the body is line-for-line core, with one change:&lt;/span&gt;
    &lt;span class="c1"&gt;//     utf8_uri_encode( $title, 200 )  →  utf8_uri_encode( $title, 1000 )&lt;/span&gt;
    &lt;span class="c1"&gt;// 1000 leaves headroom under the 1024 column for a "-2" collision suffix.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick a sane budget: around 1000 bytes (≈ 160 Arabic characters) leaves room&lt;br&gt;
under the column for the numeric suffix WordPress adds to resolve duplicates. You&lt;br&gt;
don't want &lt;em&gt;unlimited&lt;/em&gt; slugs; you want &lt;em&gt;enough.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Layer 3 (&lt;code&gt;_truncate_post_slug()&lt;/code&gt;) caps at 200 too, but it only runs when a slug&lt;br&gt;
collides and needs a &lt;code&gt;-2&lt;/code&gt; suffix. For unique news headlines that almost never&lt;br&gt;
fires, and it isn't filterable, so most sites can leave it alone. If you&lt;br&gt;
genuinely need more than 200 bytes &lt;em&gt;even on collisions&lt;/em&gt;, you take over uniqueness&lt;br&gt;
through the &lt;code&gt;pre_wp_unique_post_slug&lt;/code&gt; filter, an advanced case worth a footnote,&lt;br&gt;
not a paragraph.&lt;/p&gt;
&lt;h3&gt;
  
  
  Keeping the fork honest
&lt;/h3&gt;

&lt;p&gt;Copying a core function buys a maintenance debt: the day WordPress rewrites&lt;br&gt;
&lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;, your copy diverges, &lt;em&gt;silently&lt;/em&gt;, which is the exact&lt;br&gt;
failure mode we're trying to kill. Pinning the copy to a core version and re-diffing&lt;br&gt;
by hand works right up until the release someone forgets to check, which on a&lt;br&gt;
long-lived site is &lt;em&gt;when&lt;/em&gt;, not &lt;em&gt;if&lt;/em&gt;. So don't rely on remembering. Make the plugin&lt;br&gt;
re-diff itself.&lt;/p&gt;

&lt;p&gt;The lever: &lt;code&gt;remove_filter()&lt;/code&gt; only unhooks the function; &lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;&lt;br&gt;
stays &lt;strong&gt;callable&lt;/strong&gt;. So you can use core's own current code as a live oracle and assert&lt;br&gt;
your fork still agrees with it on &lt;strong&gt;short&lt;/strong&gt; inputs, where neither byte cap fires and&lt;br&gt;
the only thing left that could differ is the cleanup logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run once per core version. Short fixtures =&amp;gt; neither length cap engages,&lt;/span&gt;
&lt;span class="c1"&gt;// so any difference is pure logic drift, not the byte budget.&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'الذكاء الاصطناعي'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'عاجل: «مهم» اليوم'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'foo/bar &amp;amp;mdash; baz'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;sanitize_title_with_dashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'save'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;asg_sanitize_title_with_dashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'save'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// core changed the cleanup logic — log + email ops, and (optionally) fall&lt;/span&gt;
        &lt;span class="c1"&gt;// back to core's generator so slugs cap at 200 again: degraded, but safe&lt;/span&gt;
        &lt;span class="c1"&gt;// and known, never a malformed slug from a stale copy.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This beats a source-hash check on two counts. It's &lt;strong&gt;semantic, not textual&lt;/strong&gt;: it&lt;br&gt;
stays quiet when core only touches the length handling (which you override anyway)&lt;br&gt;
and fires only when a change actually breaks &lt;em&gt;your&lt;/em&gt; output, so the alerts that do&lt;br&gt;
fire get acted on. And it &lt;strong&gt;degrades safely&lt;/strong&gt;: on drift you fall back to core's exact&lt;br&gt;
behaviour: worst case, new slugs cap at 200 until you re-sync, never a half-encoded&lt;br&gt;
slug from a stale copy. Loud-and-safe beats silent-and-wrong. The full guard (with&lt;br&gt;
the per-core-version cache and the optional fail-safe switch) is in the complete&lt;br&gt;
plugin below.&lt;/p&gt;
&lt;h2&gt;
  
  
  A tripwire, because silent failure is the real enemy
&lt;/h2&gt;

&lt;p&gt;The filter prevents the known incident. But the failure mode that actually hurt&lt;br&gt;
you was that it happened &lt;em&gt;silently&lt;/em&gt;, so the last piece is making sure you'd&lt;br&gt;
never again learn about it from a drop in search traffic. The same MU-plugin can&lt;br&gt;
verify the column widths after every update and shout if anything is wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hook &lt;code&gt;upgrader_process_complete&lt;/code&gt; to flag that a core update just ran.&lt;/li&gt;
&lt;li&gt;On the next admin request, check the real column widths in &lt;code&gt;information_schema&lt;/code&gt;
and, if either has shrunk, write to the error log and email ops.&lt;/li&gt;
&lt;li&gt;Expose a WP-CLI command for cron-based monitoring:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Nightly: verify the slug columns, email ops if either has reverted.&lt;/span&gt;
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;  &lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/site &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wp asg verify | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; REVERTED &lt;span class="se"&gt;\&lt;/span&gt;
           &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wp asg verify | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"WP slug schema reverted on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ops@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;One discipline to internalise: this tripwire restores &lt;em&gt;confidence&lt;/em&gt;, not &lt;em&gt;data&lt;/em&gt;.&lt;br&gt;
If it ever reports a reverted column, treat it as an incident, not a self-healing&lt;br&gt;
event; the slugs longer than the new width are already gone and must come back&lt;br&gt;
from a backup. Prevention (the filter) and backups are your safety net; the&lt;br&gt;
tripwire just tells you when to reach for them.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Isn't VARCHAR(1024) dangerous?": no, and the reason is reassuring
&lt;/h2&gt;

&lt;p&gt;Widening these columns &lt;em&gt;sounds&lt;/em&gt; risky (indexes, utf8mb4, InnoDB limits), and&lt;br&gt;
it's the part people most often get scared off by. It's almost entirely a&lt;br&gt;
non-issue, and one fact explains why:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The index on these columns is a fixed 191-character &lt;em&gt;prefix&lt;/em&gt;, independent of&lt;br&gt;
the column's declared length.&lt;/strong&gt; Core defines &lt;code&gt;KEY post_name (post_name(191))&lt;/code&gt;; it&lt;br&gt;
indexes the first 191 characters whether the column is 200 wide or 1024. Widening&lt;br&gt;
the column doesn't enlarge the index by a single byte.&lt;/p&gt;

&lt;p&gt;That dissolves the usual worries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Index / InnoDB prefix limits.&lt;/strong&gt; The old InnoDB prefix ceiling is 767 bytes;
WordPress chose 191 because 191 × 4 (utf8mb4's worst case) = 764, just under it.
Because the prefix stays at 191 no matter how wide the column grows, you never
move &lt;em&gt;towards&lt;/em&gt; that limit. (A quiet irony: slugs are stored as percent-encoded
ASCII, so they were never going to hit four bytes per character anyway.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row size.&lt;/strong&gt; &lt;code&gt;VARCHAR&lt;/code&gt; stores the &lt;em&gt;actual&lt;/em&gt; length of each value, not its
declared maximum; a 30-character slug takes ~30 bytes in a &lt;code&gt;VARCHAR(1024)&lt;/code&gt;
exactly as it would in a &lt;code&gt;VARCHAR(200)&lt;/code&gt;. Rows don't get bigger because the
ceiling moved up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query plans.&lt;/strong&gt; Lookups use the same 191-prefix index to narrow, then verify
the full value. Identical behaviour at 200 or 1024.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugins.&lt;/strong&gt; SEO plugins, caches and CDNs read &lt;code&gt;post_name&lt;/code&gt; and emit longer URLs
without complaint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The genuine risks aren't in steady state; they're tools that &lt;strong&gt;recreate the&lt;br&gt;
schema&lt;/strong&gt; from an old definition: a migration plugin that runs its own&lt;br&gt;
&lt;code&gt;CREATE TABLE&lt;/code&gt;, or, most commonly, restoring a SQL dump taken &lt;em&gt;before&lt;/em&gt; you widened&lt;br&gt;
the columns. A dump of the &lt;em&gt;current&lt;/em&gt; database preserves &lt;code&gt;VARCHAR(1024)&lt;/code&gt;&lt;br&gt;
correctly; importing an old structure silently puts you back at 200. Write that&lt;br&gt;
one down in your runbook.&lt;/p&gt;
&lt;h2&gt;
  
  
  Doing it safely on a site with millions of posts
&lt;/h2&gt;

&lt;p&gt;There's one operational gotcha, and it's about the migration itself, not the&lt;br&gt;
result. Going from &lt;code&gt;VARCHAR(200)&lt;/code&gt; to &lt;code&gt;VARCHAR(1024)&lt;/code&gt; &lt;strong&gt;cannot&lt;/strong&gt; be an instant,&lt;br&gt;
in-place change. InnoDB only keeps a &lt;code&gt;VARCHAR&lt;/code&gt; length change "in place" while both&lt;br&gt;
lengths stay in the same size class: values under 256 use one length byte,&lt;br&gt;
values 256 and over use two. Crossing 255 forces &lt;code&gt;ALGORITHM=COPY&lt;/code&gt;: a full table&lt;br&gt;
rebuild. On a &lt;code&gt;wp_posts&lt;/code&gt; with millions of rows, a raw &lt;code&gt;ALTER&lt;/code&gt; means a long&lt;br&gt;
operation and a problematic lock.&lt;/p&gt;

&lt;p&gt;So on a large live site, do the initial widening with an online schema-change&lt;br&gt;
tool, not a bare &lt;code&gt;ALTER&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pt-online-schema-change &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alter&lt;/span&gt; &lt;span class="s2"&gt;"MODIFY post_name VARCHAR(1024) NOT NULL DEFAULT ''"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--chunk-time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.5 &lt;span class="nt"&gt;--max-load&lt;/span&gt; &lt;span class="nv"&gt;Threads_running&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--execute&lt;/span&gt; &lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wordpress,t&lt;span class="o"&gt;=&lt;/span&gt;wp_posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;gh-ost&lt;/code&gt; is an equally good triggerless alternative.) &lt;code&gt;wp_terms&lt;/code&gt; is tiny and&lt;br&gt;
takes a plain &lt;code&gt;ALTER&lt;/code&gt; without ceremony; &lt;code&gt;wp_posts&lt;/code&gt; is the one that needs the&lt;br&gt;
online tool. The rollout that's bitten no one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Back up&lt;/strong&gt;, and confirm the dump shows &lt;code&gt;VARCHAR(1024)&lt;/code&gt; afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clone to staging&lt;/strong&gt;, deploy the MU-plugin, and run a &lt;em&gt;real&lt;/em&gt; major core update
there. Confirm no &lt;code&gt;CHANGE COLUMN&lt;/code&gt; appears and the verify command reports &lt;code&gt;OK&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy the MU-plugin to production &lt;em&gt;before&lt;/em&gt; the next core update&lt;/strong&gt;:
prevention has to be in place ahead of the event it prevents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widen the production columns off-peak&lt;/strong&gt; with pt-osc / gh-ost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a nightly verify cron and a 404 monitor&lt;/strong&gt;, so anything unexpected
surfaces in hours, not in a month of lost rankings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the MU-plugin in version control&lt;/strong&gt;, and never import a pre-widening
structure dump.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The deeper fix: stop betting your URLs on a column width
&lt;/h2&gt;

&lt;p&gt;Everything above makes Arabic slugs safe. But there's a more permanent way to&lt;br&gt;
think about it, and for a large news archive it's worth the shift: &lt;strong&gt;don't let&lt;br&gt;
the URL depend on the slug surviving at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anchor your permalinks on the numeric post ID, and keep the Arabic slug as a&lt;br&gt;
descriptive (but cosmetic) suffix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/123456/الذكاء-الاصطناعي
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolve the post by its ID, and the slug becomes decoration. If it's ever&lt;br&gt;
truncated, malformed, or edited, the URL &lt;strong&gt;still resolves&lt;/strong&gt; to the right article&lt;br&gt;
instead of 404-ing, and WordPress can &lt;code&gt;301&lt;/code&gt; to the canonical form. You keep the&lt;br&gt;
Arabic keywords in the URL for search relevance &lt;em&gt;and&lt;/em&gt; you become structurally&lt;br&gt;
immune to the entire class of problem. (If you want to stop touching core tables&lt;br&gt;
altogether, you can go one step further and store the long slug in your own&lt;br&gt;
indexed routing table, which dbDelta has no opinion about, but for most teams,&lt;br&gt;
ID-anchored permalinks are the sweet spot.)&lt;/p&gt;

&lt;p&gt;That's the difference between patching a bug and designing it out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The complete plugin
&lt;/h2&gt;

&lt;p&gt;Everything above, assembled into one must-use plugin. Drop it in&lt;br&gt;
&lt;code&gt;wp-content/mu-plugins/wp-arabic-slug-schema-guard.php&lt;/code&gt;, widen the columns once with&lt;br&gt;
an online schema-change tool, and all three layers, plus the tripwire, are&lt;br&gt;
handled in a single place. The slug generator is a copy of core's&lt;br&gt;
&lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;; only the byte cap differs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📦 &lt;strong&gt;Prefer it ready-made?&lt;/strong&gt; The plugin is open-source on GitHub as &lt;a href="https://github.com/mantekio/wp-arabic-slug-schema-guard" rel="noopener noreferrer"&gt;&lt;code&gt;mantekio/wp-arabic-slug-schema-guard&lt;/code&gt;&lt;/a&gt;, or &lt;code&gt;composer require mantekio/wp-arabic-slug-schema-guard&lt;/code&gt;. Star it, file an issue, or lift it whole.&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * Plugin Name: Arabic Slug Schema Guard (must-use)
 * Description: Keeps wp_posts.post_name and wp_terms.slug at VARCHAR(1024) across
 *              core DB upgrades, lets core generate long URL-encoded Arabic slugs,
 *              and verifies + alerts after every update. Prevention-first:
 *              dbDelta never truncates, because the canonical schema it diffs
 *              against already says 1024. The slug fork self-tests against core
 *              on each update and can fail safe.
 */&lt;/span&gt;
&lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ABSPATH'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ASG_COLUMN_LEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// physical column width (bytes)&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ASG_SLUG_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// max generated slug length, under the column width&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ASG_SCHEMA_SIG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'post_name:1024;slug:1024'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// define( 'ASG_ALERT_EMAIL', 'ops@example.com' );  // optional, set in wp-config.php&lt;/span&gt;
&lt;span class="c1"&gt;// define( 'ASG_L2_FAILSAFE', true );               // optional: on Layer-2 drift, fall back to core (slugs cap at 200) until you re-sync&lt;/span&gt;

&lt;span class="cm"&gt;/* LAYER 1 — PREVENTION
 * Rewrite the canonical CREATE TABLE dbDelta diffs against, so "desired" already
 * equals the live 1024-wide column and no destructive CHANGE COLUMN is emitted. */&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dbdelta_create_queries'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$queries&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'post_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'/(\b'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'\s+varchar\()\s*200\s*(\))/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'${1}'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;ASG_COLUMN_LEN&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'${2}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$queries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cm"&gt;/* LAYER 2 — GENERATION
 * Core caps new slugs at 200 bytes via utf8_uri_encode($title, 200). Swap in a
 * copy of sanitize_title_with_dashes() whose only change is the byte budget.
 * Installed conditionally: if the drift guard (below) flagged THIS core version
 * and ASG_L2_FAILSAFE is on, stay on core's default (caps at 200 — degraded but
 * safe and known) rather than run a fork we know has drifted. */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_l2_status'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'drift:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$GLOBALS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'wp_version'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
     &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ASG_L2_FAILSAFE'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;ASG_L2_FAILSAFE&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Known-drifted on this core version → leave core's generator in place.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;remove_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title_with_dashes'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'asg_sanitize_title_with_dashes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;asg_sanitize_title_with_dashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$raw_title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'display'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strip_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Preserve already-encoded octets through the cleanup below.&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'|%([a-fA-F0-9][a-fA-F0-9])|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'---$1---'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'|---([a-fA-F0-9][a-fA-F0-9])---|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%$1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;seems_utf8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'mb_strtolower'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;utf8_uri_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ASG_SLUG_BYTES&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// core hard-codes 200 here&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'save'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// EVERYTHING here is copied verbatim from core. The ONLY sanctioned difference&lt;/span&gt;
        &lt;span class="c1"&gt;// between this fork and core is the byte budget above. Drop a line and you have&lt;/span&gt;
        &lt;span class="c1"&gt;// drifted — see the note under the drift guard below.&lt;/span&gt;

        &lt;span class="c1"&gt;// Turn non-breaking spaces, en/em dashes and slashes into hyphens.&lt;/span&gt;
        &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'%c2%a0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%93'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%94'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;nbsp;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;#160;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;ndash;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;#8211;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;mdash;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;#8212;'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Strip these characters entirely. Note %c2%ab / %c2%bb: the guillemets Arabic&lt;/span&gt;
        &lt;span class="c1"&gt;// uses as quotation marks. And %e2%80%8e / %e2%80%8f: the LRM and RTL marks.&lt;/span&gt;
        &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'%c2%ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                            &lt;span class="c1"&gt;// soft hyphen&lt;/span&gt;
                &lt;span class="s1"&gt;'%c2%a1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%c2%bf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                  &lt;span class="c1"&gt;// iexcl, iquest&lt;/span&gt;
                &lt;span class="s1"&gt;'%c2%ab'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%c2%bb'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%b9'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%ba'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// angle quotes&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%98'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%99'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%9c'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%9d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// curly quotes&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%9a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%9b'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%9e'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%9f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%a2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                         &lt;span class="c1"&gt;// bullet&lt;/span&gt;
                &lt;span class="s1"&gt;'%c2%a9'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%c2%ae'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%c2%b0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%a6'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%84%a2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'%c2%b4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%cb%8a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%cc%81'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%cd%81'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// acute accents&lt;/span&gt;
                &lt;span class="s1"&gt;'%cc%80'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%cc%84'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%cc%8c'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                        &lt;span class="c1"&gt;// grave, macron, caron&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%8b'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%8c'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%8d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// zero-width chars&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%8e'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%8f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                            &lt;span class="c1"&gt;// LRM, RTL mark&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%aa'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%ab'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%ac'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'%e2%80%ad'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%e2%80%ae'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'%ef%bb%bf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                         &lt;span class="c1"&gt;// byte order mark&lt;/span&gt;
                &lt;span class="s1"&gt;'%ef%bf%bc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                         &lt;span class="c1"&gt;// object replacement&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$title&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Convert &amp;amp;times to 'x'.&lt;/span&gt;
        &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'%c3%97'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/&amp;amp;.+?;/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// strip remaining entities&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/[^%a-z0-9 _-]/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// keep only slug-safe chars&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\s+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'|-+|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* LAYER 2 DRIFT GUARD — self-test the fork against core's live implementation.
 * remove_filter() only unhooks sanitize_title_with_dashes(); the function stays
 * callable, so we use core's CURRENT code as a live oracle and assert our fork
 * still agrees on SHORT inputs (where neither byte cap engages — the only thing
 * the fork changed). Any divergence == core altered the cleanup logic and our
 * copy has drifted. Runs once per core version; shouts, and can fail safe. */&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'asg_guard_layer2_drift'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;asg_guard_layer2_drift&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$ok&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ok:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$GLOBALS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'wp_version'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$drifted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'drift:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$GLOBALS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'wp_version'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_l2_status'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$drifted&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// already settled for this core version&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_title_with_dashes'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// core refactored it away — no oracle to diff against; the fork&lt;/span&gt;
                 &lt;span class="c1"&gt;// still runs as a standalone copy (it needs no removed function).&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Deliberately SHORT fixtures so neither length cap fires — any difference is&lt;/span&gt;
    &lt;span class="c1"&gt;// pure cleanup-logic drift, not the byte budget.&lt;/span&gt;
    &lt;span class="nv"&gt;$fixtures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'الذكاء الاصطناعي'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'عاجل: تطورات «مهمة» اليوم'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Mixed عربي + English — dash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'foo/bar &amp;amp;mdash; baz'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'UPPER   multiple   spaces'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'%d8%a7 pre-encoded octet'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$drift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$fixtures&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$core&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitize_title_with_dashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'save'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$ours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;asg_sanitize_title_with_dashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'save'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$core&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ours&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$drift&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in=[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] core=[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$core&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] ours=[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$ours&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$drift&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"[Arabic Slug Schema Guard] Layer-2 DRIFT on WP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$GLOBALS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'wp_version'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: core "&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"sanitize_title_with_dashes() no longer matches our fork — re-sync the copy.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$drift&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ASG_ALERT_EMAIL'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_mail'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;wp_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="no"&gt;ASG_ALERT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'WP slug Layer-2 drift: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;wp_parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_HOST&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_l2_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$drifted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// autoload — the fail-safe reads it every request&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// do NOT cache "ok" while drifted&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_l2_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* LAYER 3 — DE-DUPLICATION (optional)
 * _truncate_post_slug() also caps at 200, but only when a slug COLLIDES and needs
 * a "-2" suffix — rare for unique headlines, and it isn't filterable. If you need
 * &amp;gt;200 bytes even on collisions, take over uniqueness here. Most sites skip this.
 *
 * add_filter( 'pre_wp_unique_post_slug', 'asg_unique_post_slug', 10, 6 );
 */&lt;/span&gt;

&lt;span class="cm"&gt;/* TRIPWIRE — verify + alert after every core update
 * This only detects and alerts: it restores the COLUMN, never bytes already
 * truncated. Treat any revert as an incident — restore from backup. */&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'upgrader_process_complete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$upgrader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'core'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;delete_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_schema_sig'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// force a re-check on the next admin_init&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'admin_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_schema_sig'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="no"&gt;ASG_SCHEMA_SIG&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// fast path: already verified since the last update&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$reverted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;asg_reverted_columns&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$reverted&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'[Arabic Slug Schema Guard] Column(s) reverted: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$reverted&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'. Long slugs were truncated — restore from backup and check 404 logs.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ASG_ALERT_EMAIL'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;wp_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="no"&gt;ASG_ALERT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'WP slug schema reverted: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;wp_parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_HOST&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// do NOT cache "ok" while broken&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg_schema_sig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ASG_SCHEMA_SIG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;asg_reverted_columns&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$reverted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'post_name'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'slug'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS
              WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="no"&gt;DB_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ASG_COLUMN_LEN&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$reverted&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (now &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$len&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$reverted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* WP-CLI — `wp asg verify` for cron-based monitoring */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_CLI'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;WP_CLI&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;WP_CLI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'asg verify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;asg_reverted_columns&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="no"&gt;WP_CLI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'Schema OK — both columns at VARCHAR(1024).'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"REVERTED: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A postscript: the drift guard caught us
&lt;/h3&gt;

&lt;p&gt;The argument above is that a fork of a core function drifts silently, so the guard&lt;br&gt;
matters more than the copy. That turned out to be true in the least comfortable way.&lt;/p&gt;

&lt;p&gt;Versions 1.0.0 through 1.2.0 of the released plugin shipped a fork that was &lt;strong&gt;missing&lt;br&gt;
core's entire "strip these characters entirely" block&lt;/strong&gt;, along with the &lt;code&gt;×&lt;/code&gt; to &lt;code&gt;x&lt;/code&gt;&lt;br&gt;
mapping. The consequence lands exactly on the readers this post is written for: Arabic&lt;br&gt;
guillemets (&lt;code&gt;«&lt;/code&gt; &lt;code&gt;»&lt;/code&gt;) and the LRM/RTL direction marks survived into slugs as&lt;br&gt;
&lt;code&gt;%c2%ab&lt;/code&gt;, &lt;code&gt;%c2%bb&lt;/code&gt;, &lt;code&gt;%e2%80%8f&lt;/code&gt;. The character filter at the end cannot remove them,&lt;br&gt;
because &lt;code&gt;%c2%ab&lt;/code&gt; is built entirely from permitted characters.&lt;/p&gt;

&lt;p&gt;The drift guard printed above would have caught it on the first &lt;code&gt;admin_init&lt;/code&gt;. Its&lt;br&gt;
second fixture is &lt;code&gt;'عاجل: تطورات «مهمة» اليوم'&lt;/code&gt;. It never fired, because the guard is&lt;br&gt;
bound to &lt;code&gt;admin_init&lt;/code&gt; and nobody had opened &lt;code&gt;wp-admin&lt;/code&gt; on an install running that&lt;br&gt;
fork. A guard on a hook that never fires is a comment.&lt;/p&gt;

&lt;p&gt;Both are fixed in &lt;strong&gt;v1.3.0&lt;/strong&gt;: the fork is re-synced (the block above is now complete&lt;br&gt;
and verified against core by an offline oracle on every build), and &lt;code&gt;wp asg verify&lt;/code&gt;&lt;br&gt;
evaluates the drift oracle itself rather than waiting for an admin page load. If you&lt;br&gt;
lifted this code before July 2026, take the &lt;code&gt;str_replace&lt;/code&gt; block above.&lt;/p&gt;




&lt;p&gt;This is the kind of problem that never shows up in a tutorial. It lives where&lt;br&gt;
WordPress internals, MySQL's storage rules, Arabic character encoding and SEO all&lt;br&gt;
overlap, and it only reveals itself at the worst possible moment. It's also the&lt;br&gt;
territory we work in every day: keeping Arabic publishing fast, multilingual and&lt;br&gt;
unbreakable on WordPress, &lt;a href="https://www.mantek.io/wordpress-aws" rel="noopener noreferrer"&gt;right through to AWS at scale&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run a WordPress publication in Arabic and want it to survive its own&lt;br&gt;
updates, &lt;a href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.wordpress.org/reference/functions/dbdelta/" rel="noopener noreferrer"&gt;&lt;code&gt;dbDelta()&lt;/code&gt;&lt;/a&gt; and the &lt;a href="https://developer.wordpress.org/reference/hooks/dbdelta_create_queries/" rel="noopener noreferrer"&gt;&lt;code&gt;dbdelta_create_queries&lt;/code&gt; hook&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://core.trac.wordpress.org/ticket/36748" rel="noopener noreferrer"&gt;Trac #36748&lt;/a&gt;: the TEXT/BLOB downsize protection that VARCHAR never got&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.wordpress.org/reference/functions/sanitize_title_with_dashes/" rel="noopener noreferrer"&gt;&lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://developer.wordpress.org/reference/functions/_truncate_post_slug/" rel="noopener noreferrer"&gt;&lt;code&gt;_truncate_post_slug()&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.wordpress.org/reference/hooks/upgrader_process_complete/" rel="noopener noreferrer"&gt;&lt;code&gt;upgrader_process_complete&lt;/code&gt; hook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>How we built mantek.io for the price of a domain</title>
      <dc:creator>Jaafar Abazid</dc:creator>
      <pubDate>Tue, 16 Jun 2026 23:43:33 +0000</pubDate>
      <link>https://dev.to/mantekio/how-we-built-mantekio-for-the-price-of-a-domain-1c9c</link>
      <guid>https://dev.to/mantekio/how-we-built-mantekio-for-the-price-of-a-domain-1c9c</guid>
      <description>&lt;p&gt;The site you're reading costs us almost nothing to run. There's no server invoice, no hosting bill, no monthly platform fee — just the domain name, renewed once a year. Everything else runs on infrastructure that's free at our scale and fast everywhere in the world.&lt;/p&gt;

&lt;p&gt;That isn't a corner we cut. It's a set of deliberate choices — static output, edge delivery, git as the backbone — that happen to be cheaper &lt;em&gt;and&lt;/em&gt; faster &lt;em&gt;and&lt;/em&gt; simpler than the alternative. Here's the whole stack, and the thinking behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole bill is the domain
&lt;/h2&gt;

&lt;p&gt;Take the site apart and look for the costs, and there's only one line item.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hosting and CDN: nothing.&lt;/strong&gt; The site lives on Cloudflare Pages, which serves it from data centres in hundreds of cities, with unlimited bandwidth on the free tier. The thing that usually costs the most — global delivery — costs zero here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS: nothing.&lt;/strong&gt; Managed on Cloudflare alongside the domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email Sending: nothing.&lt;/strong&gt; Contact-form mail goes through Resend's free tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The domain: the only invoice.&lt;/strong&gt; Renewed yearly, and that's the entire running cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The infrastructure is free; the engineering is the investment. That's the trade — and for a site that has to be fast, bilingual and maintainable for years, it's the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static output, Git as the pipeline
&lt;/h2&gt;

&lt;p&gt;The site is built with Astro and compiled to plain HTML and CSS. There's no application server running in the background — nothing to patch, scale, or wake up when a visitor arrives. Cloudflare serves the finished files from the edge location nearest each reader, which is why pages feel instant.&lt;/p&gt;

&lt;p&gt;Deployment is just git. We push to the main branch; Cloudflare builds the site and publishes it worldwide in a minute or less. Every branch gets its own preview URL, so a change can be reviewed on a real address before it goes live. And because the repository &lt;em&gt;is&lt;/em&gt; the source of truth, undoing a bad change is a git revert — not a 2 a.m. restore from backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  A CMS with no database
&lt;/h2&gt;

&lt;p&gt;This blog is editable in the browser, but there's no database behind it and no CMS server to keep alive. The editor is git-based: saving a post commits a Markdown file to the repository and rides the same automatic deploy as the code.&lt;/p&gt;

&lt;p&gt;That has quiet benefits. Every edit is versioned like code, with full history. There's no database to back up, secure, or migrate. The editor sits behind GitHub sign-in, so there's no separate set of passwords to manage and very little for a scanner to find. Content and code live in one place, and ship the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A contact form without a back end
&lt;/h2&gt;

&lt;p&gt;A contact form normally means a server somewhere waiting to receive it. Ours is a single serverless function that runs at the edge, on demand: it validates the submission and hands it to Resend, which sends the email. No always-on back end, no queue, no infrastructure to maintain between messages.&lt;/p&gt;

&lt;p&gt;The credentials never touch the codebase. The Resend API key lives in Cloudflare's encrypted environment variables, read by the function at run time — it isn't in the repository and isn't in the published site. Spam is caught with a hidden honeypot field and server-side checks rather than a third-party CAPTCHA. And the form degrades gracefully: with JavaScript switched off it submits natively and returns a thank-you page; with JavaScript on it posts in the background and shows inline status. No cookies, either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chasing a perfect score
&lt;/h2&gt;

&lt;p&gt;A static site is a fast &lt;em&gt;start&lt;/em&gt;, not a finished one. Getting to a perfect Lighthouse score — 100 across Performance, Accessibility, Best Practices and SEO — took a series of small, deliberate decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted fonts, preloaded.&lt;/strong&gt; The fonts are served from our own domain, not a third-party font CDN, and the one the headline renders in is preloaded so it's ready for the first paint. The Arabic typeface only downloads on Arabic pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No render-blocking JavaScript.&lt;/strong&gt; What little script the site needs is inlined and tiny; there's no framework runtime shipping to the browser. These are HTML pages with a few grams of JavaScript, not an app pretending to be a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One shared stylesheet.&lt;/strong&gt; The CSS is a single file, cached across every page, so moving around the site re-downloads nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Work the browser can skip.&lt;/strong&gt; Sections below the fold are marked so the browser doesn't spend layout effort on them until they're about to be seen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No flash, no shift.&lt;/strong&gt; The colour theme is resolved before the first paint, so nothing flickers from light to dark or jumps around as it loads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is dramatic on its own. Together they're the difference between "fast enough" and a perfect score.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cached hard, but never stale
&lt;/h2&gt;

&lt;p&gt;Speed and freshness usually pull against each other; the right caching rules give you both. Build assets get fingerprinted filenames, cached for a year and marked &lt;em&gt;immutable&lt;/em&gt; — the browser never asks about them again. Images are cached for a week. But the HTML itself is always revalidated, so the moment we publish, every reader sees the new version.&lt;/p&gt;

&lt;p&gt;The one lesson worth passing on: &lt;em&gt;immutable&lt;/em&gt; means exactly that. It belongs only on files whose name changes when their contents do. Put it on a stable URL and you'll spend a day fighting your own cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secure and findable by default
&lt;/h2&gt;

&lt;p&gt;A set of security headers is applied at the edge, for free: strict HTTPS (HSTS), protection against clickjacking and MIME-sniffing, a conservative referrer policy, and a permissions policy that switches off the camera, microphone and location access the site never uses. There's one canonical hostname; everything else redirects to it.&lt;/p&gt;

&lt;p&gt;Findability is built in the same spirit. Every page declares its canonical URL and its English/Arabic alternates, so search engines read the two languages as one site rather than duplicate content. Each post generates its own social-share card. Structured data, an auto-generated sitemap and an RSS feed ship on every build. And the analytics are cookieless — which means no consent banner slowing the page down or greeting the reader with a pop-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two languages, one build
&lt;/h2&gt;

&lt;p&gt;The site is fully bilingual: English at the root, Arabic — right-to-left — under &lt;code&gt;/ar/&lt;/code&gt;. There's no duplicate site and no translation plugin. The same components render both languages from a dictionary, down to mirrored layouts and translated structured data. Adding a language is additive, not a rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building something similar
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default to static.&lt;/strong&gt; If a page can be HTML, it should be. Everything downstream — speed, cost, security — gets easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make git the backbone.&lt;/strong&gt; Deploys, content, rollbacks and history in one place beats four systems that have to be kept in sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend the savings on judgement.&lt;/strong&gt; Free infrastructure frees the budget for the part that actually matters — the engineering.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;This is the same discipline we bring to client work: systems that are fast, cheap to run, and built to last. If you'd like a site that behaves like this one, &lt;a href="https://www.mantek.io/services" rel="noopener noreferrer"&gt;see what we do&lt;/a&gt; or &lt;a href="https://www.mantek.io/contact" rel="noopener noreferrer"&gt;start a conversation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>astro</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
