A PHP library for Typesense with attribute-based document mapping, a fluent search API, and optional Doctrine/Symfony integrations.
composer require enabel/typesenseMap your PHP class to a Typesense collection using attributes:
use Enabel\Typesense\Mapping as Typesense;
use Enabel\Typesense\Type\StringType;
#[Typesense\Document(collection: 'products', defaultSortingField: 'popularity')]
class Product
{
#[Typesense\Id]
public int $id;
#[Typesense\Field(facet: true, infix: true)]
public string $title;
#[Typesense\Field(sort: true)]
public float $price;
#[Typesense\Field]
public bool $inStock;
#[Typesense\Field(type: new StringType(array: true), facet: true)]
public array $tags;
#[Typesense\Field(sort: true, index: false)]
public int $popularity;
#[Typesense\Field]
public \DateTimeImmutable $createdAt;
#[Typesense\Field(optional: true)]
public ?string $description;
}use Enabel\Typesense\Search\{Query, Filter, Sort};
$response = $collection->search(
Query::create('wireless headphones')
->queryBy('title', 'description')
->filterBy(Filter::all(
Filter::equals('inStock', true),
Filter::between('price', 10, 500),
))
->sortBy('price', Sort::Asc)
->facetBy('tags')
->perPage(20)
);
foreach ($response->documents as $product) {
echo "{$product->title} - \${$product->price}\n";
}
echo "Found {$response->found} results across {$response->totalPages} pages\n";composer require enabel/typesense symfony/framework-bundle symfony/consoleRegister the bundle (if not using Symfony Flex):
// config/bundles.php
return [
// ...
Enabel\Typesense\Bundle\EnabelTypesenseBundle::class => ['all' => true],
];# config/packages/enabel_typesense.yaml
enabel_typesense:
client:
url: '%env(TYPESENSE_URL)%' # e.g. http://localhost:8108
api_key: '%env(TYPESENSE_API_KEY)%'
default_denormalizer: ~ # optional: service ID for default denormalizer
default_data_provider: ~ # optional: service ID for default data provider
collections:
App\Entity\Product: ~ # uses defaults
App\Entity\Article:
denormalizer: app.article_denormalizer # override per collection
data_provider: app.article_provider # override per collectionThe bundle automatically registers:
| Service | Description |
|---|---|
Enabel\Typesense\ClientInterface |
Main client |
Enabel\Typesense\Metadata\MetadataRegistryInterface |
Cached metadata registry |
Enabel\Typesense\Document\DocumentNormalizerInterface |
Document normalizer |
Enabel\Typesense\Schema\SchemaBuilderInterface |
Schema builder |
Enabel\Typesense\Doctrine\IndexListener |
Auto-registered if Doctrine is present |
# Create all configured collections
php bin/console enabel:typesense:create
# Create a specific collection
php bin/console enabel:typesense:create --class='App\Entity\Product'
# Drop collections (requires --force)
php bin/console enabel:typesense:drop --force
php bin/console enabel:typesense:drop --class='App\Entity\Product' --force
# Import documents (batched in chunks of 100)
php bin/console enabel:typesense:import
php bin/console enabel:typesense:import --class='App\Entity\Product'
# Search a collection
php bin/console enabel:typesense:search 'App\Entity\Product' \
--query='headphones' \
--query-by='title,description' \
--filter='price:<500' \
--per-page=10use Enabel\Typesense\ClientInterface;
use Enabel\Typesense\Search\{Query, Filter, Sort};
class ProductSearchService
{
public function __construct(
private ClientInterface $client,
) {}
public function search(string $term, int $page = 1): array
{
$response = $this->client->collection(Product::class)->search(
Query::create($term)
->queryBy('title', 'description')
->filterBy(Filter::equals('inStock', true))
->sortBy('popularity', Sort::Desc)
->page($page)
->perPage(20)
);
return $response->documents;
}
}When Doctrine ORM is available, the bundle automatically registers an IndexListener that syncs entities to Typesense on persist, update, and remove. The listener treats the database as the source of truth — if a Typesense sync fails, it logs a warning rather than throwing an exception.
To return fully hydrated Doctrine entities from search results, configure a DoctrineDenormalizer as your denormalizer:
enabel_typesense:
default_denormalizer: Enabel\Typesense\Doctrine\DoctrineDenormalizer
default_data_provider: Enabel\Typesense\Doctrine\DoctrineDataProvider
collections:
App\Entity\Product: ~use Enabel\Typesense\Client;
use Enabel\Typesense\Document\{DocumentNormalizer, ObjectDenormalizer};
use Enabel\Typesense\Metadata\{MetadataReader, MetadataRegistry};
use Enabel\Typesense\Schema\SchemaBuilder;
$typesense = new \Typesense\Client([
'api_key' => 'your-api-key',
'nodes' => [['host' => 'localhost', 'port' => '8108', 'protocol' => 'http']],
]);
$registry = new MetadataRegistry(new MetadataReader());
$normalizer = new DocumentNormalizer($registry);
$denormalizer = new ObjectDenormalizer($registry);
$client = new Client(
typesenseClient: $typesense,
registry: $registry,
normalizer: $normalizer,
schemaBuilder: new SchemaBuilder(),
denormalizers: [
Product::class => $denormalizer,
],
);$client->create(Product::class);
$collection = $client->collection(Product::class);
$collection->upsert($product);
$collection->import([$product1, $product2, $product3]);For projects using Doctrine ORM without Symfony, wire the integrations manually:
Fetches real entities from the database instead of creating plain objects:
use Enabel\Typesense\Doctrine\DoctrineDenormalizer;
$denormalizer = new DoctrineDenormalizer($entityManager, $registry);
$client = new Client(
typesenseClient: $typesense,
registry: $registry,
normalizer: $normalizer,
schemaBuilder: new SchemaBuilder(),
denormalizers: [
Product::class => $denormalizer,
],
);
// search() now returns fully hydrated Doctrine entities
$response = $client->collection(Product::class)->search($query);
$entity = $response->documents[0]; // Doctrine-managed Product entityStreams entities for bulk import with low memory usage:
use Enabel\Typesense\Doctrine\DoctrineDataProvider;
$provider = new DoctrineDataProvider($entityManager);
foreach ($provider->provide(Product::class) as $product) {
// Each entity is detached after yielding
}Automatically syncs Doctrine entities to Typesense on persist, update, and remove:
use Enabel\Typesense\Doctrine\IndexListener;
$listener = new IndexListener(
client: $client,
classNames: [Product::class],
logger: $logger, // optional, logs sync failures as warnings
registry: $registry, // optional, used for ID extraction on preRemove
);
// Register as Doctrine event listener
$entityManager->getEventManager()->addEventListener(
[Events::postPersist, Events::postUpdate, Events::preRemove],
$listener,
);Import the mapping namespace as Typesense for concise, readable attribute declarations:
use Enabel\Typesense\Mapping as Typesense;Applied to a class. Defines the Typesense collection name.
| Parameter | Type | Default | Description |
|---|---|---|---|
collection |
string |
(required) | Typesense collection name |
defaultSortingField |
?string |
null |
Default sort field (must have index: true) |
Applied to exactly one property. Marks it as the document ID.
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
?TypeInterface |
null |
Explicit type (auto-inferred if omitted) |
Applied to properties that should be indexed in Typesense.
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
?TypeInterface |
null |
Explicit type (required for array properties) |
facet |
bool |
false |
Enable faceting |
sort |
bool |
false |
Enable sorting |
index |
bool |
true |
Index for searching/filtering |
store |
bool |
true |
Persist on disk |
infix |
bool |
false |
Enable infix (substring) searching |
optional |
bool |
false |
Allow absent values (auto-set for nullable properties) |
Types are inferred from PHP property types when possible. Use explicit types for arrays or custom conversions.
| PHP Type | Typesense Type | Inferred |
|---|---|---|
string |
string |
Yes |
int |
int64 |
Yes |
float |
float |
Yes |
bool |
bool |
Yes |
DateTimeImmutable / DateTime |
int64 (Unix timestamp) |
Yes |
| Backed enum | string or int32 |
Yes |
array |
(varies) | No, requires explicit type |
use Enabel\Typesense\Type\{StringType, IntType, FloatType, BoolType, DateTimeType, BackedEnumType};
new StringType() // string
new StringType(array: true) // string[]
new IntType() // int64
new IntType(int32: true) // int32
new IntType(array: true) // int64[]
new FloatType() // float
new BoolType() // bool
new DateTimeType() // int64 (Unix timestamp)
new BackedEnumType(Status::class) // string or int32
new BackedEnumType(Status::class, array: true) // string[] or int32[]When building filters with datetime or enum values, use the static cast methods:
use Enabel\Typesense\Type\{DateTimeType, BackedEnumType};
Filter::greaterThan('createdAt', DateTimeType::cast(new \DateTimeImmutable('-7 days')));
Filter::equals('status', BackedEnumType::cast(Status::Active));use Enabel\Typesense\Search\{Query, Sort};
$query = Query::create('search term') // null or '*' for wildcard
->queryBy('title', 'description') // Fields to search in
->filterBy($filter) // Filter or string expression
->sortBy('price', Sort::Asc) // Primary sort (resets previous sorts)
->thenSortBy('rating', Sort::Desc) // Additional sort
->facetBy('category', 'brand') // Facet fields
->maxFacetValues(20) // Max facet values returned
->page(1) // Page number
->perPage(25); // Results per pageuse Enabel\Typesense\Search\Filter;
// Comparison
Filter::equals('status', 'active');
Filter::notEquals('status', 'draft');
Filter::greaterThan('price', 100);
Filter::greaterThanOrEqual('price', 100);
Filter::lessThan('price', 500);
Filter::lessThanOrEqual('price', 500);
Filter::between('price', 100, 500);
// String matching
Filter::matches('title', 'wire*');
// Set operations
Filter::in('category', ['electronics', 'computers']);
Filter::notIn('status', ['archived', 'deleted']);
// Logical operators
Filter::all($filter1, $filter2); // AND
Filter::any($filter1, $filter2); // OR| Property | Type | Description |
|---|---|---|
found |
int |
Total matching documents |
outOf |
int |
Total documents in collection |
page |
int |
Current page |
searchTimeMs |
int |
Search duration in ms |
searchCutoff |
bool |
Whether search was cut off |
hits |
Hit[] |
Search hits |
documents |
object[] |
Denormalized PHP objects (computed) |
totalPages |
int |
Total pages (computed) |
facetCounts |
array<string, FacetCount> |
Facet results keyed by field |
raw |
array |
Full raw Typesense response |
| Property | Type | Description |
|---|---|---|
document |
object |
Denormalized PHP object |
textMatch |
int |
Match score |
raw |
array |
Raw hit data |
$response = $collection->searchGrouped(
Query::create('laptop')->queryBy('title'),
groupBy: 'category', // Field must have facet: true
groupLimit: 5,
);
foreach ($response->groupedHits as $group) {
echo "Group: " . implode(', ', $group->groupKey) . "\n";
foreach ($group->hits as $hit) {
echo " - {$hit->document->title}\n";
}
}Bypass the query builder and response DTOs entirely:
$raw = $collection->searchRaw([
'q' => '*',
'query_by' => 'title',
'per_page' => 10,
]);$collection = $client->collection(Product::class);
// CRUD
$collection->upsert($product);
$collection->find('42'); // Returns Product or null
$collection->delete('42');
// Bulk import (throws ImportException on partial failure)
$collection->import([$p1, $p2, $p3]);
// Collection lifecycle
$client->create(Product::class); // Idempotent
$client->drop(Product::class);MIT