survos / elastic-bundle
Elasticsearch index lifecycle for Symfony: create/populate/status, and Doctrine change spooling. The query and faceted-UI half lives in survos/search-bundle.
Package info
github.com/survos/elastic-bundle
Type:symfony-bundle
pkg:composer/survos/elastic-bundle
Fund package maintenance!
Requires
- php: ^8.5
- elasticsearch/elasticsearch: ^9.0
- psr/log: ^3.0
- survos/jsonl-bundle: ^2.6
- survos/kit-bundle: ^2.5
- survos/search-bundle: ^2.24
- symfony/console: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/filesystem: ^8.1
- symfony/finder: ^8.1
- symfony/framework-bundle: ^8.1
- symfony/lock: ^8.1
- symfony/routing: ^8.1
Requires (Dev)
- doctrine/orm: ^3.4
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.0
- survos/jsonl-bundle: ^2.6
- survos/tabler-bundle: ^2.24
Suggests
- doctrine/orm: For the postFlush spool listener that records changed entity ids.
- survos/tabler-bundle: For the Elastic dropdown in the admin navbar and the Tabler-styled index/schema pages.
- symfony/messenger: To flush the spool asynchronously instead of via elastic:spool:flush.
Provides
None
Conflicts
None
Replaces
None
README
Elasticsearch index lifecycle for Symfony. The query and faceted-UI half lives in survos/search-bundle; this bundle owns everything that writes.
Moving an app off Meilisearch? See Migrating from Meilisearch: the decision, the app checklist, and the fsn1 production setup.
Why it's separate
survos/search-bundle adapters are read-only, and it must not require an engine client — an
app using only the SQLite FTS5 or Postgres BM25 adapter shouldn't pay for
elasticsearch/elasticsearch, elastic/transport and OpenTelemetry. Index ownership
therefore lives in the engine's own bundle, exactly as survos/meili-bundle already does for
Meilisearch.
This is deliberately not FOSElasticaBundle: that brings its own query layer (Finder,
its own Query objects), which would compete with search-bundle's Query/ResultSet/facets,
and its mappings live in ES-specific YAML instead of deriving from #[Field] metadata.
Commands
bin/console elastic:index:status [code] bin/console elastic:index:create [code] [--drop] [--strict|--no-strict] bin/console elastic:index:populate [code] [--batch-size=250] [--limit=N] bin/console elastic:index:rebuild [code] [--keep-old] [--batch-size=250] bin/console elastic:index:delete [code] [--force]
Mappings are not computed here — they come from the search's resolved adapter parameters in search-bundle, so querying and indexing can't drift apart.
Demo: a faceted, stemmed search in five minutes
Reproduced end to end against survos-sites/bench (Elasticsearch 9.5.0, 300 films). Every
number below is from that run.
# config/packages/survos_search.yaml survos_search: default_adapter: es adapters: es: { dsn: '%env(ELASTICSEARCH_DSN)%' } # config/packages/survos_elastic.yaml survos_elastic: analysis: language: english ascii_folding: true
bin/console elastic:index:create app_movie # bench_movie -> bench_movie_20260816113526 bin/console elastic:index:populate app_movie # indexed 300 documents
Then open /entity/app_movie/search. The page is served by search-bundle's ux-search
components against the Elasticsearch adapter — 300 results, a Year range slider whose 1986–2023
bounds come from a live stats aggregation, and term facets (mcu, superhero, marvel, …)
from terms aggregations.
Stemming is the part worth demonstrating, because it is invisible until you look for it:
| query | results |
|---|---|
?query=village |
2 |
?query=villages |
2 |
?query=war |
13 |
Singular and plural returning the same hits is the english stemmer. Without
survos_elastic.analysis those numbers are 0 and 2 — the default standard analyzer does no
stemming at all. On the same index you can see both behaviours side by side:
curl "$ES/bench_movie/_analyze" -H 'Content-Type: application/json' \ -d '{"analyzer":"standard","text":"Running through Kovács'"'"' villages"}' # -> running, through, kovács, villages curl "$ES/bench_movie/_analyze" -H 'Content-Type: application/json' \ -d '{"analyzer":"survos_text","text":"Running through Kovács'"'"' villages"}' # -> run, through, kovac, villag
Changing the analyzer
index.analysis is a static setting — editing the config does nothing to an index that
already exists. The admin page says so rather than letting you wonder:
Configured for "hungarian" but this index was built with "english".
index.analysisis a static setting, so the configuration has had no effect on it — runelastic:index:rebuild.
bin/console elastic:index:rebuild app_movie
builds a new generation, populates it, swaps the alias atomically, and drops the old one. The old index serves every query until the moment of the swap, and a failure mid-populate leaves the live alias untouched.
Admin pages
/admin/elastic/ lists every registered search with its index, document source and schema
status; /admin/elastic/{code} is the diagnostic page — alias, analyzers, dynamic mapping,
declared-vs-actual drift, field count, deep-paging headroom, and a Field intent table tracing
each #[Field] through the adapter into the live mapping. That last one answers "why isn't my
facet showing up" without guessing which of the three layers dropped it.
Doctrine sync
ElasticSpoolDoctrineListener collects changed ids in postPersist/postUpdate/postRemove
and hands them off in postFlush. It never calls Elasticsearch inline: a database write must
not depend on the search engine being up, and an HTTP request must not wait on it.
Messages carry ids, never documents. The worker loads current state when it runs, so duplicate messages are cheap and correct -- an import that flushes the same entity four times produces one reindex from the final state, not four racing writes.
# config/packages/survos_elastic.yaml survos_elastic: spool_dir: '%kernel.project_dir%/var/elastic-spool' spool_enabled: true async: true # dispatch through Messenger batch_size: 500 # ids per message
Two modes
async: true (default, needs symfony/messenger) dispatches ReindexDocuments /
RemoveDocuments, chunked by batch_size, so one enormous flush becomes several bounded jobs.
async: false, or no bus installed, writes a JSONL spool drained by elastic:spool:flush.
This is the right mode for a bulk import: a line per id costs nothing, and reconciling 400k
ids once at the end beats dispatching 400k messages.
bin/console elastic:spool:flush [FQCN] [--batch-size=500] [--async]
Write path under load
A workflow flushes once per transition, so a busy pipeline produces a stream of small
ReindexDocuments messages. The write path is built for that:
- The handler batches.
ReindexDocumentsHandleris a MessengerBatchHandlerInterface: it collectshandler_batch_sizemessages (default 50), merges their ids per class, and reconciles them with one query and one bulk request. A partial batch goes out afterhandler_idle_timeoutseconds of worker idleness (default 1). - Incremental writes are upserts. Reconciliation sends
update+doc_as_upsert, so Elasticsearch'sdetect_noopskips documents whose indexed fields didn't change, such as a transition that only touches unindexed columns. Mapped fields missing from a document are sent as null, so a merge can't keep a stale value. - No forced refresh on incremental writes or deletes. Each forced refresh creates a segment.
refresh_interval(1s) makes changes searchable.elastic:index:populaterefreshes once at the end. - Rebuilds load with refresh off.
elastic:index:rebuildsetsrefresh_interval: -1on the new generation, restores the default, refreshes once, then swaps the alias. - Bulk requests are sized by bytes as well as count (
ElasticIndexService::MAX_BULK_BYTES, 10 MB), because OCR and AI output make document size vary by orders of magnitude.
survos_elastic: handler_batch_size: 50 handler_idle_timeout: 1
Routing
Unrouted messages are handled synchronously -- which still works, but the flush then waits on Elasticsearch, defeating the point. Route them to get real async:
# config/packages/messenger.yaml framework: messenger: routing: 'Survos\ElasticBundle\Message\ReindexDocuments': async 'Survos\ElasticBundle\Message\RemoveDocuments': async
Not built yet
- The embedding cache. Vectors stay off until it exists — see search-bundle's
docs/elasticsearch.md. - Streaming/resumable populate, conditional indexing, relation traversal in auto-mapping, a raw
request-body hook,
search_afterdeep paging and suggesters — see survos/mono#42 items 3–8.
Settings/analyzer management and schema validation are done — see the demo above and the admin pages.
SearchBench lifecycle validation (September 2026)
SearchBench's es branch now evaluates the full lexical browser UI using InstantSearch and
client-side Twig, with 9,751 movies, 5,076 cars, 1,807 Marvel characters and 17,092 WCMA records.
See the browser contract and the app's
setup and live tests.
elastic:index:populate now creates a missing index with the declared mapping before loading
records. rebuild fills a new generation before swapping the alias; pause writes or replay
changes during that operation. Incremental indexing refuses a missing index rather than letting
Elasticsearch auto-create an incompatible mapping. Bulk item failures are surfaced.
The Doctrine listener selects Elasticsearch-backed entities, captures generated identifiers before removal, and dispatches reconciliation IDs after flush. Reconciliation loads current DB state: present rows are indexed, absent rows deleted. Both old remove messages and new index messages follow this rule so a delayed delete does not blindly remove a replacement record.
Use a dedicated Messenger queue/consumer. Sharing the ORM database connection with the Doctrine transport keeps queue inserts inside an explicit outer transaction until commit. A process crash between a standalone flush commit and dispatch is not covered by a transactional outbox. DQL/SQL bulk writes bypass ORM events and need explicit reindexing. Concurrent consumers do not guarantee version ordering; one indexing consumer per dataset is the evaluated configuration.
With async: false (or no bus), IDs are spooled for elastic:spool:flush. Queue dispatch failures
also fall back to the spool. Schedule drains/monitor queue failures in deployed applications.
A drain claims the current file under a lock, retains failed claims for retry, and leaves new
appends for a subsequent drain. This requires a persistent shared spool volume if multiple
application processes need to drain the same files. JSONL, filesystem, finder and lock are now
explicit runtime dependencies.
Unit tests cover generated-ID capture, cleared units of work, failed queue dispatch, failed spool claims, concurrent appends, and bulk errors. SearchBench additionally tests population, Doctrine CRUD, replacement identifiers and rollback reconciliation against a local ES node.
Admin navbar and local/hosted Elasticsearch
With Tabler and an Elasticsearch-backed search, the Elastic dropdown in
ADMIN_NAVBAR_MENU shows the configured ES endpoints (without credentials),
app indexes, Kibana, Kibana index management, and Dev Tools. In Dev Tools,
GET _tasks?detailed=true shows running Elasticsearch tasks; these are distinct
from application Messenger jobs. Menu rendering never makes cluster requests.
In debug mode, loopback-only ES connections default to http://localhost:5601.
For a hosted cluster, container hostname, SSH tunnel, or Kibana space, configure
the browser-facing URL explicitly; it is independent of the ES DSN:
survos_elastic: kibana_url: '%env(KIBANA_URL)%'
Set KIBANA_URL in the app environment (for example http://localhost:5602
for a tunnel). Set kibana_url: '' to hide Kibana links. Changing this URL does
not switch the search backend: configure the SearchBundle adapter DSN separately.
Use separate per-app index prefixes when sharing a server. A remote authenticated
node also needs correct TLS trust and credentials in the app's connection setup.
SearchBundle alone keeps its engine-neutral Search menu; ES administration belongs
in ElasticBundle, like the Meilisearch tools belong in MeiliBundle.