jrk/levenshtein-bundle
jrk/levenshtein-bundle offers Levenshtein distance utilities for PHP, useful for comparing strings, fuzzy matching, spelling suggestions, and similarity checks. A lightweight, low-adoption package; review suitability before production use.
The honest starting position: jrk/levenshtein-bundle is a Symfony 2 bundle that registers a Doctrine DQL function, not a Laravel package. There is no service provider, no facade, no extra.laravel block, and nothing that Laravel's package discovery will pick up. When your first task is to wire it in, the correct move is not composer require — it is to read the one class that matters and port it.
Read the repo in this order (30 minutes, tops):
JrkLevenshteinBundle.php and DependencyInjection/ — shows the DQL name it registers (almost certainly LEVENSHTEIN) and how it hooks into doctrine.orm.dql.string_functions.Doctrine\ORM\Query\AST\Functions\FunctionNode. That is the entire package's technical value.Resources/config/ — service wiring for the Symfony 2 kernel; irrelevant to you and safe to discard.If you are on Laravel, your first useful deployment is not a Composer install. Two paths exist, and you should pick deliberately:
levenshtein() or Symfony\Component\String\Levenshtein in a comparison step. The algorithm is the same; you avoid Doctrine entirely.laravel-doctrine stack: copy the FunctionNode subclass into app/Doctrine/DQL/, delete the Symfony bundle wrapper, and register it with EntityManager::getConfiguration()->addCustomStringFunction('LEVENSHTEIN', LevenshteinFunction::class) in a service provider. This is what "using the package" actually means in a Laravel context.A good first use case — the one this is genuinely worth reaching for — is a back-office admin search where you want forgiving "did you mean" behavior on a lookup table of a few thousand rows:
// Repository or Doctrine query
$qb->where("LEVENSHTEIN(c.name, :input) <= 2")
->setParameter('input', $term)
->setMaxResults(25);
That is the canonical, low-risk pattern. Everything else (hot-path search, user-facing endpoints, batch dedup at scale) should be scrutinized heavily before you spend an afternoon on it.
Minimum viable setup checklist:
lib_mysqludf_levenshtein) or a STORED FUNCTION. Managed services (RDS, Cloud SQL, Aurora) usually do not, and you will not find that out until production. Test on staging and a read replica.utf8mb4 collation. Byte-oriented Levenshtein silently miscounts multibyte strings.FunctionNode shape against your actual Doctrine ORM major version — 2.x and 3.x differ.If your data volume fits in memory (tens of thousands of rows, narrow columns), do the distance check in PHP. No DB privilege negotiation, no Doctrine, no port risk.
use Symfony\Component\String\Levenshtein;
$term = strtolower(trim($request->input('q')));
$candidates = Customer::query()
->select('id', 'name')
->where('name', 'like', substr($term, 0, 3) . '%') // cheap pre-filter
->limit(2000)
->get();
$matches = $candidates
->map(fn ($c) => [
'customer' => $c,
'distance' => Levenshtein::distance($term, strtolower($c->name)),
])
->filter(fn ($r) => $r['distance'] <= 2)
->sortBy('distance')
->take(25);
The LIKE pre-filter is the important bit — it cuts the candidate set before you pay O(n·m) per row. This is the pattern most Laravel developers should reach for first.
laravel-doctrine)Register the function once, then use it anywhere in DQL. This is the closest you get to real leverage from the package — a fuzzy predicate that composes with WHERE, ORDER BY, and HAVING.
// app/Providers/DoctrineLevenshteinServiceProvider.php
use App\Doctrine\DQL\LevenshteinFunction;
public function boot()
{
$em = app('em'); // laravel-doctrine
$em->getConfiguration()->addCustomStringFunction(
'LEVENSHTEIN',
LevenshteinFunction::class
);
}
Then in a repository:
$qb->select('c', 'LEVENSHTEIN(c.name, :input) AS distance')
->where('LEVENSHTEIN(c.name, :input) <= :max')
->setParameter('input', $term)
->setParameter('max', 2)
->addOrderBy('distance', 'ASC')
->setMaxResults(50);
The distance alias is the ergonomic win over Pattern 1 — you get ranking from the DB without a second pass.
whereRawIf you cannot (or will not) adopt Doctrine, install a stored function and call it through the query builder. This keeps the Eloquent surface intact at the cost of dropping into raw SQL.
// migration: define the routine
DB::unprepared(<<<SQL
CREATE FUNCTION LEVENSHTEIN(s1 VARCHAR(255), s2 VARCHAR(255))
RETURNS INT DETERMINISTIC
BEGIN /* ... implementation ... */ END
SQL);
// usage
Customer::query()
->whereRaw('LEVENSHTEIN(name, ?) <= ?', [$term, 2])
->orderByRaw('LEVENSHTEIN(name, ?) ASC', [$term])
->limit(25)
->get();
The migration is the point: the routine becomes a versioned, reviewable artifact your team owns. The package gives you no migration — you write it.
Wrap whichever mechanism you chose in a scope so call-sites stay readable and threshold changes are centralized.
// app/Models/Concerns/FuzzySearchable.php
public function scopeLevenshteinNear(Builder $q, string $column, string $term, int $max = 2): Builder
{
return $q->whereRaw(
"LEVENSHTEIN(LOWER($column), ?) <= ?",
[mb_strtolower($term), $max]
);
}
Then Customer::levenshteinNear('name', $input, 2)->limit(20)->get(). The scope is where you swap implementations later (UDF → trigram → search engine) without touching controllers.
This is the pattern where the O(n·m) cost is acceptable because you are not on the request path. Blocking keys are essential — never compare every row to every other row.
// nightly job after CSV ingest
Product::query()
->whereNull('merged_into_id')
->chunkById(500, function ($products) {
foreach ($products as $a) {
Product::where('id', '>', $a->id)
->where('brand_id', $a->brand_id) // blocking key
->whereRaw('LEFT(name, 4) = ?', [substr($a->name, 0, 4)])
->whereRaw('LEVENSHTEIN(name, ?) <= 2', [$a->name])
->each(fn ($b) => report_duplicate($a, $b));
}
});
The two cheap predicates (brand_id, LEFT(name, 4)) do the real work of reducing comparisons; Levenshtein is the tie-breaker.
LEVENSHTEIN(...) <= n predicate is all-or-nothing and hides useful near-misses; selecting the distance as an alias and sorting is more useful in admin UIs and no more expensive.const FUZZY_MAX_DISTANCE = 2 beats magic numbers sprinkled through repositories; thresholds almost always need tuning post-launch.mb_strtolower(trim($term)) before comparison — case and whitespace dominate edit-distance results otherwise.LIKE pre-filtering will feel instant and you can defer the DB integration entirely.Byte-oriented Levenshtein (including lib_mysqludf_levenshtein) counts bytes, not characters. On utf8mb4 with accented or CJK names, 'José' vs 'Jose' returns a distance based on byte lengths, not logical characters. You get plausible wrong answers, not errors — the worst kind of bug, because it passes a demo and fails an audit.
Mitigation: normalize to ASCII where domain-appropriate (iconv('UTF-8', 'ASCII//TRANSLIT', $s)) before the comparison, and add a fixture test with mixed scripts. If you cannot control the collation, use symfony/string's Levenshtein in PHP instead — it is character-aware.
Any WHERE LEVENSHTEIN(col, ?) < n is a full table scan. There is no index you can add that makes a per-row function usable — short of a functional index on an expression (Postgres can do this; MySQL 8.0+ technically can, but you will still pay the function cost on every comparison).
Symptom in production: query latency that scales linearly with table growth and spikes with connection-pool pressure. Nothing in PHP fixes this; the fix is architectural (trigrams, full-text, or an external search index).
Tuning: cap with setMaxResults and add at least one cheap pre-filter (LIKE, REGEXP, LEFT(...), or a blocking key column) before the Levenshtein predicate. Even a bad pre-filter is better than none.
FunctionNode churn between Doctrine versionsThe FunctionNode abstract class and its getSql(Query $query, Parser $parser) signature changed materially through Doctrine 2.x and again into 3.x. A 2013 Symfony 2 bundle targets an early 2.x surface. Dropping it into a modern laravel-doctrine install without editing the class will produce Class ... does not implement getSql(Query $query, Parser $parser) or parser errors at query build time — sometimes only at runtime.
Mitigation: re-implement the FunctionNode against the Doctrine version you actually run; unit-test it against a real parser. It is 30 lines; do not try to fix the old class file in place.
lib_mysqludf_levenshtein requires filesystem access and CREATE FUNCTION privileges. Amazon RDS/Aurora, Cloud SQL, Azure Database, and most managed MySQL offer no way to install a C extension. Stored functions (CREATE PROCEDURE/CREATE FUNCTION) are more portable but often restricted to elevated parameter groups and typically disallowed by DBAs outright.
Symptom: the feature works in your local docker-compose MySQL, then fails at deploy — or worse, works on primary and fails on read replicas because the routine was only installed on one host.
Mitigation: treat "is the function installed" as a startup health check, or eliminate the dependency by using application-layer comparison.
Once you have the FunctionNode class, the Bundle.php, DependencyInjection/, and Resources/config/ are all noise. Attempting to keep the Symfony auto-registration path in a Laravel app leads to broken service container expectations and, if the bundle's Symfony constraints are too tight, install failures that block unrelated upgrades.
Mitigation: copy the single file, add it under your own namespace, and delete the package from composer.json. This is also the correct license posture if the package is MIT — check and attribute deliberately rather than by accident.
distance <= 2 means very different things on 'Al' vs 'Alexandria'. For short strings, even a distance of 1 is a completely different word; for long strings, 2 may be too strict.
Tips:
distance / max(len(a), len(b))) instead of a raw threshold for mixed-length fields.<= 2, addresses can tolerate <= 4, free-text should not use this at all.strlen($term) >= 3) — Levenshtein on 1–2 character inputs is noise.There is no test suite, no CI badge, no security policy, and 2 stars. None of the failure modes above will be caught upstream.
Mitigation:
If the fuzzy-search requirement grows past admin tooling, the migration targets — in order of increasing effort — are:
pg_trgm with a GIN index: WHERE name % :term ORDER BY similarity(name, :term) DESC is indexed and dramatically faster. If you run Postgres, this is the immediate replacement.MATCH ... AGAINST): workable for "did you mean" with boolean/wildcard queries, no UDF required.In every case, the migration is a swap inside the scopeLevenshteinNear (or equivalent) abstraction from Pattern 4 — another reason to keep that abstraction in place from day one.
If the requirement is "forgive typos in a 500-row lookup table on an admin screen," reach for symfony/string's Levenshtein in a controller and move on. The package, the UDF, and the Doctrine function all amount to more infrastructure than the problem needs. The one thing this package teaches you is that the algorithm is trivial — thirty lines — and therefore that the decision is never really about Levenshtein, it is about where you want the work to happen: in PHP, in the database, or in a search engine.
How can I help you explore Laravel packages today?