PHPackages                             mantekio/arabic-slug-schema-guard - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Localization &amp; i18n](/categories/localization)
4. /
5. mantekio/arabic-slug-schema-guard

Abandoned → [mantekio/wp-arabic-slug-schema-guard](/?search=mantekio%2Fwp-arabic-slug-schema-guard)Wordpress-muplugin[Localization &amp; i18n](/categories/localization)

mantekio/arabic-slug-schema-guard
=================================

WordPress must-use plugin that stops core updates from truncating long Arabic URLs (the VARCHAR(200) slug-column trap).

v1.1.2(1mo ago)25GPL-2.0-or-laterPHP

Since Jun 18Pushed 3w agoCompare

[ Source](https://github.com/mantekio/arabic-slug-schema-guard)[ Packagist](https://packagist.org/packages/mantekio/arabic-slug-schema-guard)[ Docs](https://github.com/mantekio/arabic-slug-schema-guard)[ RSS](/packages/mantekio-arabic-slug-schema-guard/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (2)Versions (5)Used By (0)

Arabic Slug Schema Guard
========================

[](#arabic-slug-schema-guard)

[![Packagist Version](https://camo.githubusercontent.com/c89e9c41277687dfde0362686e5449b5f6aed8a1273aa387fbde85d60c222fa5/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d616e74656b696f2f77702d6172616269632d736c75672d736368656d612d6775617264)](https://packagist.org/packages/mantekio/wp-arabic-slug-schema-guard) [![License: GPL v2](https://camo.githubusercontent.com/34fd4798bfe47593a94283d1d4fb5c522738d07b84c13f374f10485c0e0ffbbf/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d47504c76322d626c75652e737667)](LICENSE)

A WordPress **must-use plugin** that stops core updates from silently truncating long Arabic (and other non-Latin) URLs.

> 📖 **Full write-up:** [The 200-byte trap: why WordPress core updates break Arabic URLs](https://www.mantek.io/insights/wordpress-arabic-slug-truncation)

The problem
-----------

[](#the-problem)

WordPress stores post and term slugs **percent-encoded** in `VARCHAR(200)` columns (`wp_posts.post_name`, `wp_terms.slug`). Each Arabic character costs about **six bytes** once URL-encoded, so a `VARCHAR(200)` column holds only ~33 Arabic characters, and Arabic publishers widen the columns to `VARCHAR(1024)`.

The trap: on every major core update, `dbDelta()` reconciles the live schema against WordPress's canonical schema and **shrinks `VARCHAR(1024)` back to `VARCHAR(200)`**. Unlike `TEXT`/`BLOB`, `VARCHAR` has no downsize protection, so the truncation is silent and **unrecoverable**, and your long-headline URLs start returning 404.

Widening the column alone isn't enough, either: WordPress hard-codes `200` in **three** independent places: storage, slug **generation** (`sanitize_title_with_dashes()`), and collision **de-duplication** (`_truncate_post_slug()`).

What it does
------------

[](#what-it-does)

- **Prevents the shrink** (Layer 1): filters `dbdelta_create_queries` so dbDelta's *desired* schema already says `1024`; it never emits a destructive `CHANGE COLUMN`. Covers the admin DB-upgrade screen, background auto-updates, and `wp core update-db`.
- **Stops new slugs truncating** (Layer 2): replaces `sanitize_title_with_dashes()` with a byte-for-byte copy whose **only** difference from core is the byte budget. The copy **self-tests against core** on each update: it diffs the fork against core's live function on short inputs (where neither cap fires), so if core rewrites that function, or the fork falls behind it, the drift is reported instead of shipping silently.
- **Verifies + alerts** (tripwire): after every core update it checks the real column widths, logs and (optionally) emails on a revert, and exposes a `wp asg verify` CLI command for cron.

Layer 3 (collision de-dup) only fires on slug clashes and is left optional (see the write-up).

Installation
------------

[](#installation)

This is a must-use plugin, so it loads before the upgrade routine runs and can't be deactivated by accident.

**Manual**

```
mkdir -p wp-content/mu-plugins
cp wp-arabic-slug-schema-guard.php wp-content/mu-plugins/
```

**Composer**

```
composer require mantekio/wp-arabic-slug-schema-guard
```

Composer installs it as a [`wordpress-muplugin`](https://github.com/composer/installers), which has two consequences worth knowing:

- **Allow the installer plugin.** On a fresh project Composer blocks `composer/installers` until you permit it. Add this to your root `composer.json` (most WordPress-Composer stacks already have it): ```
    { "config": { "allow-plugins": { "composer/installers": true } } }
    ```
- **Make sure it actually loads.** It installs into `wp-content/mu-plugins/wp-arabic-slug-schema-guard/` (a subfolder). Vanilla WordPress only auto-loads `*.php` placed *directly* in `mu-plugins/`, not in subfolders, so either rely on a mu-plugins autoloader (Bedrock and similar stacks ship one) or use the **Manual** method above to drop the single file straight into `mu-plugins/`.

### One-time: widen the columns

[](#one-time-widen-the-columns)

The plugin *keeps* the columns wide; you still widen them once. On a small site:

```
ALTER TABLE wp_posts MODIFY post_name VARCHAR(1024) NOT NULL DEFAULT '';
ALTER TABLE wp_terms MODIFY slug      VARCHAR(1024) NOT NULL DEFAULT '';
```

> ⚠️ **On MySQL 8 in strict mode this `ALTER` fails**, with `Invalid default value for 'post_date'`. Altering the table makes MySQL re-validate WordPress's legacy zero-date column defaults, which strict mode rejects. Clear the mode for the session (`SET SESSION sql_mode = '';`) or fix those defaults first. **dbDelta-driven widening is unaffected**, so on a strict host you can also just let the next core upgrade widen the column for you, losslessly, since Layer 1 rewrites the desired schema.

> ⚠️ **Widen both columns.** Core ships `wp_terms.slug` at `VARCHAR(200)` too, so widening only `post_name` is the most common mistake, and Layer 2 generates slugs for terms as well. On a narrow column MySQL truncates them (in strict mode the insert fails outright). Until the plugin has read the live width, **Layer 2 clamps its budget to what the narrowest column can actually hold**, so a half-widened site degrades to shorter slugs rather than corrupted ones. Widen both anyway.
>
> Because Layer 1 rewrites dbDelta's *desired* schema, the **next core upgrade will widen a narrow column for you**, losslessly. So treat the manual `ALTER` as "do it now, because Layer 2 is already generating slugs," not "or it never happens."

On a large `wp_posts` (millions of rows) the `200 → 1024` change crosses InnoDB's VARCHAR length-byte boundary and forces a full table rebuild, so use an online schema-change tool instead of a raw `ALTER`:

```
pt-online-schema-change \
  --alter "MODIFY post_name VARCHAR(1024) NOT NULL DEFAULT ''" \
  --execute D=wordpress,t=wp_posts
```

Configuration
-------------

[](#configuration)

Define before the plugin loads (e.g. in `wp-config.php`), or edit the constants at the top of the file:

ConstantDefaultMeaning`ASG_COLUMN_LEN``1024`Physical column width (bytes)`ASG_SLUG_BYTES``1000`Ceiling for a generated slug. The effective budget is `min(ASG_SLUG_BYTES, live_column_width - ASG_SUFFIX_HEADROOM)``ASG_SUFFIX_HEADROOM``24`Bytes kept free for a `-2` collision suffix, so a colliding slug cannot overflow the column`ASG_ALERT_EMAIL`*(unset)*If defined, the tripwire (and the Layer-2 self-test) email this address on a column revert or fork drift`ASG_L2_FAILSAFE`*(unset)*On Layer-2 drift, hand generation back to core. ⚠️ **Reintroduces silent 200-byte truncation.** Read the warning below> ⚠️ **`ASG_L2_FAILSAFE` is not the safe option, despite its name.**On drift it hands slug generation back to core, which caps at 200 bytes on a *character* boundary. The result is a slug that is perfectly valid and merely **wrong**: it serves `200`, never `404`, never `400`, and never reaches a log. It is the one failure class nothing can see, on a site that installed this plugin to prevent exactly it.
>
> Running a **drifted fork** instead yields full-length slugs under stale cleanup rules, which is a cosmetic problem, not a data one. So the default on drift is to **keep the fork and shout**. If you do enable the fail-safe, the plugin records that it engaged, emails once, and `wp asg verify` keeps failing until you re-sync the fork and unset the constant.

### Switching layers off

[](#switching-layers-off)

Each layer is independent and **on by default** (Layer 3 is opt-in). Switch one off with a **constant in `wp-config.php`**:

SwitchDefaultLayer`ASG_LAYER1`onPrevention: hold both columns at `VARCHAR(1024)` and monitor them`ASG_LAYER2`onGeneration: the long-slug `sanitize_title_with_dashes()` fork`ASG_LAYER3`offCollision de-duplication: reserved (handler pending)```
// wp-config.php: keep the wide column, but let core (or another plugin) make slugs
define( 'ASG_LAYER2', false );
```

**Constants, not filters.** There are matching `asg_layer{N}_enabled` filters, but they resolve *while this mu-plugin loads*, so only code that runs earlier (another mu-plugin) can use them. A theme's `functions.php` or a regular plugin is far too late. More decisively: during a database upgrade WordPress loads **neither** plugins nor themes, because `wp_get_active_and_valid_plugins()` and `wp_get_active_and_valid_themes()` both return early on `wp_installing()`. Layer 1 matters precisely during that upgrade, so a filter could never reach it. Use the constant.

**Dependency:** Layers 2 and 3 emit slugs longer than 200 bytes, which only survive because Layer 1 widened the column, so they require Layer 1. Turning Layer 1 off while Layer 2 is requested leaves Layer 2 off (and logs once) rather than truncating. Layer 1 on its own is safe: the column stays wide and core's 200-byte generator stays in place.

Verifying
---------

[](#verifying)

```
wp asg verify
```

It exits **non-zero** on anything that is silently damaging slugs, so cron can simply react to the exit code:

- a **short column**,
- **Layer-1 drift** (core changed its `CREATE TABLE` string, so the rewrite no longer applies),
- **Layer-2 drift** (the fork no longer matches core's generator),
- the **fail-safe engaged** (slugs are capping at 200 bytes and truncating silently).

The last two matter without `--data`, because a fail-safe whose alarm only sounds when you pass an optional flag is not an alarm. `verify` also **evaluates the Layer-2 drift oracle itself** rather than trusting a cached option, since that oracle is otherwise bound to `admin_init` and a headless site (WP-CLI, REST, no `wp-admin` visits) would never run it. That is precisely how a drifted fork can ride along unnoticed for releases at a time.

```
0 3 * * *  cd /var/www/site && wp asg verify >/dev/null 2>&1 \
           || wp asg verify 2>&1 | mail -s "WP slug schema alert on $(hostname)" ops@example.com
```

### Check the data, not just the schema

[](#check-the-data-not-just-the-schema)

The schema check watches the **column**. It cannot tell you whether slugs were *already* truncated, and the obvious detector turns out to be the wrong one. There are three cut classes:

Cut landsExample tailServesNoticed byinside an escape`…%d9%85%d`**400**, nginx rejects italready in your access logon an escape boundary`…%d9%85%d8`**200**only code that *decodes* it (REST/JSON)on a character boundary`…%d8%aa`**200**nothing at allRouting never decodes the slug, it byte-matches `post_name` against the request segment, so the last two resolve normally and quietly serve the **wrong** URL forever. An encoding-only scan finds the class that already screams and misses both silent ones.

So the real detector compares the stored slug against what its title regenerates to:

```
wp asg verify --data
wp asg verify --data --min-length=190   # tune the length gate
```

Full scan, CLI only, never `admin_init`. Every scanned row lands in one bucket:

BucketMeaningFails the command**corrupt**bytes do not decode **and** differ from the regeneration: data is goneyes**truncation candidate**stored slug is a strict prefix of the regenerated oneyes**generator artifact**bytes do not decode but are *identical* to the regeneration: nothing lostno**trashed**`wp_trash_post()` cut it to 191 bytes, one-wayno**clean**matches, or matches plus a genuine `-` uniqueness suffixnoThe **artifact** bucket exists because Arabic writes the percent sign *before* the numeral, so a title opening `%80 …` makes core's "preserve escaped octets" step keep `%80` as an octet. The slug then opens on an orphan continuation byte and fails to decode, yet it is exactly what the generator produces, it serves `200`, and nothing was lost. Only a slug that is *both* undecodable *and* different from its own regeneration has actually lost bytes. (Anything that **decodes** such a slug, REST or `json_encode()`, will still choke on it. It is just not a truncation, and rewriting it repairs nothing.)

Comparison order matters: **compare first, strip last.** Testing a stripped `-` stem up front flags every title that legitimately ends in a year.

Regeneration runs through the **live `sanitize_title` filter chain**, whatever actually minted these slugs: core, this fork, or your theme's own copy. Forcing a full budget lifts *this plugin's* clamp; it never swaps the generator, because diffing two different generators would report their disagreement as truncation.

`--min-length` is a lower bound, not the ceiling. Truncation lands at whatever the column was, so a cut in a `VARCHAR(512)` `wp_terms.slug` sits at 512, not 200. Read the histogram: a pile of slugs at exactly one length *is* the ceiling that cut them.

> **Candidates, not verdicts.** A hand-shortened slug is indistinguishable from a truncated one, and a row whose title changed after publication never matches at all. The count is a **lower bound**.
>
> ⚠️ **The scan refuses to pass from a blind spot.** The prefix test only works if the generator can out-run the stored slug, so the command first **probes** the live generator with an over-long title and measures what it actually emits. Any slug at or above that ceiling can be neither proved nor disproved: the command prints `SCAN IS BLIND` (or `PARTIALLY BLIND`) and exits non-zero instead of "Data OK". Blindness is anchored to the measured ceiling, never to `--min-length`, and never to whether `ASG_L2_FAILSAFE` engaged, because a theme's own fallback or a future core change reaches the same capped state by a different road.
>
> ⚠️ **Repairing candidates moves live URLs.** They serve `200` today. Change slugs through `wp_update_post()`, which records `_wp_old_slug` so `wp_old_slug_redirect()` 301s the old URL. A raw `$wpdb->update()` bypasses that and silently breaks links that work right now.

### Tests

[](#tests)

```
php tests/run.php
```

No WordPress, no database, no dependencies. Three suites: the Layer-2 fork diffed against core's real `sanitize_title_with_dashes()`, the slug classifier, and the `Data OK` / blindness gate. Each one **extracts the functions from the plugin file** rather than copying them, because a copied gate re-encodes the blindness it is supposed to catch and then passes itself.

The fixtures are deliberately Arabic, and deliberately model a short column and a capped generator at the same time. Every bug this plugin has shipped lived in the gap between a Latin fixture and a real corpus, and stayed green until those conditions existed together. Point the suite at an older build to see it: `ASG_PLUGIN=/path/to/old.php php tests/run.php`.

Known limits
------------

[](#known-limits)

Three truncations live in core and are outside this plugin's current reach:

- **Post collisions.** `wp_unique_post_slug()` rebuilds a colliding slug with `_truncate_post_slug( $slug, 200 - … )`. That cap is UTF-8 boundary-safe, so the slug is merely **shortened** to about 200 bytes, never corrupted, and it happens regardless of column width.
- **Term collisions.** `wp_unique_term_slug()` appends `-2` (or an unbounded parent slug) with **no truncation at all**. A slug near the ceiling then overflows the column and MySQL cuts it mid-escape, which is the broken-URL bug. `ASG_SUFFIX_HEADROOM` covers numeric suffixes; the parent-slug case needs a terms-aware Layer 3. That layer is genuinely hard: posts expose a real short-circuit (`pre_wp_unique_post_slug`), terms expose only a post-hoc result filter (`wp_unique_term_slug`), so a handler cannot intercept. Nor can it simply truncate, because boundary-truncating two distinct composed slugs can collide them back together. It must truncate on a UTF-8 boundary **and** re-run uniqueness within budget.
- **Trashing.** `wp_trash_post()` permanently rewrites the slug as `_truncate_post_slug( $post_name, 191 ) . '__trashed'`, and untrashing does not restore it. Trashing a post is a one-way haircut for a long slug.

Important caveats
-----------------

[](#important-caveats)

- The tripwire restores the **column definition**, never bytes already truncated. Treat any revert as an incident: restore from backup and check your 404 logs.
- **Never import a SQL dump taken *before* you widened the columns**: the old `CREATE TABLE` puts you back at 200. A dump of the *current* database is fine.

How it works (and why `VARCHAR(1024)` is safe)
----------------------------------------------

[](#how-it-works-and-why-varchar1024-is-safe)

The full root-cause analysis (the `dbDelta` chain, why the fixed **191-character index prefix** means widening the column carries no index / InnoDB / utf8mb4 risk, and the production rollout for multi-million-row sites) is in the write-up:

**→ [The 200-byte trap: why WordPress core updates break Arabic URLs](https://www.mantek.io/insights/wordpress-arabic-slug-truncation)**

License
-------

[](#license)

[GPL-2.0-or-later](LICENSE): same as WordPress.

---

Built and maintained by **[ManTek Technologies](https://www.mantek.io)**: WordPress + AWS at scale, for Arabic newsrooms and beyond.

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance94

Actively maintained with recent releases

Popularity8

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity37

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

4

Last Release

35d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/216154993?v=4)[ManTek Technologies](/maintainers/mantekio)[@mantekio](https://github.com/mantekio)

---

Top Contributors

[![jaafarabazid](https://avatars.githubusercontent.com/u/11146126?v=4)](https://github.com/jaafarabazid "jaafarabazid (10 commits)")

---

Tags

arabicdbdeltai18nmupluginpermalinkphprtlslugwordpresswordpress-pluginslugwordpressi18npermalinkarabicrtlmuplugindbdelta

### Embed Badge

![Health badge](/badges/mantekio-arabic-slug-schema-guard/health.svg)

```
[![Health](https://phpackages.com/badges/mantekio-arabic-slug-schema-guard/health.svg)](https://phpackages.com/packages/mantekio-arabic-slug-schema-guard)
```

###  Alternatives

[roots/bedrock

WordPress boilerplate with Composer, easier configuration, and an improved folder structure

6.6k471.6k2](/packages/roots-bedrock)[helsingborg-stad/municipio

A bootstrap theme for creating municipality sites.

4028.6k10](/packages/helsingborg-stad-municipio)[wp-media/wp-rocket

Performance optimization plugin for WordPress

7561.4M4](/packages/wp-media-wp-rocket)[qtranslate/qtranslate-xt

qTranslate-XT (eXTended): Adds user-friendly multilingual content support, stored in single post.

60136.7k](/packages/qtranslate-qtranslate-xt)[mediawiki/translate

The only standard solution to translate any kind of text with an avant-garde web interface within MediaWiki, including your documentation and software

438.3k](/packages/mediawiki-translate)[pressbooks/pressbooks-book

This theme is named after Canadian media theorist Marshall McLuhan, who coined the phrase “the medium is the message.” It is designed for academic writing and is also suitable for fiction. Headings are set in Cormorant Garamond, and body type is set in Lora.

226.7k](/packages/pressbooks-pressbooks-book)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
