short-edition/elastica-bundle
A Symfony bundle that integrates the Elastica client for Elasticsearch: define indexes and mappings in configuration, index documents automatically, and run full-text queries through repository services to add fast, scalable search to your PHP application.
Do the ecosystem check before writing any code. short-edition/elastica-bundle is a fork of friendsofsymfony/elastica-bundle (FOSElasticaBundle). It is a Symfony bundle: it registers services through a bundle extension and DI compiler passes, reads YAML/XML config, and hooks Doctrine ORM/ODM event subscribers. None of those integration points exist in Laravel. The package has 0 stars and 0 dependents, so unless it carries a specific patch you need, use upstream friendsofsymfony/elastica-bundle — the API and config below are identical.
There are two realistic starting points:
A. You're in a Symfony app (the package's native home).
composer require short-edition/elastica-bundle
// config/bundles.php
FOS\ElasticaBundle\FOSElasticaBundle::class => ['all' => true],
# config/packages/fos_elastica.yaml
fos_elastica:
clients:
default:
host: '%env(ELASTICSEARCH_HOST)%'
port: '%env(int:ELASTICSEARCH_PORT)%'
indexes:
app:
use_alias: true
types:
post:
persistence:
driver: orm
model: App\Entity\Post
listener: true
properties:
title: { type: text }
slug: { type: keyword }
tags: { type: keyword }
body: { type: text, analyzer: english }
bin/console fos:elastica:create-index app
bin/console fos:elastica:populate --index=app --type=post
B. You're in Laravel. This is not a drop-in. Laravel has no bundle loader, no Doctrine subscriber, no config/packages/*.yaml resolution. Your options, cheapest first:
composer require elasticsearch/elasticsearch (or ruflin/elastica) and bind a client + a small SearchIndex port in a service provider. Roughly a day of work.laravel/scout plus an Elasticsearch driver — Eloquent-native, maintained.ContainerBuilder inside a Laravel provider to host this bundle. This is 1–2 engineer-weeks before you write a single mapping, and you now own the glue forever. Don't.Where to look first (either path, if the bundle is in play): the finder service. In Symfony it's fos_elastica.finder.app.post, typed FOS\ElasticaBundle\Finder\TransformedFinder. That object is ~90% of your day-to-day surface — it takes an Elastica\Query and returns hydrated models. Everything else is configuration.
First use case: a paginated, keyword-filtered listing page. It exercises the whole loop end to end — mapping, populate, query, transform, paginate — with a result you can eyeball against the database.
Inject the finder, not the client. Application code should never touch Elastica\Client directly. The finder applies the transformer (Elasticsearch _source → your model or DTO) and gives you sane return types.
final class PostSearch
{
public function __construct(
private readonly TransformedFinder $finder,
) {}
/** @return Post[] */
public function search(string $term, int $page = 1, int $perPage = 20): array
{
$query = new \Elastica\Query(new \Elastica\Query\BoolQuery());
// build via a dedicated builder, see below
return $this->finder
->findPaginated($query, $perPage, $page - 1) // Pagerfanta
->getCurrentPageResults();
}
}
Keep a query-builder class per index type. Queries grow into 40-line boolean trees faster than you expect; a builder keeps them testable without a cluster.
final class PostQueryBuilder
{
public function build(string $term, array $filters = []): \Elastica\Query
{
$bool = new \Elastica\Query\BoolQuery();
$bool->addMust(new \Elastica\Query\MultiMatch($term, ['title^3', 'body']));
if (!empty($filters['tags'])) {
$bool->addFilter(new \Elastica\Query\Terms('tags', $filters['tags']));
}
$query = new \Elastica\Query($bool);
$query->setHighlight(['fields' => ['title' => new \stdClass()]]);
$query->addAggregation('by_tag', new \Elastica\Aggregation\Terms('by_tag'));
$query->setSize(20);
return $query;
}
}
Write path — pick one and be explicit about it.
Declarative (bundle handles it): listener: true wires Doctrine's postPersist/postUpdate/postDelete to index synchronously. Fast to set up, but indexing runs inside the request and fails the save if the cluster is down.
Explicit (recommended for anything beyond a prototype): set listener: false, emit an application event from your persistence layer, and consume it in a queued listener.
// Symfony Messenger consumer
final class IndexPostOnWrite
{
public function __construct(private readonly IndexManager $index) {}
public function __invoke(PostWritten $event): void
{
$post = $this->posts->find($event->postId);
$this->index->getIndex('app')->getType('post')->addDocument(
new \Elastica\Document($post->getId(), $this->transformer->toArray($post))
);
}
}
Custom transformer when the document shape differs from the entity (very common — you rarely want the full row in the index).
final class PostToDocument implements ElasticaToModelTransformerInterface
{
public function transform(array $elasticaObjects): array { /* map _source → DTOs */ }
public function getIdentifierField(): string { return 'id'; }
}
Register it on the type via persistence: { driver: orm, model: ..., finder: ... } or serializer: { groups: [search] } for the serializer-driven variant.
Deploy-time reindex with an alias swap — the one operational pattern you must get right on day one.
bin/console fos:elastica:reset --index=app --type=post --no-delete # or populate with --no-reset
bin/console fos:elastica:populate --index=app --type=post --batch-size=500 --no-reset
With use_alias: true, populate builds the physical app_<timestamp> index and repoints the app alias atomically at the end. Reads never see a half-built index. Without the alias you will serve partial results during every deploy.
Integration tip — treat search as a port. Whatever you do, define the interface in your app (SearchIndex::index()/delete()/search()) and implement it once against the finder or the raw client. Every piece of the assessment above (fork vs upstream, bundle vs Scout, ES 7 vs 8) then becomes a swap of one class instead of a refactor.
Testing. Unit-test the query builder by asserting on $query->getQuery() — no cluster needed. Integration-test hydration against a real container (docker run elasticsearch:8.x) or an in-memory test double behind the port. Never mock Elastica\Client directly; the fluent API makes it miserable.
Ecosystem trap. Every hour you spend trying to register this bundle in Laravel is an hour you could have spent on relevance. If the app is Laravel, this package is the wrong component — not because it's bad, but because its two differentiators (DI convention and Doctrine listeners) are both inert. The Laravel-shaped equivalent of "get search in a sprint" is Scout plus a driver.
Fork risk is the dominant risk. Zero stars, zero dependents, non-obvious maintainer. If you adopt it anyway: pin to an exact commit in composer.json, vendor or mirror it, diff it against upstream to understand the patch, and assume you're the maintainer. If the delta is cosmetic, take upstream.
Version pins chain you to a cluster version. Elastica's client constraints lag the official client. Check the Elastica major → Elasticsearch major mapping before you commit, because a future ES 7→8 or OpenSearch migration is now coupled to a library upgrade.
Doctrine listeners are synchronous and throwing. listener: true means a down cluster = failed save = failed user request. It also means bulk operations, raw SQL, and queue workers that bypass the ORM silently desync the index. If you use listeners, add a nightly reconciliation job (fos:elastica:populate --no-reset on a diff, or compare updatedAt watermarks) and treat it as non-optional.
Elasticsearch 7+ dropped "type" semantics. The bundle's types: key is a legacy FOSElasticaBundle concept. On ES 7+ it mostly maps to a single document type per index; on ES 8 the whole idea is gone. Expect friction: newer Elasticsearch/Elastica combinations want one index per document type, with aliases doing the routing. Read the bundle's own version notes before designing your index layout, and don't assume the types: nesting still does anything useful.
Mapping changes are not migrations. There is no ALTER. Changing an analyzer, adding a keyword subfield, changing text → keyword all require a new index and a reindex. fos:elastica:populate without --no-reset drops and rebuilds — fine locally, catastrophic against production traffic. This is why aliases are non-negotiable.
Populate will OOM on large tables. --batch-size=500 (or tune to your object graph depth — a Post with lazy-loaded relations pulled into the transformer is the usual culprit). For first-time backfills, run the populate and an alias swap as a separate deploy step, not inside the request-serving deploy.
Container caching bites in dev. After editing fos_elastica.yaml, Symfony may serve stale service definitions. bin/console cache:clear first, always, when mappings "mysteriously don't apply."
Debugging queries. Log the actual ES request: fos_elastica.clients.default.logger: monolog.logger.elastica (or set a PSR-3 logger on the client). Elasticsearch's _explain and ?pretty&error_trace=true on the raw endpoint are more useful than the Elastica exception's message, which often drops the useful part of the response body.
Pagination shape. findPaginated() returns a Pagerfanta\Pagerfanta, so you get page/hasNext/total-count semantics for free — but total count requires a full _count pass and can be slow and inaccurate on clustered indices. If you only need "next page?" set track_total_hits: false and use getNbResults() sparingly.
Extension points if you do commit. The bundle exposes fos_elastica.index_manager, fos_elastica.client_manager, and per-type fos_elastica.finder.* services. You can decorate or replace the finder in a compiler pass without forking — do that rather than editing vendor code if you need custom query defaults, request caching, or a tracing decorator.
How can I help you explore Laravel packages today?