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

Similar Text Finder Laravel Package

rap2hpoutre/similar-text-finder

A lightweight PHP library for finding similar text. Compare strings and get similarity percentages using algorithms like Levenshtein and similar_text, making it easy to build fuzzy search, deduplication, or spell-check suggestions into your Laravel and PHP projects with a simple API.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and verify. The package is framework-agnostic with no service provider:

composer require rap2hpoutre/similar-text-finder

There is nothing to publish, no config file, no facade, and no extra.laravel auto-discovery entry. If composer require prints "Nothing to install", you already have it — that's expected for a ~50-line library.

Where to look first. Open vendor/rap2hpoutre/similar-text-finder/src/ before writing any code. The entire package is one small class wrapping PHP's similar_text(). Reading it takes two minutes and answers the three questions that actually matter in practice: is find() static or instance, does it return the best string or a scored result, and does it preserve the input array's keys. Everything else in this assessment is a consequence of those answers.

First call. The documented usage is a single call — needle first, candidates second:

use Rap2hpoutre\SimilarTextFinder\SimilarTextFinder;

SimilarTextFinder::find('jhon', ['john', 'jane', 'jack']); // "john"

Confirm the exact signature in vendor/ and then verify behavior in Tinker, since the tie-breaking and empty-array behavior are not documented:

php artisan tinker
>>> SimilarTextFinder::find('laravle', ['laravel', 'symfony', 'lumen'])

First use case worth shipping. The highest-value, lowest-risk integration is a "did you mean…?" hint on a search page that returned zero results. It's read-only, has no schema impact, and the candidate list is naturally small (a config() array of categories, a pluck() of a few hundred names, or the user's own previous queries). That gives you a feel for the fuzzy-match quality on your real data before you commit to it anywhere structural like deduplication.


Implementation Patterns

Wrap it in a service, not a static call

Even though the API is one line, bind it behind your own interface from day one. The package is unmaintained (last release Feb 2019) and relies on similar_text(), which is one of PHP's slower string functions — you will eventually want to swap in Levenshtein, Str::contains prefiltering, or a real search index. A one-method interface makes that a one-file change.

// app/Services/Text/SimilarityFinder.php
namespace App\Services\Text;

interface SimilarityFinder
{
    /** @param  array<int, string>  $candidates */
    public function bestMatch(string $needle, array $candidates): ?string;
}
// app/Services/Text/PackageSimilarityFinder.php
namespace App\Services\Text;

use Rap2hpoutre\SimilarTextFinder\SimilarTextFinder;

class PackageSimilarityFinder implements SimilarityFinder
{
    public function bestMatch(string $needle, array $candidates): ?string
    {
        $candidates = array_values(array_filter($candidates, fn ($c) => filled($c)));
        if ($candidates === []) {
            return null;
        }

        return SimilarTextFinder::find($this->normalize($needle), array_map($this->normalize(...), $candidates));
    }

    private function normalize(string $value): string
    {
        return mb_strtolower(trim($value));
    }
}
// app/Providers/AppServiceProvider.php
$this->app->bind(SimilarityFinder::class, PackageSimilarityFinder::class);

Note the normalize step: similar_text() compares bytes, so "Café" and "cafe" score poorly and "JOHN" vs "john" scores zero. Lowercasing and trimming is the single cheapest accuracy win, and Str::ascii() is the next ("Café" → "Cafe").

Controller integration: suggestion on empty result sets

The pattern that reads best to reviewers is "run the real search; only if it's empty, ask the finder."

public function index(Request $request, SimilarityFinder $finder)
{
    $term = $request->string('q')->trim()->toString();

    $results = Product::query()
        ->where('name', 'like', "%{$term}%")
        ->limit(50)
        ->get();

    $suggestion = $results->isEmpty()
        ? $finder->bestMatch($term, Cache::rememberForever('product.names', fn () =>
            Product::pluck('name')->all()))
        : null;

    return view('search.index', compact('results', 'suggestion', 'term'));
}

Cache the candidate list — the finder itself is fast on a few hundred items but you don't want to pluck() 20k rows on every typo'd request. Note the ordering: the DB does the coarse filtering, the finder does the last mile. It is not a substitute for a WHERE, so never load a whole table into memory and fuzzy-match it.

Threshold-aware wrapper when you need a score

If you need to reject weak matches (otherwise every input gets a "did you mean", including gibberish), the package may only hand back the winner without the confidence. Compute the percentage yourself with the same primitive the package uses:

private function score(string $needle, string $candidate): float
{
    similar_text($needle, $candidate, $percent);
    return $percent; // 0..100
}

Then gate in your service (return $percent >= 60 ? $candidate : null;). Tune that number against your real data — for short strings like SKUs and names, anything below ~55% produces nonsense suggestions.

Data cleanup: queued chunked dedupe

For "find near-duplicate rows" (import cleanup, tag normalisation), never do it inline in a request. Chunk and compare within the chunk, which keeps it O(n·k) instead of O(n²):

Product::query()->chunkById(500, function ($products) use ($finder) {
    $names = $products->pluck('name', 'id')->all();

    foreach ($names as $id => $name) {
        $others = collect($names)->except($id)->values()->all();
        $match  = $finder->bestMatch($name, $others);

        if ($match !== null && $match !== $name) {
            // log a candidate pair for human review — do not auto-merge
            DuplicateCandidate::firstOrCreate(['left' => $name, 'right' => $match]);
        }
    }
});

Treat output as candidates for review, not decisions. Fuzzy matching on real user data produces false positives at a rate that makes automatic merging dangerous.

Blade/UI hook

Render the suggestion as a clickable link back into the same search, and keep the original term visible so users see what was corrected:

@if ($suggestion)
    <p class="text-sm">
        No results for <strong>{{ $term }}</strong>.
        Did you mean <a href="{{ route('search.index', ['q' => $suggestion]) }}">{{ $suggestion }}</a>?
    </p>
@endif

Preserve your IDs

The finder works on strings, so if you need to act on a record you must map back. Keep a parallel array and reverse-look it up, or key your candidates by ID and translate after the match:

$byLabel = Product::pluck('id', 'name'); // label => id
$match   = $finder->bestMatch($term, $byLabel->keys()->all());
$id      = $match ? $byLabel[$match] : null;

This is fragile if two rows share a name — dedupe labels before passing them in (->unique()), or accept the first ID.


Gotchas and Tips

Case and accents silently wreck accuracy. similar_text() is not multibyte-aware and is case-sensitive. "JOHN" vs "john" returns 0%. Always mb_strtolower(trim(...)) and consider Str::ascii() on both sides before comparing. For non-Latin scripts (CJK, Arabic), byte-level comparison is close to useless — don't expect this package to work there.

It's not a search engine, and it's not fast at scale. Internally it's similar_text(), which is roughly O(n³) on string length. A few hundred short candidates per request is fine; a few thousand long candidates will time out. Always prefilter with SQL/LIKE/Str::contains first, and always cache or precompute the candidate list. If you find yourself chunking and usleep-ing, you've outgrown the package — reach for Meilisearch, Postgres pg_trgm, or a Levenshtein-based implementation.

Ties are resolved by array order. When two candidates score identically, whichever comes first wins. That makes results depend on your pluck()/orderBy — and it makes them non-obvious to debug. If ordering matters to users, sort() your candidates first so behavior is deterministic and testable.

Empty arrays and empty needles are the classic 500. Guard both. An empty candidate array or a null/"" needle can produce a notice or an exception depending on PHP version. The service wrapper above handles both with an ?string return, which also makes the "no suggestion" path explicit in your controller.

No threshold, no config, no extension points. There's nothing to publish and nothing to tune. If you need a minimum confidence, you must compute similar_text() yourself (see above) rather than expect a config flag. This is the main reason to keep the package behind your own interface.

Unmaintained since 2019 — pin it and isolate it. It's MIT and dependency-free, so risk is low, but it will never get PHP-version fixes. Put it in composer.json with a caret constraint, keep the single import inside one class, and don't let SimilarTextFinder leak into controllers, models, or Blade. A grep -r SimilarTextFinder app/ returning one file is the goal.

Debug by dumping scores, not just the winner. Because the package returns only the best match, a "wrong" answer tells you nothing. Temporarily loop with similar_text($a, $b, $pct) and dump every candidate's percentage — you'll almost always find the real problem is a whitespace, case, or accent mismatch you didn't normalize, not a package bug.

Test it like any pure function. It's deterministic and has no I/O, which makes it ideal for a feature test asserting the did-you-mean link. Assert on a fixed candidate array rather than live DB data so the test doesn't break when someone adds a product name that outscores your fixture.

Pair it with Str::startsWith before the fuzzy call. An exact prefix match ("larav" → "Laravel") is what users usually want, and it's O(1) to check. Running fuzzy matching only when prefix matching fails gives better suggestions with less CPU, and keeps the package doing what it's genuinely good at: short lists, short strings, last-resort fallback.

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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource