You update WordPress on a quiet afternoon — a routine release, the kind you've
installed a hundred times. The dashboard says everything went fine. Then the
404s start: not a handful, but every long-headlined article in your archive, all
at once, all in Arabic.
Nothing in the update log mentions it. No plugin changed. And the cruel part:
the data that made those URLs work is already gone — shaved off inside a
database-upgrade routine that ran for a few milliseconds and reported success.
This isn't a freak accident or a broken plugin. It's three separate assumptions
baked into WordPress core, each hard-coding the same number — 200 — and
Arabic sites are almost uniquely exposed to all three. We hit this running
WordPress for Arabic newsrooms, the same high-traffic publishing we've written
about , born from exactly
this kind of data loss). VARCHAR got no such guard. So a TEXT column is safe
from accidental shrinking; your VARCHAR(1024) slug column is not. dbDelta
shrinks it without hesitation, and restoring the column to 1024 afterwards brings
back the width but not the data — those bytes were handed to MySQL and
dropped.
That's why your fix keeps coming undone: always right after an update, always
silently. It isn't a bug aimed at you — it's core doing exactly what it was
designed to do, to a column you were never expected to change.
For accuracy: a separate routine,
pre_schema_upgrade(), also runs hand-written
ALTERs, but those are version-gated to historic migrations and won't touch a
modern site'spost_name. The reversion you're seeing is dbDelta.
The fix — don't repair the damage, prevent the shrink
The instinct is to write something that detects the reverted column and
re-widens it after each update. Resist it. By the time you can detect the shrink,
the data is already gone — you'd just be putting an empty wider column back over
truncated slugs. The only fix that actually saves the data is one that stops
the shrink from ever happening.
And core hands you the seam to do it. dbDelta exposes a filter,
dbdelta_create_queries, that receives the canonical CREATE TABLE statements
before they're compared to the live database. Rewrite the canonical
post_name varchar(200) to varchar(1024) in that filter, and dbDelta's idea of
"correct" now matches what's actually on disk. It sees no mismatch. It emits no
CHANGE COLUMN. There is nothing to truncate.
<?php
/**
* 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.
*/
defined( 'ABSPATH' ) || exit;
const SLUG_COLUMN_LEN = 1024;
// Rewrite the canonical schema dbDelta diffs against, so "desired" already
// equals the live 1024-wide column — no CHANGE COLUMN is ever emitted.
add_filter( 'dbdelta_create_queries', function ( $queries ) {
global $wpdb;
foreach ( array( $wpdb->posts => 'post_name', $wpdb->terms => 'slug' ) as $table => $column ) {
if ( isset( $queries[ $table ] ) ) {
$queries[ $table ] = preg_replace(
'/(\b' . $column . '\s+varchar\()\s*200\s*(\))/i',
'${1}' . SLUG_COLUMN_LEN . '${2}',
$queries[ $table ]
);
}
}
return $queries;
} );
That single filter is the whole prevention story for Layer 1, and it's robust in
a way an after-the-fact repair can never be: it runs inside dbDelta, so it
covers every path that triggers a schema reconcile — the admin "database
update required" screen, background auto-updates, and wp core update-db on the
CLI alike.
Where this code lives matters as much as what it does. Put it in a must-use
plugin — a single .php file in wp-content/mu-plugins/ — not a regular
plugin. MU-plugins always load, can't be deactivated from the dashboard, and load
before the upgrade routine runs. And because a core update only ever replaces
wp-admin, wp-includes and the root PHP files — it never touches wp-content —
your guard is guaranteed to still be in place at the exact moment the upgrade
fires. A regular plugin could be deactivated the one time it mattered; don't bet
your archive on a toggle.
Layer 2 — stop new slugs truncating at birth
Prevention on the column keeps your existing slugs safe. But Layer 2 is still
cutting every new slug to 200 bytes at the moment of creation, inside
sanitize_title_with_dashes(). The relevant line, unchanged in current core, is:
$title = utf8_uri_encode( $title, 200 );
That function is attached to the sanitize_title filter, so you can cleanly swap
it for an identical copy that uses a larger byte budget:
// Layer 2: replace core's slug generator with a byte-for-byte copy whose only
// change is the encoding cap. Re-sync if core ever rewrites this function (rare).
remove_filter( 'sanitize_title', 'sanitize_title_with_dashes' );
add_filter( 'sanitize_title', 'asg_sanitize_title_with_dashes', 10, 3 );
function asg_sanitize_title_with_dashes( $title, $raw_title = '', $context = 'display' ) {
// … the body is line-for-line core, with one change:
// utf8_uri_encode( $title, 200 ) → utf8_uri_encode( $title, 1000 )
// 1000 leaves headroom under the 1024 column for a "-2" collision suffix.
}
Pick a sane budget — around 1000 bytes (≈ 160 Arabic characters) leaves room
under the column for the numeric suffix WordPress adds to resolve duplicates. You
don't want unlimited slugs; you want enough.
Layer 3 — _truncate_post_slug() — caps at 200 too, but it only runs when a slug
collides and needs a -2 suffix. For unique news headlines that almost never
fires, and it isn't filterable, so most sites can leave it alone. If you
genuinely need more than 200 bytes even on collisions, you take over uniqueness
through the pre_wp_unique_post_slug filter — an advanced case worth a footnote,
not a paragraph.
A tripwire, because silent failure is the real enemy
The filter prevents the known incident. But the failure mode that actually hurt
you was that it happened silently — so the last piece is making sure you'd
never again learn about it from a drop in search traffic. The same MU-plugin can
verify the column widths after every update and shout if anything is wrong:
- Hook
upgrader_process_completeto flag that a core update just ran. - On the next admin request, check the real column widths in
information_schema
and, if either has shrunk, write to the error log and email ops. - Expose a WP-CLI command for cron-based monitoring:
# Nightly: verify the slug columns, email ops if either has reverted.
0 3 * * * cd /var/www/site && wp asg verify | grep -q REVERTED \
&& wp asg verify | mail -s "WP slug schema reverted on $(hostname)" [email protected]
One discipline to internalise: this tripwire restores confidence, not data.
If it ever reports a reverted column, treat it as an incident, not a self-healing
event — the slugs longer than the new width are already gone and must come back
from a backup. Prevention (the filter) and backups are your safety net; the
tripwire just tells you when to reach for them.
"Isn't VARCHAR(1024) dangerous?" — no, and the reason is reassuring
Widening these columns sounds risky — indexes, utf8mb4, InnoDB limits — and
it's the part people most often get scared off by. It's almost entirely a
non-issue, and one fact explains why:
The index on these columns is a fixed 191-character prefix, independent of
the column's declared length. Core defines KEY post_name (post_name(191)) — it
indexes the first 191 characters whether the column is 200 wide or 1024. Widening
the column doesn't enlarge the index by a single byte.
That dissolves the usual worries:
Index / InnoDB prefix limits. 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 towards that limit. (A quiet irony: slugs are stored as percent-encoded
ASCII, so they were never going to hit four bytes per character anyway.)
Row size.VARCHARstores the actual length of each value, not its
declared maximum — a 30-character slug takes ~30 bytes in aVARCHAR(1024)
exactly as it would in aVARCHAR(200). Rows don't get bigger because the
ceiling moved up.
Query plans. Lookups use the same 191-prefix index to narrow, then verify
the full value. Identical behaviour at 200 or 1024.
Plugins. SEO plugins, caches and CDNs readpost_nameand emit longer URLs
without complaint.
The genuine risks aren't in steady state — they're tools that recreate the
schema from an old definition: a migration plugin that runs its own
CREATE TABLE, or, most commonly, restoring a SQL dump taken before you widened
the columns. A dump of the current database preserves VARCHAR(1024)
correctly; importing an old structure silently puts you back at 200. Write that
one down in your runbook.
Doing it safely on a site with millions of posts
There's one operational gotcha, and it's about the migration itself, not the
result. Going from VARCHAR(200) to VARCHAR(1024) cannot be an instant,
in-place change. InnoDB only keeps a VARCHAR length change "in place" while both
lengths stay in the same size class — values under 256 use one length byte,
values 256 and over use two. Crossing 255 forces ALGORITHM=COPY: a full table
rebuild. On a wp_posts with millions of rows, a raw ALTER means a long
operation and a problematic lock.
So on a large live site, do the initial widening with an online schema-change
tool, not a bare ALTER:
pt-online-schema-change \
--alter "MODIFY post_name VARCHAR(1024) NOT NULL DEFAULT ''" \
--chunk-time=0.5 --max-load Threads_running=50 \
--execute D=wordpress,t=wp_posts
(gh-ost is an equally good triggerless alternative.) wp_terms is tiny and
takes a plain ALTER without ceremony; wp_posts is the one that needs the
online tool. The rollout that's bitten no one:
Back up, and confirm the dump showsVARCHAR(1024)afterwards.
Clone to staging, deploy the MU-plugin, and run a real major core update
there. Confirm noCHANGE COLUMNappears and the verify command reportsOK.
Deploy the MU-plugin to production before the next core update —
prevention has to be in place ahead of the event it prevents.
Widen the production columns off-peak with pt-osc / gh-ost.
Add a nightly verify cron and a 404 monitor, so anything unexpected
surfaces in hours, not in a month of lost rankings.
Keep the MU-plugin in version control, and never import a pre-widening
structure dump.
The deeper fix — stop betting your URLs on a column width
Everything above makes Arabic slugs safe. But there's a more permanent way to
think about it, and for a large news archive it's worth the shift: don't let
the URL depend on the slug surviving at all.
Anchor your permalinks on the numeric post ID, and keep the Arabic slug as a
descriptive — but cosmetic — suffix:
https://example.com/123456/الذكاء-الاصطناعي
Resolve the post by its ID, and the slug becomes decoration. If it's ever
truncated, malformed, or edited, the URL still resolves to the right article
instead of 404-ing — and WordPress can 301 to the canonical form. You keep the
Arabic keywords in the URL for search relevance and you become structurally
immune to the entire class of problem. (If you want to stop touching core tables
altogether, you can go one step further and store the long slug in your own
indexed routing table, which dbDelta has no opinion about — but for most teams,
ID-anchored permalinks are the sweet spot.)
That's the difference between patching a bug and designing it out.
The complete plugin
Everything above, assembled into one must-use plugin. Drop it in
wp-content/mu-plugins/arabic-slug-schema-guard.php, widen the columns once with
an online schema-change tool, and all three layers — plus the tripwire — are
handled in a single place. The slug generator is a copy of core's
sanitize_title_with_dashes(); only the byte cap differs.
📦 Prefer it ready-made? The plugin is open-source on GitHub as .
If you run a WordPress publication in Arabic and want it to survive its own
updates, and the — the TEXT/BLOB downsize protection that VARCHAR never got
upgrader_process_completehook
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR