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

Simstring Bundle Laravel Package

mapado/simstring-bundle

Symfony bundle integrating SimString for fast approximate string matching and fuzzy search, useful for typo-tolerant lookup, similarity-based queries, and deduplicating strings across datasets.

View on GitHub
Deep Wiki
Context7

Getting Started

Accept the reality first: you are not installing this bundle. It is a Symfony bundle, archived, with 2 stars and 0 dependents. In a Laravel app there is no bundles.php, no container compiling a simstring extension, and no maintainer to patch Symfony/PHP compatibility. Treat the repo as a reference implementation of the SimString algorithm, not a dependency. The valuable part — n-gram feature extraction, inverted indexing, prefix/position filtering, and cosine/Jaccard/Dice similarity — is the underlying C++ library, not the wrapper.

Where to look first. Open the bundle's DependencyInjection/ and Service/ directories in the GitHub repo. You are looking for three things:

  1. The service definition — how a SimStringDatabase is constructed and stored.
  2. The config tree — what tuning knobs exist (measure, threshold, ngram_size).
  3. How the index is built and where it is persisted (per-request? cached? a serialized file?).

That is the entire surface area. If it is a few hundred lines, you will re-implement it in a few hours.

The first use case to try. Do not start with fuzzy matching over millions of rows. Start with a bounded vocabulary you already own: a customers or products table small enough to load into memory, where a user typing "Acme Inc." should match "ACME, Incorporated". Build a tiny matcher, tune the threshold against a labeled sample of 20–50 real duplicates, and measure precision/recall before touching production.

A minimal pure-PHP starting point (zero native build, zero Composer risk):

// app/Support/FuzzyMatcher.php
class FuzzyMatcher
{
    public function __construct(
        private array $corpus,      // ['acme inc', 'acme incorporated', ...]
        private int $ngram = 3,
        private float $threshold = 0.6,
    ) {}

    public function search(string $query): array
    {
        $q = $this->normalize($query);
        $qGrams = $this->ngrams($q);
        $out = [];
        foreach ($this->corpus as $candidate) {
            $c = $this->normalize($candidate);
            $dice = $this->dice($qGrams, $this->ngrams($c));
            if ($dice >= $this->threshold) {
                $out[$candidate] = $dice;
            }
        }
        arsort($out);
        return $out;
    }

    private function normalize(string $s): string
    {
        $s = mb_strtolower(trim($s));
        return preg_replace('/[^\p{L}\p{N}\s]/u', '', $s) ?? '';
    }

    private function ngrams(string $s): array
    {
        $chars = mb_str_split($s);
        $grams = [];
        for ($i = 0; $i <= count($chars) - $this->ngram; $i++) {
            $grams[] = implode('', array_slice($chars, $i, $this->ngram));
        }
        return $grams;
    }

    private function dice(array $a, array $b): float
    {
        $setA = array_unique($a);
        $setB = array_unique($b);
        $inter = count(array_intersect($setA, $setB));
        return $inter === 0 ? 0.0 : (2 * $inter) / (count($setA) + count($setB));
    }
}

Bind it once if you want it as a singleton, but do not pretend it is a "service" just to match the Symfony bundle's shape:

// app/Providers/FuzzySearchServiceProvider.php
$this->app->singleton(FuzzyMatcher::class, fn () => new FuzzyMatcher(
    corpus: Product::pluck('name')->all(),
    ngram: 3,
    threshold: 0.65,
));

If you already run Postgres, stop here and use pg_trgm instead. It is the operationally correct answer in 90% of cases:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX customers_name_trgm ON customers USING gin (name gin_trgm_ops);

SELECT id, name, similarity(name, 'Acme Inc.') AS score
FROM customers
WHERE name % 'Acme Inc.'      -- uses the % threshold operator
ORDER BY score DESC
LIMIT 10;
$results = DB::select(
    "SELECT id, name, similarity(name, ?) AS score
     FROM customers
     WHERE name % ?
     ORDER BY score DESC LIMIT 10",
    [$query, $query]
);

