Open-source project
loupe-php/loupe avatar
loupe-php/loupe

Loupe: full text search for PHP with nothing but SQLite behind it

A full text search engine with tokenization, stemming, typo tolerance, filters and geo support based on only PHP and SQLite.

530 stars29 forksPHPMIT

At a glance

What is it?
Loupe is a PHP-only full text search engine that keeps its index in SQLite, with typo tolerance, phrase and negative search, faceting, geo filtering and relevance ranking, and no service to run. The README is explicit that anything above 50k documents is probably not a use case for it.
Who is it for?
Adopt Loupe if you run a PHP application and want real search with ranking, filtering and facets without adding a service to operate, since the index lives in the SQLite file you already have. Do not adopt it for a large corpus, because the README says anything above 50k documents is probably not a use case.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 6 days ago.
What is it written in?
Mainly PHP, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The pitch: no containers, no nothing

Loupe describes itself as a full text search engine with tokenization, stemming, typo tolerance, filters and geo support based on only PHP and SQLite, and the README's first bullet puts it plainly: it only requires PHP and SQLite, you do not need anything else, no containers, no nothing.

That framing is aimed at a specific and common situation. A PHP application with a `LIKE %...%` query that is getting slow, and a team that does not want to run Elasticsearch or Meilisearch or Typesense alongside their application server. The README makes the comparison directly, describing Loupe as all-in-all just the easiest way to replace your good old SQL LIKE queries with a way better search experience but without all the hassle of an additional service to manage. SQLite is everywhere and all it needs is your filesystem.

The feature list is what makes the claim plausible. It is typo-tolerant, based on the State Set Index Algorithm and Damerau-Levenshtein. It supports phrase search using quotation marks, and negative keyword and phrase search using - as a modifier. It supports filtering and ordering on any attribute with any SQL-inspired filter statement, and filtering and ordering on geo distance. It supports search facets. It orders relevance based on a number of factors such as number of matching terms, typos, proximity, word counts and exactness. It crops attributes to show the most relevant context around matched terms. It auto-detects languages, supports stemming, and automatically decomposes English and German compound words, so searching for brush finds toothbrush.

That last one is a genuinely unusual feature. Compound decomposition means a user does not need to know whether the data says toothbrush or tooth brush, which is a real search-quality win in German and a partial one in English.

The primary language is PHP, the licence is MIT, the default branch is main, and the release cadence is fast: 1.1.6 on 2026-10-17, 1.1.5 on 2026-10-16 and 1.1.4 on 2026-10-14. The last push was on 2026-10-17.

The name is explained at the end of the README. Loupe means magnifier in French, and the author felt it appropriate after giving his PHP crawler library, Escargot, a French name.

The Meilisearch-shaped API and why that was deliberate

The Acknowledgement section is the most useful thing in the README for understanding the design, because it explains that the API is inspired by MeiliSearch and gives two reasons for that.

The first is that the author thinks MeiliSearch did an amazing job of keeping configuration simple and understandable from a developer's perspective, and that basic search tools should not be complicated.

The second is a migration path. If Loupe should not be enough for your use case anymore, if you need advanced features or better performance, switching to MeiliSearch instead should be a piece of cake. The author even took the liberty of copying some of MeiliSearch's test data to feed Loupe for functional tests, which is why the benchmark fixture is the roughly 32k movies set provided by MeiliSearch.

That is a deliberate hedge against the library's own stated ceiling, and it makes the API choice easy to evaluate. If you have used MeiliSearch, Loupe will feel familiar: the same style of builder configuration, the same shape of response with hits, query, processingTimeMs, hitsPerPage, page, totalPages, totalHits, facetDistribution and facetStats.

The response shape is worth reading closely because it tells you what the engine is actually computing.

php
$searchParameters = SearchParameters::create()
    ->withQuery('Gucleberry')
    ->withAttributesToRetrieve(['uuid', 'firstname'])
    ->withFilter("(departments = 'Backoffice' OR departments = 'Project Management') AND age > 17")
    ->withFacets(['departments', 'age'])
    ->withSort(['lastname:asc'])
;

$results = $loupe->search($searchParameters);

The query in that example is a deliberate typo of Huckleberry, and the filter is a SQL-inspired expression over two filterable attributes, and the sort is by a sortable attribute. One call covers typo tolerance, filtering and ordering.

The results array contains a list of search hits and metadata about the query, and the printed structure shows the facet distribution for departments and the facet stats for age with a min and a max. So faceting returns both counts per value and numeric statistics, which is what a faceted search UI needs.

Configuring the index and creating the client

The client is built from a configuration object and a factory, and the configuration is a fluent builder with named methods.

php
use Loupe\Loupe\Config\TypoTolerance;
use Loupe\Loupe\Configuration;
use Loupe\Loupe\LoupeFactory;
use Loupe\Loupe\SearchParameters;

$configuration = Configuration::create()
    ->withPrimaryKey('uuid') // optional, by default it's 'id'
    ->withSearchableAttributes(['firstname', 'lastname']) // optional, by default it's ['*'] - everything is indexed
    ->withFilterableAttributes(['departments', 'age'])
    ->withSortableAttributes(['lastname'])
    ->withTypoTolerance(TypoTolerance::create()->withFirstCharTypoCountsDouble(false)) // can be further fine-tuned but is enabled by default
;

$loupe = (new LoupeFactory())->create('path/to/my_loupe_data_dir', $configuration);

Every option is optional and the comments give the defaults. The primary key defaults to id, and the searchable attributes default to ['*'], which means everything is indexed unless you narrow it. Filterable and sortable attributes have to be declared explicitly, which is the right design: those two are the ones that require extra index structures, and making them opt-in keeps the write cost down.

The typo tolerance object has its own configuration, and the example disables withFirstCharTypoCountsDouble, which the README notes is enabled by default. That is the option that decides whether a typo in the first character of a term is counted as one typo or two, and turning it off makes matching stricter for single-word queries.

The factory produces either a persistent client or an in-memory one.

php
$loupe = (new LoupeFactory())->createInMemory($configuration);

The in-memory variant is the one to reach for in tests, where you want a fresh index per case and no filesystem cleanup. The persistent variant takes a data directory path, and that directory is the entire deployment footprint.

Documents are then added as plain associative arrays.

php
$loupe->addDocuments([
    [
        'uuid' => 2,
        'firstname' => 'Uta',
        'lastname' => 'Koertig',
        'departments' => [
            'Development',
            'Backoffice',
        ],
        'age' => 29,
    ],
]);

Note that departments is an array, and that the document carries attributes that are not searchable but are filterable and facetable. The engine handles a multi-valued attribute as a set for filtering and faceting purposes, which is what you want for a tag or department field.

Installing it, and the SQLite version that actually matters

Installation is a Composer require and a version check, and the version check is the part people miss.

The first requirement is that pdo_sqlite must be available and your installed SQLite version must be at least 3.35.0. The README explains why: that is when RETURNING functions have been added, without which no bulk inserts are possible. For best performance it is of course better to run a more recent version to benefit from improvements within SQLite.

That is a sharp requirement, and it is a good example of a constraint that comes from the storage layer rather than from the search engine's design. The bulk insert path is built on SQLite's RETURNING clause, which lets an INSERT report the rowid or primary key it assigned, so the engine does not have to select the table to find out what was inserted. Without it, the insert path would have to fall back to a select per row or give up on returning identifiers at all.

bash
composer require loupe/loupe

That is the whole install. There is no service to configure and no index to prepare before you start.

The repository layout is consistent with a library of this size: src/ for the implementation, tests/ for the test suite, docs/ for the documentation, a composer.json, a phpunit.xml.dist, a phpstan.neon for static analysis, a phpbench.json for the benchmarks, a composer-dependency-analyser.php script, and the logo/ directory.

The documentation index in the README lists eight chapters: Schema, Configuration, Indexing, Searching, Browsing, Ranking, Tokenizer and Performance. That list is a fair description of the surface area, and the presence of a Browsing chapter next to Searching is a hint about the scope. Browsing is the ability to enumerate and paginate documents rather than match a query, which most full text search engines do not bother with, and which is useful when you want a filterable listing that can also offer a search box.

The Composer name is loupe/loupe, and the package lives in the loupe-php GitHub organisation with the documentation at loupe-php.github.io/loupe.

The performance envelope, and the benchmark you can run yourself

The README gives ballpark numbers and then tells you the number that matters more.

Querying for Amakin Dkywalker with typo tolerance and relevance ranking takes less than 10 milliseconds, based on indexing the roughly 32k movies fixture provided by MeiliSearch. Indexing varies greatly because it depends on how much content per document you want to index.

Then the sentence that decides whether this library fits your project: anything above 50k documents is probably not a use case for Loupe.

That is a useful, specific limit to have stated, because a search engine's architecture usually has a document count at which it stops working well, and vendors rarely say so. Here the ceiling is on the order of a product catalogue or a modest internal knowledge base, and the shape of the implementation explains why: the index is SQLite, the ranking is computed, and the relevance factors include proximity and word counts, which means scoring is not a simple inverted index lookup.

The README also tells you how to check the claim on your own hardware.

bash
composer bench         # SearchBench + FilterBench (query latency)
composer bench-index   # IndexBench (cold-insert + update; slower)

Two separate benchmarks for two different costs, which is the right split. Query latency is what a user waits for, and index cost is what you pay on write. The second is explicitly flagged as slower, and it measures cold-insert plus update.

The mechanics are documented so that the fixture is reproducible. The benches in tests/Benchmark run against the roughly 32k movies fixture, which is downloaded automatically into var/movies.json on first run. A shared search index is built once into var/bench/ and reused across composer bench runs, so the comparison between runs is not polluted by re-indexing.

There is also a Performance chapter in the docs, and the README invites readers to report their own measurements in a discussion thread. For a library whose headline claim is a latency number under 10 ms, being handed the benchmark scripts is the difference between a claim and something you can verify on your own dataset.

Where Loupe is the wrong choice

There are three limits, and the first is the document count ceiling stated plainly in the README.

The first is the 50k document ceiling. That is not a soft warning; the README says anything above 50k documents is probably not a use case for Loupe. If you are building search over a product catalogue of a hundred thousand items, or over a large document archive, or over anything that will grow past that, this is the wrong engine and no amount of tuning will change the architecture.

The second is the feature ceiling relative to the service it is modelled on. The README says that if you need advanced features or better performance, switching to MeiliSearch should be a piece of cake. That sentence is an admission that those features are not here. What you get is a carefully scoped set: typo tolerance, phrase and negative search, filtering and sorting, facets, geo distance, ranking, context cropping. What you do not get is the rest of a modern search platform, and the list of what is absent is not in the README, so you will have to compare against your requirements directly.

The third is the SQLite dependency in a specific and less obvious form. The engine needs pdo_sqlite and SQLite 3.35.0 or newer, and the bulk insert path depends on RETURNING. On a modern host that is a non-issue. On a managed PHP platform where you do not control the SQLite version, or on a host where the extension is present but the library version is old, the constraint bites at install time rather than at query time, which is the good way to find out but a needless one to hit.

There is a related operational point that the README does not make: writing to a SQLite file means the index is a file you have to back up, replicate and put on fast storage. That is a genuine advantage over a service for a single-server application, and a liability if your search index needs to scale independently of the application, which is exactly the situation where the 50k ceiling would already have bitten you.

The honest alternative depends on the scale and the appetite for operations. If you want search on tens of thousands of documents and you already run PHP, Loupe is a strong fit and it removes a service from your estate. If you are past 50k, MeiliSearch is the comparison the project itself nominates, and the API similarity means the migration is genuinely cheap. And if you have no PHP at all, or you want something you can point many applications at, a dedicated search service is the only option that scales past this ceiling without a rewrite.

What Loupe offers over those is one dependency fewer and a search experience that is meaningfully better than LIKE, which is a lower bar than it sounds. For a content catalogue, a help centre or an internal tool, clearing that bar while keeping the deployment to a file on disk is a good trade.

Editorial conclusion

Adopt Loupe if you run a PHP application and want real search with ranking, filtering and facets without adding a service to operate, since the index lives in the SQLite file you already have. Do not adopt it for a large corpus, because the README says anything above 50k documents is probably not a use case. Before you start, confirm pdo_sqlite is available and your SQLite version is at least 3.35.0, since bulk inserts depend on RETURNING, and note the last push was on 2026-10-17 with release 1.1.6 the same day.

Frequently asked questions

What is Loupe and what does it require to run?

It is a full text search engine that requires only PHP and SQLite, with no containers and no additional service. It provides tokenization, stemming, typo tolerance, phrase and negative search, filtering and sorting on any attribute, geo distance filtering, facets, relevance ranking and attribute context cropping.

How do I install Loupe and what are the requirements?

Run composer require loupe/loupe. You need pdo_sqlite available and an installed SQLite version of at least 3.35.0, which is when RETURNING functions were added, without which bulk inserts are not possible. A more recent SQLite version is better for performance.

How large a dataset can Loupe handle?

The README states that anything above 50k documents is probably not a use case for Loupe. The published figures are ballpark numbers based on indexing the roughly 32k movies fixture provided by MeiliSearch, where a typo-tolerant query with relevance ranking takes under 10 milliseconds.

What search features does Loupe support?

Phrase search using quotation marks, negative keyword and phrase search using - as a modifier, filtering and ordering on any attribute with SQL-inspired filter statements, filtering and ordering on geo distance, search facets, and relevance ordering based on factors such as number of matching terms, typos, proximity, word counts and exactness. It also auto-detects languages and decomposes English and German compound words.

Can I benchmark Loupe myself?

Yes. Run composer bench for the SearchBench and FilterBench query latency benchmarks and composer bench-index for the IndexBench covering cold-insert and update. The benchmarks in tests/Benchmark run against the roughly 32k movies fixture, which is downloaded automatically into var/movies.json on first run, and a shared index is built once into var/bench/ and reused across runs.

Official sources

  1. License: MIT
  2. loupe-php/loupe on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/loupe-php-loupe.svg)](https://hysenlabs.com/projects/loupe-php-loupe)

Was this useful?