khepin/drupal-transliterator-bundle
Symfony bundle that integrates Drupal's transliteration functionality, converting non-ASCII text into ASCII-friendly strings. Useful for generating slugs, filenames, and search-safe identifiers in PHP applications.
Reality check before you start: this is not a Laravel package. It is a Symfony 2 bundle (KhepinDrupalTransliteratorBundle) that registers a single service wrapping Drupal's transliteration tables, and it only earns its keep when paired with Gedmo/Doctrine Sluggable. In a Laravel app you will not composer require it and get anything useful — Composer resolution against PHP 8 / Symfony 5+ will fail, and even if it installed, the DI wiring and Doctrine listener hook are dead weight.
What you actually want from it is the transliteration algorithm and its character tables, dropped behind your own interface.
First steps:
Transliteration/ (or wherever the pure function lives) and the Resources/config/services.yml. The service definition tells you exactly what the pluggable contract is; the function tells you what you can lift.AsciiSlugger or cocur/slugify already wins and you are done in an hour.App\Support\Transliteration\Transliterator with a single transliterate(string): string method. Every implementation you try — cocur/slugify, symfony/string's AsciiSlugger, or ported Drupal tables — binds to this. Nothing else in your app knows the difference.Your first use case in Laravel: a model saving observer or cviebrock/eloquent-sluggable's custom method option, calling your Transliterator before uniqueness suffixing.
The seam pattern (do this). Bind the interface in a service provider and never call a concrete slugger directly:
$this->app->bind(Transliterator::class, SlugifyTransliterator::class);
Swapping to vendored Drupal tables later is a one-line provider change plus a config flag.
Two-phase slugging. Transliterate first, then uniquify. This ordering matters — the Doctrine listener in the original enforced it, and getting it backwards produces moskva-1 collisions when the transliteration of two distinct titles converges. Structure it as transliterate() → slugify() → unique().
Eloquent integration via custom method. With cviebrock/eloquent-sluggable, override the slug generator method rather than fighting the package's default Str::slug:
protected function generateSlug($string, $separator) {
return app(Transliterator::class)->transliterate($string);
}
The observer alternative is a saving hook that sets the attribute — simpler to read, but you lose the package's uniqueness helpers.
Vendored table pattern. If parity is a hard requirement, port the data files into app/Support/Transliteration/data/ and load lazily by detected script. Drupal ships per-script tables; loading all of them per request is the classic memory mistake. Detect the script from the first non-ASCII codepoint and require only that table.
Golden-file test pattern. Keep the corpus as a fixture file and assert round-trip equality against known Drupal output:
#[DataProvider('drupalSlugs')]
public function test_parity(string $input, string $expected) {
$this->assertSame($expected, $this->transliterator->transliterate($input));
}
Run this in CI on every change to the transliteration path. Treat any diff as a breaking change requiring a redirect plan.
Backfill pattern. Existing records need re-slugging once you switch implementations. Do it as a queued job that writes the old slug into a slug_history table and 301s from it — never overwrite silently.
The license is the first blocker, not the code. Drupal core is GPLv2+, and the transliteration tables are derived from it. The bundle's own license may be permissive but does not launder the provenance of the data. Get a legal read before porting tables into a proprietary codebase — this is a day-one question, cheap to resolve, expensive to ignore.
Silent degradation is the failure mode. Missing or incomplete tables don't throw — characters pass through or get dropped, producing empty slugs (-, post-1) or percent-encoded monsters. You find out when SEO or routing breaks, weeks later. Guard it: assert non-empty output and ASCII-only output in the transliterator itself, and log a warning on degraded results.
CJK coverage is weaker than the name implies. Drupal's core tables are strong on Latin diacritics, Cyrillic, and Greek; the CJK handling is historically patchy and pinyin-style output is not what you get by default. If "we need Chinese slugs" is a driver, benchmark against your corpus before committing — you may need a specialised solution regardless of which package you choose.
Composer constraints will bite immediately. Expect Symfony 2.x and PHP 5.x-era constraints. composer why-not and a dependency audit on any fork is mandatory; realistically you delete the Symfony and Doctrine requires wholesale and keep only the pure function.
Do not port the bundle class or the DI config. The KhepinDrupalTransliteratorBundle class, DependencyInjection/, and services.yml are the 90% you discard. Lift the function and the data, rewrite the wiring as a Laravel ServiceProvider.
The Gedmo ordering is a behaviour spec, not just glue. If you are migrating from a Drupal + Gedmo stack, the listener's ordering (transliterate → check uniqueness → append suffix) is part of the contract. Reproduce it exactly or your parity tests will pass on titles and fail on duplicates.
Numeric and punctuation handling differ per transliterator. Drupal's tables preserve some punctuation and mangle others; cocur/slugify and AsciiSlugger make different choices about &, ', and numbers. Your parity corpus must include those cases explicitly — they are the ones that silently mismatch.
Extension point worth knowing: the interface is one method. That means you can compose implementations — try cocur/slugify first, fall back to ported Drupal tables when the output is empty or contains non-ASCII. This "layered transliterator" pattern gets you maintained-library coverage and migration parity without depending on the abandoned bundle.
Debugging tip: when a slug comes out wrong, dump the raw bytes (bin2hex) of the input. Most "transliteration bugs" are actually encoding bugs — non-UTF-8 input from a legacy database, or a BOM at the start of a field. Fix the encoding before you touch the character map.
Treat this as unmaintained code you now own. Three stars, zero dependents, no release cadence, no CVE feed. That is fine for a pure function over static arrays — it is one of the lowest-churn things you can own — but unacceptable for anything touching request handling or persistence. Keep it behind the seam and it stays swappable.
How can I help you explore Laravel packages today?