That path has a supported upgrade story, no native artifact, and no per-worker index-build cost. Use the DIY matcher above for offline experiments and small fixed dictionaries; use pg_trgm for production.

Implementation Patterns

Hide the algorithm behind an interface. The single most valuable architectural move is to make the matcher swappable so that "we tried SimString's algorithm, then moved to pg_trgm" is a one-line change instead of a refactor. Note that this is not over-engineering for its own sake — the underlying library is archived, so you should expect to move.

interface Matcher
{
    /**
     * @return array<int, array{candidate: string, score: float}>
     */
    public function search(string $query, ?float $threshold = null): array;
}

Implementations: PhpTrigramMatcher, PostgresTrigramMatcher, SimStringCliMatcher (if you ever shell out to the SimString CLI). Bind one in a service provider:

$this->app->bind(Matcher::class, fn () => new PostgresTrigramMatcher(DB::connection()));

Normalize before you index, not after. Every n-gram approach breaks on the same things: accents, punctuation, suffixes ("Inc.", "Ltd."), and casing. Do this once at write time and store it in a shadow column so queries and candidates meet in the same normalized space:

// customer migration
$table->string('name');                     // canonical, user-facing
$table->string('name_normalized')->index(); // lowercased, accent-stripped, suffix-stripped
// an observer or model mutator keeps them in sync
protected static function booted(): void
{
    static::saving(function (Customer $c) {
        $c->name_normalized = app(NameNormalizer::class)->normalize($c->name);
    });
}

The classic "candidate generation then strict scoring" pipeline. Fuzzy matching is not the final answer — it is how you narrow the field. Use the matcher to produce the top 20 candidates, then apply a stricter scorer (e.g., exact-token match, human review, or a learned model) to decide whether to merge.

class DeduplicationService
{
    public function __construct(private Matcher $matcher) {}

    public function duplicatesFor(Customer $customer): array
    {
        $candidates = $this->matcher->search($customer->name_normalized, 0.55);

        return collect($candidates)
            ->top(20)
            ->filter(fn ($c) => $this->strictScore($customer->name_normalized, $c['candidate']) > 0.85)
            ->values()
            ->all();
    }
}

Batch matching for CSV imports. The pattern that actually shows up in Laravel apps is "upload a CSV of vendor names, match against the vendor table." Do the index build once, then loop rows against it, not one query-per-row:

class CsvVendorMatcher
{
    public function __construct(private Matcher $matcher) {}

    public function import(UploadedFile $file): array
    {
        $rows = array_map('str_getcsv', file($file->getRealPath()));
        $mapped = [];
        foreach ($rows as $row) {
            $name = $row[0];
            $mapped[] = [
                'input'      => $name,
                'matches'    => $this->matcher->search($name, 0.7),
                'needs_review' => empty($this->matcher->search($name, 0.85)),
            ];
        }
        return $mapped;
    }
}

Cache the index, not the result. Loading a dictionary on every request is the cost that kills the naive implementation. In Laravel, cache a serialized representation (JSON, an array cast, or an Eloquent collection dumped with Cache::rememberForever). Invalidate on domain events:

// app/Observers/CustomerObserver.php
public function saved(Customer $customer): void
{
    Cache::forget('fuzzy:corpus:customers');
}

// in the service
private function corpus(): array
{
    return Cache::rememberForever('fuzzy:corpus:customers', fn () =>
        Customer::pluck('name_normalized', 'id')->all()
    );
}

Expose the tuning parameters; do not hard-code them. Threshold and n-gram size are the only two knobs that matter, and they vary by domain. A drug name vocabulary wants different values than an address list. Put them in config/fuzzy.php:

// config/fuzzy.php
return [
    'ngram'     => env('FUZZY_NGRAM', 3),
    'threshold' => env('FUZZY_THRESHOLD', 0.65),
    'measure'   => env('FUZZY_MEASURE', 'dice'), // dice | jaccard | cosine
];

Queue heavy matching. If you are scoring millions of candidates, or running a nightly dedup pass, do it in a job with a chunked cursor. This is the pattern that most resembles "the right way to use SimString" — a batch job that builds an index over the whole corpus and produces a duplicate-report artifact that humans review.

