Tntsearch
A fully featured full text search engine written in PHP

#TNTSearch
TNTSearch is a full-text search (FTS) engine written entirely in PHP. A simple configuration lets you add an amazing search experience in minutes. It stores its index in SQLite (default), MySQL, or Redis, and the index can be updated on the fly — no full reindex required.
Features: fuzzy search · search-as-you-type · boolean search · geo-search · BM25/TF-IDF ranking · stemming (many languages) · custom tokenizers · result highlighting · text classification · dynamic index updates.
View online demo | Follow us on Twitter or Facebook | Visit our sponsors:
#🤖 TL;DR for AI agents
What it is: a PHP library that builds an inverted index from your data and answers text queries against it. It returns matching document IDs (and scores), not the documents themselves — you fetch the rows from your own database using those IDs.
Mental model — three steps:
- Index your data once → creates an index file (e.g.
articles.index) in thestoragefolder. - Select that index.
- Search it → get back an array of document IDs ordered by relevance.
Minimal working example (SQLite, the default engine):
use TeamTNT\TNTSearch\TNTSearch; $tnt = new TNTSearch; $tnt->loadConfig([ 'driver' => 'sqlite', // where your SOURCE data lives 'database' => __DIR__ . '/app.sqlite', 'storage' => __DIR__ . '/storage/', // where the INDEX is written (must be writable) 'stemmer' => \TeamTNT\TNTSearch\Stemmer\PorterStemmer::class, // optional ]); // 1. Build the index from a query against your source data (run once). $indexer = $tnt->createIndex('articles.index'); $indexer->query('SELECT id, title, article FROM articles;'); // first column = primary key $indexer->run(); // 2. Select the index. $tnt->selectIndex('articles.index'); // 3. Search it. $res = $tnt->search('romeo and juliet', 20); // $res = ['ids' => [7, 3, 10, ...], 'hits' => 42, 'docScores' => [...], 'execution_time' => '3.1 ms'] // 4. Fetch the actual rows yourself, preserving the order: // SELECT * FROM articles WHERE id IN (7,3,10) ORDER BY FIELD(id, 7,3,10)
The rules that trip agents up (read these):
search()andsearchBoolean()return document IDs, not rows. Fetch rows from your DB and keep the order (ORDER BY FIELD(id, ...)in MySQL).- You must
createIndex()before indexing andselectIndex()before searching. Searching a not-yet-selected index throws. - The first column of the indexer
query()is the primary key (default nameid; change withsetPrimaryKey()). search()uses relevance/OR semantics (a doc matching one term can still rank). UsesearchBoolean()for AND / OR / NOT logic.- The index is a file in
storage; that folder must be writable (it is auto-created since v5.2). The source DB and the index are separate things. - Stemmer and tokenizer are baked into the index at index time — set them when you create the index, not when you search.
Full API cheat-sheet is at the bottom.
#Installation
composer require teamtnt/tntsearch
Requirements: PHP >= 7.4 · PDO · pdo_sqlite (for the default engine) · mbstring.
PDOException: could not find drivermeans thepdo_sqliteextension isn't enabled for the PHP SAPI running your code (CLI and web often differ) — it's an environment issue, not a library one.
#Configuration
loadConfig(array $config) accepts:
| Key | Required | Description |
|---|---|---|
driver |
yes | Source DB driver: sqlite, mysql, pgsql, sqlsrv, or filesystem. |
database |
for sqlite/db | Source database name / path (the data you index). |
host, username, password |
for mysql/pgsql | Source DB connection. |
storage |
yes | Folder where index files are written. Must be writable. |
engine |
no | Index backend: SqliteEngine::class (default), MysqlEngine::class, or RedisEngine::class. |
stemmer |
no | Stemmer class, e.g. PorterStemmer::class. Defaults to NoStemmer. |
tokenizer |
no | Tokenizer class. Defaults to Tokenizer (Unicode words, keeps digits/_/-/@). |
wal |
no | SQLite Write-Ahead Logging. Defaults to true. |
redis_host, redis_port |
for redis | Connection for RedisEngine. |
options |
no | Extra PDO options passed to the connection, e.g. [PDO::MYSQL_ATTR_SSL_CA => '/path/ca.pem'] for managed MySQL (PlanetScale, TiDB Cloud) that require TLS. |
Filesystem indexing also uses location (directory to scan) and extension (e.g. txt).
// Connecting to a managed MySQL provider that requires TLS: $tnt->loadConfig([ 'driver' => 'mysql', 'host' => 'aws.connect.psdb.cloud', 'database' => 'mydb', 'username' => 'user', 'password' => 'pass', 'storage' => __DIR__ . '/storage/', 'options' => [ PDO::MYSQL_ATTR_SSL_CA => '/etc/ssl/cert.pem', ], ]);
Engine choice:
SqliteEngine(default) is best for most cases and needs no server.MysqlEnginekeeps the index in your MySQL DB.RedisEnginekeeps it in Redis. All expose the same API.
#Indexing
#From a database (bulk)
$indexer = $tnt->createIndex('articles.index'); $indexer->query('SELECT id, title, article FROM articles;'); // 1st column = primary key // $indexer->setPrimaryKey('article_id'); // if the PK column isn't named "id" // $indexer->includePrimaryKey(); // make the PK itself searchable (off by default) // $indexer->setLanguage('german'); // pick a stemmer by language $indexer->run();
run() streams the query and writes the index in batched transactions — use it for large datasets.
#Dynamic updates (no reindex needed)
$tnt->selectIndex('articles.index'); $index = $tnt->getIndex(); $index->insert(['id' => 11, 'title' => 'new title', 'article' => 'new body']); $index->update(11, ['id' => 11, 'title' => 'updated', 'article' => 'updated body']); $index->delete(11);
Each insert() is wrapped in a single transaction (fast even for large documents).
#From the filesystem
$tnt->loadConfig([ 'driver' => 'filesystem', 'location' => __DIR__ . '/docs/', 'extension' => 'txt', 'storage' => __DIR__ . '/storage/', ]); $indexer = $tnt->createIndex('docs.index'); $indexer->run();
#Searching
#Relevance search
$tnt->selectIndex('articles.index'); $res = $tnt->search('romeo and juliet', 20); // 2nd arg = max results (default 100)
Returns:
[ 'ids' => [7, 3, 10], // document IDs, best match first 'hits' => 42, // total matching documents 'docScores' => [7 => 5.1, 3 => 4.8],// id => BM25 score 'execution_time' => '3.1 ms', ]
search() sums per-term BM25 contributions, so it is OR-like: a document matching only one term can still appear.
#Boolean search
$tnt->searchBoolean('romeo -juliet'); // has "romeo" but NOT "juliet" $tnt->searchBoolean('romeo or hamlet'); // has "romeo" OR "hamlet" $tnt->searchBoolean('romeo juliet'); // has "romeo" AND "juliet" (space = AND) $tnt->searchBoolean('(romeo juliet) or (prince hamlet)');
Operators: space = AND, or / | = OR, -term or ~term = NOT. Same return shape as search() (docScores is empty).
#Fuzzy search
$tnt->fuzziness(true); $res = $tnt->search('juleit'); // matches "juliet"
Tuning (defaults shown):
| Setter | Default | Meaning |
|---|---|---|
setFuzzyPrefixLength($n) |
2 |
Candidates must share the first N characters. Lower it to match typos near the start (e.g. 1). |
setFuzzyDistance($n) |
2 |
Max Levenshtein distance (measured in characters, multibyte-safe). |
setFuzzyMaxExpansions($n) |
50 |
Max candidate terms considered. |
setFuzzyNoLimit($bool) |
false |
If true, also expand fuzzily even when an exact match exists. |
Common gotcha: with
fuzzy_prefix_length = 2,yonwill not matchyann(2nd character differs). Lower the prefix length for such cases.
#Search-as-you-type (prefix matching)
$tnt->asYouType(true); $res = $tnt->search('jul'); // matches "juliet", "julius", ...
#Highlighting & snippets
$title = 'The tragedy of Romeo and Juliet'; echo $tnt->highlight($title, 'romeo juliet', 'em', ['wholeWord' => false]); // The tragedy of <em>Romeo</em> and <em>Juliet</em> $snippet = $tnt->snippet('romeo juliet', $fullArticleText); // relevant excerpt around the terms
highlight($text, $needle, $tag = 'em', $options = []) options: wholeWord, caseSensitive, simple, stripLinks, tagOptions.
#Stemming & languages
Set a stemmer via config ('stemmer' => GermanStemmer::class) or $indexer->setLanguage('german'). Built-in languages: arabic, croatian, french, german, italian, latvian, polish, porter (English), portuguese, russian, ukrainian, and no (no stemming). Any compatible Snowball stemmer can be plugged in.
#Custom tokenizers
Implement TokenizerInterface (or extend the default Tokenizer) and pass it via config or setTokenizer():
use TeamTNT\TNTSearch\Tokenizer\Tokenizer; use TeamTNT\TNTSearch\Tokenizer\TokenizerInterface; class CommaTokenizer extends Tokenizer implements TokenizerInterface { static protected $pattern = '/[\s,\.]+/'; public function tokenize($text, $stopwords = []) { return preg_split($this->getPattern(), mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY); } } $indexer->setTokenizer(new CommaTokenizer); // or: 'tokenizer' => CommaTokenizer::class in loadConfig()
Included tokenizers: Tokenizer (default), BigramTokenizer, TrigramTokenizer, FourgramTokenizer, FivegramTokenizer, NGramTokenizer, EdgeNgramTokenizer, ProductTokenizer.
#Geo search
// Index (columns: id, longitude, latitude) $geo = new TeamTNT\TNTSearch\Indexer\TNTGeoIndexer; $geo->loadConfig($config); $geo->createIndex('shops.index'); $geo->query('SELECT id, longitude, latitude FROM shops;'); $geo->run(); // Search $search = new TeamTNT\TNTSearch\TNTGeoSearch; $search->loadConfig($config); $search->selectIndex('shops.index'); $nearest = $search->findNearest( ['longitude' => 11.576124, 'latitude' => 48.137154], 2, // radius in km 10 // max results );
#Text classification
use TeamTNT\TNTSearch\Classifier\TNTClassifier; $classifier = new TNTClassifier(); $classifier->learn('A great game', 'Sports'); $classifier->learn('The election was over', 'Not sports'); $guess = $classifier->predict('It was a close election'); echo $guess['label']; // "Not sports" $classifier->save('sports.cls'); $classifier->load('sports.cls');
#Gotchas & constraints
- IDs, not rows. Both search methods return IDs; join back to your data and preserve order (
ORDER BY FIELD(id, ...)). - Order of operations.
createIndex()→run()/insert()to build;selectIndex()→search()to query. search()≠searchBoolean().search()is relevance/OR;searchBoolean()is strict boolean.- Primary key. First column of the index query is the PK. Not searchable unless you call
includePrimaryKey(). - Index config is baked in. The stemmer/tokenizer used at index time is stored in the index; searching uses the stored one.
- SQLite is single-writer. For high-concurrency indexing, use
run()(batched) or the MySQL/Redis engine. storagemust be writable (auto-created since v5.2).
#API cheat-sheet
TNTSearch — configuration & querying:
| Method | Purpose |
|---|---|
loadConfig(array $config) |
Configure and pick the engine. |
createIndex(string $name): TNTIndexer |
Start a new index. |
selectIndex(string $name) |
Choose the index to search. |
search(string $phrase, int $limit = 100): array |
Relevance search → ids, hits, docScores. |
searchBoolean(string $phrase, int $limit = 100): array |
Boolean (AND/OR/NOT) search. |
getIndex(): TNTIndexer |
Get the indexer for the selected index (for insert/update/delete). |
fuzziness(bool), setFuzzy*, asYouType(bool) |
Toggle/tune fuzzy and prefix search. |
highlight(...), snippet(...) |
Highlight terms / extract an excerpt. |
totalDocumentsInCollection(): int |
Number of indexed documents. |
TNTIndexer (from createIndex() / getIndex()) — building & updating:
| Method | Purpose |
|---|---|
query(string $sql) |
Source query; first column is the primary key. |
run() |
Bulk-build the index from the query. |
insert(array $doc) / update(int $id, array $doc) / delete(int $id) |
Dynamic updates. |
setPrimaryKey(string) / includePrimaryKey() |
Primary-key handling. |
setLanguage(string) / setStemmer(...) / setTokenizer(...) |
Index-time text processing. |
setStopWords(array) |
Words to ignore. |
disableOutput(bool) |
Silence progress output. |
#Drivers & integrations
#Tutorials
- Solving the search problem with Laravel and TNTSearch
- Searching for Users with Laravel Scout and TNTSearch
#Demos
#Premium products
If you find TNTSearch useful, take a look at our premium analytics tool:
#PS4Ware / PS5Ware
You're free to use this package, but if it makes it to your production environment, we'd highly appreciate you sending us a PS4/PS5 game of your choice. This way you support us to further develop and add new features.
Our address is: TNT Studio, Sv. Mateja 19, 10010 Zagreb, Croatia. We'll publish all received games here.
#Support us on Open Collective
#Support

#Backers
Support us with a monthly donation and help us continue our activities. [Become a backer]
#Sponsors
Become a sponsor and get your logo on our README on Github with a link to your site. [Become a sponsor]
#Credits
#License
The MIT License (MIT). Please see the License File for more information.
From Croatia with ♥ by TNT Studio (@tntstudiohr, blog)
