keller/soundex-fr-bundle
Symfony bundle implementing the French Soundex phonetic algorithm, letting you index and compare French names and words by sound for fuzzy matching, tolerant search, and deduplication even when spellings differ.
This is a Symfony bundle — there is no Laravel service provider, no config/*.php, and package discovery will not register anything. Treat it as a source of one algorithm, not a framework integration.
composer require keller/soundex-fr-bundle
If Composer complains about platform constraints (likely — bundles from this era often target PHP 5.x/7.x), that is your first signal to vendor the file rather than install the package:
composer require keller/soundex-fr-bundle --ignore-platform-reqs
# then copy the encoder class out, or:
# composer remove keller/soundex-fr-bundle
Where to look first. Open vendor/keller/soundex-fr-bundle/src/ and ignore everything except the encoder:
*Bundle.php, DependencyInjection/, Resources/config/, DependencyInjection/Configuration.php.SoundexFr, FrenchSoundex, or similar. It is probably 50–150 lines of pure string transformation with no framework coupling.First use case — a Tinker sanity check before you plan anything else.
php artisan tinker
>>> $enc = new Keller\SoundexFrBundle\SoundexFr(); // confirm actual FQCN
>>> $enc->soundex('Dupont'); // e.g. "D153"
>>> $enc->soundex('Dupon'); // should match
>>> $enc->soundex('Lefèvre'); // does it handle the accent?
>>> $enc->soundex('Lefevre'); // does it match the above?
>>> $enc->soundex('Œuvre'); // ligature — note what happens
If Dupont/Dupon collide and Lefèvre/Lefevre collide, you have something usable. If accented input returns null, throws, or produces a different key, that is the first thing you wrap before anything else. Run this against five or ten real names from your own database before committing to the two-day integration.
The first shippable slice is usually a "sounds like" filter on a name search or a duplicate-candidate list on a contacts index — not a full dedup/merge system.
Copy the class into app/Support/Phonetics/ and change the namespace. Then:
// app/Providers/PhoneticsServiceProvider.php
namespace App\Providers;
use App\Support\Phonetics\FrenchSoundex;
use Illuminate\Support\ServiceProvider;
class PhoneticsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(FrenchSoundex::class, fn () => new FrenchSoundex());
}
}
Program against an interface so you can swap the encoder later without touching call sites:
interface PhoneticEncoder
{
public function encode(?string $value): ?string;
}
Bind FrenchSoundex::class to PhoneticEncoder::class. The day you decide Postgres pg_trgm or Meilisearch does it better, only the binding changes. Given the package is abandoned, this is your insurance policy.
The vendored code is a pure function and you should leave it alone — put every domain decision in a wrapper:
final class FrenchSoundex implements PhoneticEncoder
{
public function encode(?string $value): ?string
{
if (blank($value)) {
return null;
}
$value = Str::ascii($value); // é→e, ç→c, ñ→n
$value = strtr($value, ['œ' => 'oe', 'æ' => 'ae']);
$value = mb_strtolower($value);
$value = preg_replace('/[^a-z]/', '', $value);
return $value === '' ? null : $this->soundex($value);
}
}
The rule that matters: normalization must be identical at write time and query time. The single most common "why doesn't this match" bug is an encoder that strips accents when persisting and doesn't when searching (or vice versa).
WHERE// app/Models/Concerns/HasFrenchSoundex.php
trait HasFrenchSoundex
{
public static function bootHasFrenchSoundex(): void
{
static::saving(function ($model) {
foreach ($model->soundexAttributes ?? [] as $attr) {
$model->{"{$attr}_soundex"} = app(PhoneticEncoder::class)
->encode($model->{$attr});
}
});
}
}
// contacts migration
$table->string('last_name_soundex', 8)->nullable();
$table->index('last_name_soundex');
Then backfill existing rows with a queued job (chunkById to keep memory flat). Remember: Model::insert() and upsert() bypass Eloquent events — if you have bulk import paths, the trait will not fire and your keys will be silently null. Either route imports through save() or set the column explicitly in the importer.
Soundex produces a key, not a score. Use it as a candidate generator, then rank the candidates with something that actually orders them:
$key = app(PhoneticEncoder::class)->encode($request->input('q'));
$candidates = Contact::query()
->where('last_name_soundex', $key) // index hit — cheap
->limit(200)
->get();
$ranked = $candidates
->sortByDesc(fn ($c) => similar_text(
Str::ascii(Str::lower($c->last_name)),
Str::ascii(Str::lower($request->input('q'))),
))
->take(10);
Without the second stage, every result is "equally correct" and the UI is useless.
Any change to the encoder — a bug fix, a ligature added, upgrading the vendored file — invalidates every stored key, and it fails silently (false negatives, no exception). Cheapest mitigation: name the column last_name_soundex_v1, and ship a reindex job with the change. If you can't version the column, store soundex_version alongside and reindex when it changes.
LIKE in a UNION/scored queryReal search paths usually stack three passes: exact match, prefix/LIKE, then soundex fallback. Weight them so an exact hit always outranks a phonetic hit, otherwise identical-name users get buried under phonetic collisions.
Write PHPUnit tests against a fixed list of real surnames from your own data — not Dupont. This is your only defence against a silent regression when someone "tidies up" the encoder:
it('collapses spelling variants', function () {
$enc = new FrenchSoundex();
expect($enc->encode('Dupont'))->toBe($enc->encode('Dupon'));
expect($enc->encode('Lefèvre'))->toBe($enc->encode('Lefevre'));
});
It will not install cleanly on PHP 8.2. The Symfony-era constraints often pin php: ^5.6 || ^7.0 and an ancient symfony/framework-bundle. That is not a bug in your app — it is the package telling you it was last touched a decade ago. Vendor the class; do not fight --ignore-platform-reqs in production.
Bundle wiring is dead code in Laravel. services.yml, the DI extension, and the Bundle class never execute. The encoder works when instantiated directly, but app()->make(SoundexFr::class) will fail unless you bind it yourself.
ASCII-only string functions. Old implementations typically use strtolower/strtoupper, which do nothing useful on É. Run Str::ascii() before the encoder ever sees the string, or patch the class to use mb_*.
Ligatures are the usual blind spot. œ and æ are not covered by Str::ascii() in every Laravel version. Œuvre and Oeuvre producing different keys is the classic silent failure — handle strtr explicitly.
setlocale dependence. If the class calls setlocale(LC_CTYPE, ...), output varies across servers with different locales installed. Grep for setlocale before you deploy. If it's there, remove it.
Collisions on short French surnames are brutal. A four-character key means Roy, Roux, Rou, and Roe all likely collapse to R000/R200. Treat a soundex match as "candidate worth ranking", never as "same person". This matters most for the KYC/dedup cases where a false positive is expensive.
Never compute soundex inside the query. ->whereRaw("soundex(last_name) = ?") is a full table scan. The whole point of persisting the column is to hit an index. Same applies to SOUNDEX() in SQL — which, incidentally, is not the French variant, so do not mix PHP-computed keys with DB-computed ones. Pick one and apply it everywhere.
Hyphenated and compound names. Dupont-Moreau, Jean-Pierre, de la Fontaine. Decide whether to encode the whole string, the last token, or strip particles, and apply that rule at write and read. Do not strip de/le unless you also strip it from the query input — a one-sided normalizer is worse than none.
Str::ascii() on ß, ø, ł. You will encounter these via German, Nordic, and Polish names in any French-market dataset. Check your fixture set against the transcoder you actually use.
License before you ship. The repo's license file governs whether you can vendor this into a closed-source product with attribution. Check LICENSE in the package root before the copy-paste, not after.
Assume zero upstream support. Six stars, zero dependents: no issue triage, no security response, no fix for whatever Unicode edge case you hit. Pin the vendored file's commit SHA in a comment above the class so the next engineer knows exactly where it came from and whether anyone has diverged from it.
Check whether you need it at all. If you already run Postgres, pg_trgm's similarity() plus a GIN index handles typo-tolerant name matching with infrastructure you already back up and monitor. If you use Meilisearch via Scout, its typo-tolerance setting often covers the same ground. The soundex column is worth building when you specifically need a key to join on across tables, or when you want deterministic, offline, auditable behaviour with no external service in the request path. Otherwise it is a nice-to-have enrichment — do not let it block a release.
Extension points: essentially none. The vendored class is a single pure function. Every useful extension lives in your wrapper — normalization, particle stripping, versioning, the interface. That is exactly the right place for it, and it's why forking is cheap here.
How can I help you explore Laravel packages today?