Gotchas and Tips

Do not composer require this bundle. The repo is archived. Symfony bundles from the Symfony 2/3 era do not boot on modern Laravel because there is no bundle kernel to load them, but more importantly: adding an archived package to composer.json is a standing supply-chain audit finding, and you carry PHP-version upgrade risk forever. If you must use SimString, get it via the underlying extension or CLI and wire it yourself.

The extension is the actual dependency, not the bundle. ext-simstring must be compiled and loaded in every environment. That means Docker image changes, CI runner changes, and a fallback path for local dev. Check for it explicitly and fail loud rather than silently degrading:

// AppServiceProvider::boot() — only if you decide to use the native path
if (! extension_loaded('simstring') && app()->environment('production')) {
    throw new \RuntimeException('ext-simstring is not loaded — fuzzy matching is unavailable.');
}

Silent degradation to LIKE '%query%' in production is worse than a hard failure because you will get "the fuzzy search is broken" tickets weeks later.

UTF-8 and accents will bite you. The SimString C++ library operates on bytes; PHP n-gram implementations do too unless you use mb_str_split. Without proper handling, "Müller" and "Muller" become two completely different token sets, and accented names from EU data produce zero results. Normalize aggressively (see the NameNormalizer above) and store the normalized form — do not normalize at query time and hope you match the index.

Threshold semantics differ by measure. A Dice threshold of 0.8 and a Jaccard threshold of 0.8 mean very different precision. Jaccard penalizes size asymmetry more heavily than Dice; cosine behaves differently again on short strings. Do not copy a threshold from one measure's docs to another. Pick one measure for the whole app, tune it against a labeled sample, and document the choice.

In deduplication, false positives are destructive. If you build a "merge these customers automatically" feature on top of this, an aggressive threshold will merge the wrong records, and the merge is hard to undo. For customer/entity resolution, always route candidate merges through human review unless the score is very high (e.g., > 0.95) and the record count is large enough that manual review is infeasible. False negatives are cheap; false positives are expensive.

Inverted-index build cost dominates. The pattern that fails in production is building the index at request time in a PHP-FPM worker — every request rebuilds a dictionary, every worker holds its own copy in memory. If you go native, precompute a serialized index and load it once per worker (a service provider singleton, or an Octane-resident service), or run a standalone daemon. If you go pg_trgm, the GIN index does this for you and it is the main reason to prefer it.

The % operator in pg_trgm uses pg_trgm.similarity_threshold (default 0.3), not your app config. Set it per session or use similarity() in a WHERE clause instead of relying on the operator's default:

DB::statement('SET pg_trgm.similarity_threshold = 0.4');

// or, explicit and portable
DB::select(
    "SELECT id, name FROM customers
     WHERE similarity(name, ?) >= ? AND name % ?
     ORDER BY similarity(name, ?) DESC LIMIT 10",
    [$q, $threshold, $q, $q]
);

The % operator is what makes the GIN index usable; the explicit similarity >= ? filter is what enforces your threshold. Use both.

Index staleness is the silent killer. If the corpus changes (a customer is renamed, a product is added) and the index is cached, you will get matching results that quietly omit the new record. Invalidate on saved/deleted events (the observer above), or use a TTL. In a dedup context, "the new record isn't findable" looks like "we have no duplicate" — which is exactly wrong.

Beware the temptation to reach for SimString when LOWER(name) = LOWER(?) would do. Most "fuzzy matching" requests in a Laravel app are actually solved by normalization, LIKE, and a proper index. Only reach for n-gram similarity when you have a measured problem — a duplicate rate, a failed-lookup rate, or a support queue of "the search didn't find my product." That measurement is the justification for the complexity; without it, you are adding a maintenance burden for a hypothetical.

Extension point if you do fork. The MIT license means forking is legally clean. If you eventually need dictionary-scale matching that pg_trgm cannot handle, fork the pattern, not the bundle: keep the Matcher interface, swap the implementation, benchmark precision and p95 latency on real data before committing. The interface boundary is what makes the vendor choice reversible — that is the one lasting contribution of this package.

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.
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment