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:
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.
composer require teamtnt/tntsearchRequirements: 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.
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.
$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.
$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).
$tnt->loadConfig([
'driver' => 'filesystem',
'location' => __DIR__ . '/docs/',
'extension' => 'txt',
'storage' => __DIR__ . '/storage/',
]);
$indexer = $tnt->createIndex('docs.index');
$indexer->run();$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.
$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).
$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.
$tnt->asYouType(true);
$res = $tnt->search('jul'); // matches "juliet", "julius", ...$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 termshighlight($text, $needle, $tag = 'em', $options = []) options: wholeWord, caseSensitive, simple, stripLinks, tagOptions.
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.
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.
// 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
);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');- 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).
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. |
- Solving the search problem with Laravel and TNTSearch
- Searching for Users with Laravel Scout and TNTSearch
If you find TNTSearch useful, take a look at our premium analytics tool:
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 with a monthly donation and help us continue our activities. [Become a backer]
Become a sponsor and get your logo on our README on Github with a link to your site. [Become a sponsor]
The MIT License (MIT). Please see the License File for more information.
From Croatia with ♥ by TNT Studio (@tntstudiohr, blog)

