ongr/elasticsearch-bundle
The Ongr Elasticsearch Bundle integrates Elasticsearch with Symfony applications, offering configuration-driven document object mapping, repositories, indexing, bulk operations, aggregations, and flexible query building for scalable full-text search and analytics in PHP projects.
Install the bundle and the DSL library it builds on:
composer require ongr/elasticsearch-bundle ongr/elasticsearch-dsl
Register ONGR\ElasticsearchBundle\ONGRElasticsearchBundle in config/bundles.php. Check the bundle's version against your Elasticsearch server and Symfony version first — the bundle is tightly coupled to elasticsearch-php 7.x, so ES 8 is out of scope. As of v6.3.0 the bundle runs on PHP 8.x; on a PHP 8 codebase pin ^6.3 explicitly ("ongr/elasticsearch-bundle": "^6.3") so you don't resolve back to a PHP 7-only tag, and be ready to run composer update --with-all-dependencies because the PHP constraint bump can reshuffle doctrine/annotations and the ES client.
Configure a manager (the manager owns one index and one connection; the config shape changed between majors, so copy the example from the README matching your installed tag):
# config/packages/ongr_elasticsearch.yaml
ongr_elasticsearch:
managers:
default:
index:
hosts: ['%env(ELASTICSEARCH_HOST)%']
index_name: app_products
settings:
number_of_replicas: 0
Define a document — a plain PHP class annotated with the bundle's mapping annotations. This is the equivalent of a Doctrine entity and is what the bundle uses to build the index mapping:
use ONGR\ElasticsearchBundle\Annotation as ES;
/**
* @ES\Document(repositoryClass="App\Repository\ProductRepository")
*/
class Product
{
/** @ES\Id() */
public ?string $id = null;
/** @ES\Property(type="text") */
public string $name = '';
/** @ES\Property(type="keyword") */
public string $sku = '';
}
PHP 8 typed properties are fine — the default hydrator writes to public properties directly — but initialise them (= null, = '', = []) so a field missing from a document doesn't leave a typed property uninitialised.
Create the index from the mapping and confirm it exists:
php bin/console ongr:es:index:create
First use case — index a document, then search it. Inject the manager and a repository, persist() each document, commit() once, then query:
$manager = $this->get('ongr_elasticsearch.manager.default');
$manager->persist(new Product()); // queue into bulk
$manager->commit(); // flush the bulk request
$repo = $manager->getRepository(Product::class);
$search = $repo->createSearch();
$search->addQuery(new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('name', 'widget'));
$results = $repo->findDocuments($search); // hydrated Product objects
That loop (document → index create → persist/commit → repository search) is 90% of day-to-day work with this package.
Manager-per-Index. Treat the manager as the unit of index access. Most apps have one manager for the primary index, and optionally a second for logs / autocomplete / a read alias. Inject the manager (or a thin service wrapping it) rather than the client, so the bundle's hydration and mapping stay authoritative.
Custom repositories for query composition. Put @ES\Document(repositoryClass=...) on every document and centralize query building in the repository. The repository extends DocumentRepository and gives you createSearch() (returns an ongr/elasticsearch-dsl Search object), find(), findBy(), and findDocuments($search). Repositories are the natural seam for pagination, default filters (e.g. published = true), and sort order — keep controllers/services free of DSL syntax.
CRUD flow. Reads go through the repository. Writes go through the manager:
$manager->persist($product); // add to bulk queue
$manager->remove($product); // add delete to bulk queue
$manager->commit(); // one bulk request for everything since last commit
Batch imports (CSV/API sync) follow the same pattern: loop persist() thousands of documents, commit() in chunks (a few hundred to a few thousand per commit) to keep bulk payloads bounded.
Index lifecycle as a deploy step. Mapping only takes effect at index creation. The deploy workflow is: run your import/migration script into a new index name, then flip an alias. For zero-downtime this means configuring the manager to use an alias name as index_name, creating app_products_v2 with the new mapping, backfilling, and atomically repointing the alias. For small apps, dropping and recreating the index in a deploy task is acceptable — but treat ongr:es:index:create / ongr:es:index:drop as deploy-time commands, never per-request.
Testing. Set refresh: true on the manager in the test environment so writes are searchable immediately after commit(); leave it off in prod. Use a dedicated *_test index name and reset it in a setUp/tearDown.
Relations and nested data. Embedded/nested documents (@ES\Embedded, @ES\Object, parent/child) are supported but the annotations are fussy — prefer flattening denormalized fields for search, and only reach for nested when you genuinely query arrays of sub-objects with nested queries. Remember that changing a nested mapping is an index rebuild.
Search DSL integration. The bundle leans on ongr/elasticsearch-dsl for query building. Use the DSL's BoolQuery/FunctionScore/aggregation classes rather than hand-written arrays; you get autocompletion and the objects are serializable into the search request the repository sends. Aggregations come back off the raw result set, so use the raw result when you care about buckets rather than hydrated documents.
Service wiring. The bundle exposes one service per manager (ongr_elasticsearch.manager.<name>), useful for legacy get() code, but modern Symfony code should prefer constructor injection via an alias or a small wrapper service. The manager is stateful because of the bulk queue, so avoid injecting it into long-running workers unless you commit and re-fetch carefully.
PHP 8 ergonomics. Now that v6.3 supports PHP 8, you can write documents with typed/readonly-adjacent public properties, enums, and constructor promotion — but keep the surface the hydrator needs: public properties (or well-formed setters) and constructors the hydrator can call. Don't put hydration-critical defaults inside a promoted constructor and assume the hydrator runs it; test hydration after refactoring a document to promoted properties.
persist() writes nothing until commit(). This trips up nearly everyone once. There is no flush(); commit() sends a single bulk request and clears the queue. If documents are "missing," check for a missing commit() first.commit(), documents are not searchable until the index refreshes (~1s by default). Set refresh: true on the manager for tests and for read-after-write flows, but not in production hot paths — it is expensive.ongr:es:index:create will fail or be skipped if the index already exists; you need ongr:es:index:drop plus re-import, or the alias-swap pattern. Budget for this on every schema change.commit() that "succeeds" can still have rejected documents (mapping conflict, wrong analyzer, etc.). Log/inspect the bulk response in import scripts, or you will discover data loss days later.doctrine/annotations (and doctrine/annotations' own PHP 8 constraint must resolve against your platform). If you were hoping to delete the annotation reader, check the tag's docs/changelog before rewriting documents as #[ES\Property].Typed property ... must not be accessed before initialization, and assigning a wrong scalar type throws a TypeError inside the hydrator. Initialise every property and keep types loose enough for what ES actually returns (e.g. ?string for fields that may be missing).elasticsearch-php 7.x only (no ES 8), pre-attributes annotation config, and the config key layout moved between 4.x and 5.x, so copy the README section for the exact tag you install. Check the changelog for the last release: v6.3.0 is a narrow PHP 8 compatibility bump, not a feature or deprecation release, so nothing else in your integration changes.@ES\Id property maps to _id. If you leave it null, ES generates an ID. For idempotent upserts/imports, set the ID yourself so re-runs overwrite rather than duplicate.\DateTime fields are serialized to the mapping's expected format; mismatches between the annotation's format and the actual PHP value surface as bulk errors. Similarly, embedded objects need constructors consistent with the hydrator — public properties or well-formed setters._score, and highlights live on the raw result set. Don't try to read buckets off hydrated entities.var_dump inside the DSL builder.commit() and discard state; a stale bulk queue will silently batch unrelated writes, and a stale connection will fail after an ES restart.How can I help you explore Laravel packages today?