Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Levenshtein Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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):

  1. JrkLevenshteinBundle.php and DependencyInjection/ — shows the DQL name it registers (almost certainly LEVENSHTEIN) and how it hooks into doctrine.orm.dql.string_functions.
  2. The DQL function class itself — a 20–40 line subclass of Doctrine\ORM\Query\AST\Functions\FunctionNode. That is the entire package's technical value.
  3. 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:

  • Eloquent-native stack (the common case): skip the package. Use PHP's built-in 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:

  1. Confirm your DB platform allows a Levenshtein routine — MySQL UDF (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.
  2. Confirm utf8mb4 collation. Byte-oriented Levenshtein silently miscounts multibyte strings.
  3. Version-lock and test the FunctionNode shape against your actual Doctrine ORM major version — 2.x and 3.x differ.
  4. Add your own unit test for the comparator (see Gotchas).

Implementation Patterns

Pattern 1 — In-process comparison for Eloquent (recommended default)

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.

Pattern 2 — Doctrine DQL custom function (only if you already run 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.

Pattern 3 — Stored-function UDF via whereRaw

If 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.

Pattern 4 — Reusable query scope for the admin UI

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.

Pattern 5 — Batch dedup / import triage

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.

Integration tips

  • Prefer ranking to filtering. A 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.
  • Centralize the threshold. A constant const FUZZY_MAX_DISTANCE = 2 beats magic numbers sprinkled through repositories; thresholds almost always need tuning post-launch.
  • Normalize inputs. mb_strtolower(trim($term)) before comparison — case and whitespace dominate edit-distance results otherwise.
  • Gate behind policy or middleware. Fuzzy search on a large table is an inadvertent DoS vector; keep it on authenticated admin routes.
  • Fake it before you scale it. If the data fits in a few thousand rows, Pattern 1 with LIKE pre-filtering will feel instant and you can defer the DB integration entirely.

Gotchas and Tips

Multibyte and collation — the silent-failure class

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.

It never uses an index

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 versions

The 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.

UDF absence on managed databases

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.

The Symfony 2 wrapper is dead weight

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.

Thresholds are length-sensitive

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:

  • Use a ratio (distance / max(len(a), len(b))) instead of a raw threshold for mixed-length fields.
  • Set different thresholds per column: names need <= 2, addresses can tolerate <= 4, free-text should not use this at all.
  • Add a minimum-length guard (strlen($term) >= 3) — Levenshtein on 1–2 character inputs is noise.

No tests, no CI, no maintainer

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:

  • Write your own fixtures the day you adopt the pattern — a handful of assertions comparing known pairs is enough to catch collation and threshold regressions.
  • Assign an internal owner. This is not a package you can leave unowned.
  • Set a reminder to re-check your Doctrine version each time you upgrade the ORM major.

Extension points worth knowing

If the fuzzy-search requirement grows past admin tooling, the migration targets — in order of increasing effort — are:

  1. Postgres 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.
  2. MySQL full-text (MATCH ... AGAINST): workable for "did you mean" with boolean/wildcard queries, no UDF required.
  3. Meilisearch / Typesense: a weekend of integration for typo tolerance, ranking, and multi-field matching that this function cannot provide.
  4. Elasticsearch / OpenSearch: appropriate when ranking quality and multilingual behavior are product requirements.

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.

When to not bother with any of this

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource