edgaras/strsim
edgaras/strsim is a Laravel package for measuring and comparing string similarity. It helps power fuzzy search, typo-tolerant matching, duplicate detection, and suggestions with a simple, expressive API for ranking how alike two strings are.
edgaras/strsim is still a plain-PHP algorithm collection, not a Laravel package. No service provider, no facade, no config to publish. Installation is one line:
composer require edgaras/strsim
Check your PHP floor first. v1.2.0 requires PHP 8.2+ and declares ext-mbstring. That's the one genuinely breaking change in this release — if any app in your fleet is on 8.1, composer update will fail or you'll be silently pinned to 1.1.x. Run php -v and composer why-not edgaras/strsim ^1.2 before you plan the bump.
Where to look first: vendor/edgaras/strsim/src/ for the classes, and — new in this release — vendor/edgaras/strsim/phpunit.xml.dist plus the test suite. Previously the class list was the documentation and you had to guess at conventions. Now the tests are the spec: read them to confirm method names, return types, and edge-case behavior (especially how Hamming reports invalid UTF-8 and how Cosine handles key ordering). Still dump the public methods in php artisan tinker before writing anything.
First use case: fuzzy matching on user-supplied strings where an exact WHERE won't do — "did you mean Acme Ltd?" on a search box, or flagging near-duplicate company names during a CSV import.
// Confirm class/method names against src/ and the tests — the shape below is the convention.
$score = (new \StrSim\JaroWinkler())->similarity('Acme Ltd', 'Acme Limited');
// ~0.9+ → treat as the same entity
Wrap the first call in a test rather than a controller. That single assertion (assertGreaterThan(0.9, $score)) locks in both the API and the behavior you're relying on.
Normalize before you compare. The algorithms compare characters; users don't type characters consistently. Build a small normalizer and run everything through it first — lowercase, collapse whitespace, strip punctuation, transliterate accents (Transliterator::transliterate($s, 'Any-Latin; Latin-ASCII')), and optionally expand common abbreviations (ltd → limited). Skipping this is still the biggest source of "the algorithm is broken" complaints, even though the library is now mbstring-safe (see Gotchas).
Hide it behind your own interface. Algorithm class names shouldn't be scattered across ten files:
interface StringMatcher { public function score(string $a, string $b): float; }
final class JaroWinklerMatcher implements StringMatcher
{
public function __construct(private \StrSim\JaroWinkler $algo) {}
public function score(string $a, string $b): float
{
return $this->algo->similarity(
Str::of($a)->lower()->squish()->toString(),
Str::of($b)->lower()->squish()->toString(),
);
}
}
Bind it in a service provider. Swapping to levenshtein() or a phonetic algorithm later becomes a one-line change.
Never compare the full table pairwise. O(n²) over a few thousand rows is still seconds of PHP, even with the much faster inner loop. Use a cheap SQL prefilter to build a candidate set (WHERE normalized_name LIKE 'acme%', or a SOUNDEX/n-gram blocking column), then score only those candidates in PHP. For bulk work, chunk with lazyById() and push into a queued job.
What changed for bulk work: the DP rewrite (split-once + rolling rows) took a 2000-char Levenshtein from 8950 ms / 70 MB to 163 ms / 0.3 MB. That 70 MB figure previously meant a handful of long strings could blow memory_limit mid-import; that failure mode is gone. You can now afford to score longer fields (addresses, product descriptions, full titles) where before you'd have truncated them defensively.
Store the normalized form. Add a name_normalized column (indexed) written in a model observer. Recomputing Str::lower() on every query is wasted work, and it makes the prefilter above trivial.
Pick the algorithm per field, not per app:
max(strlen($a), strlen($b)). Now fast enough to run inline on realistic field lengths.[0, 1], so a threshold comparison is finally safe without a manual clamp.Thresholds belong in config. config/matching.php with per-context values (import.company, search.product) beats magic numbers at call sites, and lets you tune without a deploy.
Multibyte: mostly resolved, still verify. v1.2.0 declares ext-mbstring and the Hamming implementation explicitly rejects invalid UTF-8, so the class of "ą counts as two edits" bugs is largely addressed. Two caveats remain: (1) the release notes don't promise mb-safe handling for every algorithm, only that the extension is now a declared requirement — test the ones you actually use with ą, ž, and an emoji; (2) transliterating to ASCII is still worth doing, because it makes accented and unaccented forms of the same name match each other, which is a data concern the library can't solve for you.
Cosine now compares mismatched keys by position. If you feed it two associative arrays that differ in key order — or where one is missing a key — it will align them positionally and return a plausible-looking number that is quietly wrong. Sort keys (ksort()) and assert identical array_keys() on both vectors, or convert to plain lists before the call.
Raw distances aren't comparable. levenshtein("cat","bat") returns 1 — meaningless without knowing the string length. Always divide by max(len_a, len_b) to get a 0–1 similarity before you compare it to a threshold. (Cosine is the exception now that it clamps.)
Short strings flatter Jaro-Winkler. "Ann" vs "Anna" and "Ed" vs "Ted" both score high because the prefix dominates. Add a minimum-length guard (say 4+) or require the first character to match before trusting the score.
Benchmark against the builtin — the gap narrowed but didn't close. 163 ms for a 2000-char string is a ~55x improvement, and the memory profile is now a non-issue, but native levenshtein() is still C and still wins for plain Levenshtein. Reach for this package for the algorithms PHP doesn't ship (Jaro-Winkler, Damerau-Levenshtein, Cosine) and for correctness on multibyte input, not as a blanket replacement for the builtin.
Pin the version and inspect what you pull. With a declared PHP 8.2 floor, ^1.0 could now resolve you onto a version your runtime can't load — prefer ^1.2 (or ~1.2) and commit composer.lock. The release now ships a CI matrix (8.2–8.5), phpunit.xml.dist, LICENSE, and .gitattributes, which is a real maturity step and means the tag is more likely to be what it claims. Still run composer show edgaras/strsim after updating — the earlier release's future-dated timestamp is a reminder to check rather than assume.
No Laravel glue means no free testing helpers. You get no container bindings, no fakes, no config. But you now get upstream's own suite, which is a usable oracle: write one test with a data provider of known-good pairs and run it on every upgrade — that's your regression suite for the dependency, and it's cheap now that the package has a canonical test setup to crib pairs from.
Debug by logging both inputs and both scores. When a match "fails," the cause is almost always upstream (punctuation, case, a trailing newline from a CSV, unsorted Cosine keys) rather than in the algorithm. Log the raw pre-normalization strings alongside the score, or you'll spend an afternoon tuning thresholds for a data problem.
Extension point is your wrapper, not the package. Don't subclass the algorithm classes. Add caching in your matcher (identical pairs recur constantly during imports — an in-request array cache keyed by sha1($a."\0".$b) is a few lines and often a 10x win), add normalization there, and add logging there. Keep the vendor code untouched so upgrades stay boring.
How can I help you explore Laravel packages today?