Elasticsearch #
Elasticsearch is a distributed search and analytics engine built on Apache Lucene — designed specifically for fast, relevant, large-scale full-text search. Unlike a regular database that can do LIKE queries, Elasticsearch understands language more deeply: it analyzes text (tokenization, stemming, stop word removal), computes relevance scores, supports fuzzy search for typos, autocomplete, search result highlighting, and real-time analytical aggregations. Elasticsearch isn’t a replacement for your primary database — it’s a search layer that works alongside a relational database or MongoDB, storing a copy of the data optimized for searching. This article covers how to use Elasticsearch from PHP using the official elastic/elasticsearch library.
When to Use Elasticsearch #
flowchart LR
DB[(Primary Database\nMySQL/PostgreSQL/MongoDB)] -- "sync data" --> ES[(Elasticsearch\nSearch Index)]
App[PHP Application] -- "CRUD data" --> DB
App -- "search queries" --> ES
ES -- "relevant results" --> App
style ES fill:#fef9c3
style DB fill:#dcfce7Use Elasticsearch for:
✓ Full-text search with relevance (not just LIKE '%word%')
✓ Autocomplete and suggest-as-you-type
✓ Fuzzy search — tolerant of typos
✓ Faceted search — filters by category, price, rating
✓ Log analytics and monitoring (ELK Stack)
✓ Real-time analytics dashboards
✓ E-commerce product search
Don't replace your primary database with Elasticsearch:
✗ Elasticsearch isn't the source of truth — sync from the primary database
✗ ACID transactions aren't available
✗ Complex relations aren't natively supported
Installation #
# Install the official PHP library
composer require elastic/elasticsearch
# Make sure Elasticsearch is running
curl http://localhost:9200
# {
# "name": "node-1",
# "cluster_name": "elasticsearch",
# "version": { "number": "8.x.x" }
# }
Connecting #
<?php
require 'vendor/autoload.php';
use Elastic\Elasticsearch\ClientBuilder;
use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Exception\ServerResponseException;
// Local connection
$client = ClientBuilder::create()
->setHosts(['http://localhost:9200'])
->build();
// With HTTP Basic authentication
$client = ClientBuilder::create()
->setHosts(['https://localhost:9200'])
->setBasicAuthentication('elastic', 'changeme')
->setSSLVerification(false) // development only
->build();
// Elastic Cloud
$client = ClientBuilder::create()
->setElasticCloudId('my-deployment:dXMtZWFzdC0x...')
->setApiKey('api-key-id', 'api-key-value')
->build();
// Verify the connection
$info = $client->info();
echo "Elasticsearch " . $info['version']['number'] . " connected\n";
Index — Mappings and Settings #
In Elasticsearch, an “index” is equivalent to a “database” or “table” in SQL — the place where documents are stored. Mappings define the type of each field:
<?php
// Create an index with mappings
$params = [
'index' => 'products',
'body' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 1,
'analysis' => [
'analyzer' => [
'english_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'english_stop'],
],
'autocomplete_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'edge_ngram_filter'],
],
'autocomplete_search' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase'],
],
],
'filter' => [
'english_stop' => [
'type' => 'stop',
'stopwords' => ['and', 'or', 'for', 'the', 'in', 'to', 'from', 'this', 'that'],
],
'edge_ngram_filter' => [
'type' => 'edge_ngram',
'min_gram' => 2,
'max_gram' => 20,
],
],
],
],
'mappings' => [
'properties' => [
'id' => ['type' => 'integer'],
'name' => [
'type' => 'text',
'analyzer' => 'english_analyzer',
'fields' => [
'autocomplete' => [
'type' => 'text',
'analyzer' => 'autocomplete_analyzer',
'search_analyzer' => 'autocomplete_search',
],
'keyword' => ['type' => 'keyword'], // for exact match and aggregations
],
],
'description' => ['type' => 'text', 'analyzer' => 'english_analyzer'],
'price' => ['type' => 'double'],
'stock' => ['type' => 'integer'],
'category' => ['type' => 'keyword'], // exact match, aggregations
'tags' => ['type' => 'keyword'], // array of keywords
'rating' => ['type' => 'float'],
'active' => ['type' => 'boolean'],
'created_at' => ['type' => 'date', 'format' => 'yyyy-MM-dd HH:mm:ss||epoch_millis'],
'location' => ['type' => 'geo_point'], // lat/lon coordinates
],
],
],
];
try {
$response = $client->indices()->create($params);
echo "Index 'products' created\n";
} catch (ClientResponseException $e) {
if ($e->getCode() === 400) {
echo "Index already exists\n";
} else {
throw $e;
}
}
// Delete an index (be careful!)
// $client->indices()->delete(['index' => 'products']);
Document CRUD #
<?php
// Index (store) one document
$client->index([
'index' => 'products',
'id' => '1', // optional — Elasticsearch generates one if absent
'body' => [
'id' => 1,
'name' => 'Pro Laptop 14 Gen3',
'description' => 'Premium laptop with the latest processor, great for programmers and designers.',
'price' => 15000000,
'stock' => 5,
'category' => 'electronics',
'tags' => ['laptop', 'computer', 'gaming', 'work'],
'rating' => 4.8,
'active' => true,
'created_at' => date('Y-m-d H:i:s'),
],
]);
// Get a document by ID
$response = $client->get(['index' => 'products', 'id' => '1']);
$document = $response['_source'];
echo $document['name'] . ": Rp " . number_format($document['price']) . "\n";
// Update a document — partial update
$client->update([
'index' => 'products',
'id' => '1',
'body' => [
'doc' => [
'price' => 14500000,
'updated_at' => date('Y-m-d H:i:s'),
],
],
]);
// Delete a document
$client->delete(['index' => 'products', 'id' => '1']);
// Check whether a document exists
$exists = $client->exists(['index' => 'products', 'id' => '999']);
var_dump($exists->asBool()); // bool(false)
Bulk Indexing #
To sync many documents from a database to Elasticsearch, use the bulk API — far faster than indexing one by one:
<?php
function bulkIndex(
\Elastic\Elasticsearch\Client $client,
string $indexName,
array $documents,
int $batchSize = 500,
): void {
$batches = array_chunk($documents, $batchSize);
foreach ($batches as $batchNo => $batch) {
$params = ['body' => []];
foreach ($batch as $doc) {
// Each document needs two entries: the action + the document itself
$params['body'][] = [
'index' => [
'_index' => $indexName,
'_id' => $doc['id'], // use the DB ID as the Elasticsearch ID
],
];
$params['body'][] = $doc;
}
$response = $client->bulk($params);
// Check for per-item errors
if ($response['errors']) {
foreach ($response['items'] as $item) {
if (isset($item['index']['error'])) {
error_log("Bulk error ID {$item['index']['_id']}: " .
json_encode($item['index']['error']));
}
}
}
echo "Batch $batchNo: " . count($batch) . " documents indexed\n";
// Avoid overloading Elasticsearch
usleep(10_000); // 10ms between batches
}
}
// Sync from the database
$stmt = $pdo->query("SELECT id, name, description, price, stock, category FROM products WHERE active = 1");
$products = $stmt->fetchAll();
bulkIndex($client, 'products', $products, batchSize: 1000);
echo "Sync complete: " . count($products) . " products\n";
Full-Text Search #
<?php
// Multi-match — search several fields at once
$response = $client->search([
'index' => 'products',
'body' => [
'query' => [
'multi_match' => [
'query' => 'cheap gaming laptop',
'fields' => ['name^3', 'description', 'tags^2'], // ^ = boost (weight)
'type' => 'best_fields', // or most_fields, cross_fields
'fuzziness' => 'AUTO', // typo tolerant: lapop → laptop
],
],
'highlight' => [
'fields' => [
'name' => ['number_of_fragments' => 0],
'description' => ['fragment_size' => 150, 'number_of_fragments' => 2],
],
],
'from' => 0,
'size' => 10,
'_source' => ['id', 'name', 'price', 'rating', 'category'],
],
]);
$hits = $response['hits'];
echo "Total: " . $hits['total']['value'] . " results\n";
foreach ($hits['hits'] as $hit) {
$doc = $hit['_source'];
$highlight = $hit['highlight'] ?? [];
echo "\n[{$hit['_score']}] {$doc['name']}\n";
echo "Price: Rp " . number_format($doc['price']) . "\n";
// Show highlights — matching words wrapped in <em>
if (isset($highlight['name'])) {
echo "Name: " . implode(' ... ', $highlight['name']) . "\n";
}
if (isset($highlight['description'])) {
echo "Description: " . implode(' ... ', $highlight['description']) . "\n";
}
}
Filters — Searches with Exact Conditions #
<?php
// Bool query — combination of must, should, filter, must_not
$response = $client->search([
'index' => 'products',
'body' => [
'query' => [
'bool' => [
// must — must match, affects the relevance score
'must' => [
['multi_match' => [
'query' => 'laptop',
'fields' => ['name^2', 'description'],
'fuzziness' => 'AUTO',
]],
],
// filter — must match but DOESN'T affect the score (faster, cacheable)
'filter' => [
['term' => ['active' => true]],
['term' => ['category' => 'electronics']],
['range' => ['price' => ['gte' => 5000000, 'lte' => 20000000]]],
['range' => ['stock' => ['gt' => 0]]],
],
// should — optional, but boosts the score if it matches
'should' => [
['range' => ['rating' => ['gte' => 4.0]]],
],
// must_not — must not match
'must_not' => [
['term' => ['tags' => 'refurbished']],
],
],
],
'sort' => [
'_score', // relevance first
['rating' => ['order' => 'desc']], // then rating
['price' => ['order' => 'asc']], // then cheapest price
],
'from' => 0,
'size' => 12,
],
]);
Autocomplete #
<?php
// Autocomplete using the edge n-gram analyzer (already configured in the mapping)
function autocomplete(
\Elastic\Elasticsearch\Client $client,
string $keyword,
int $limit = 5,
): array {
$response = $client->search([
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [
['match' => [
'name.autocomplete' => [
'query' => $keyword,
'operator' => 'and',
],
]],
],
'filter' => [['term' => ['active' => true]]],
],
],
'_source' => ['id', 'name', 'price', 'category'],
'size' => $limit,
],
]);
return array_map(
fn($hit) => $hit['_source'],
$response['hits']['hits']
);
}
// Call while the user types
$suggestions = autocomplete($client, 'lapt'); // 'lapt' → ['Pro Laptop 14', 'Gaming Laptop X', ...]
foreach ($suggestions as $product) {
echo "{$product['name']} — Rp " . number_format($product['price']) . "\n";
}
Aggregations — Faceted Search #
Aggregations let you build sidebar filters (facets) like in an online store:
<?php
$response = $client->search([
'index' => 'products',
'body' => [
'query' => ['match' => ['name' => 'laptop']],
'size' => 12, // search results
// Aggregations — for the sidebar filters
'aggs' => [
'categories' => [
'terms' => ['field' => 'category', 'size' => 20],
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['key' => 'Under 5 Million', 'to' => 5000000],
['key' => '5 - 10 Million', 'from' => 5000000, 'to' => 10000000],
['key' => '10 - 20 Million', 'from' => 10000000, 'to' => 20000000],
['key' => 'Over 20 Million', 'from' => 20000000],
],
],
],
'avg_rating' => [
'avg' => ['field' => 'rating'],
],
'price_histogram' => [
'histogram' => ['field' => 'price', 'interval' => 1000000],
],
],
],
]);
// Get the search results
$products = array_map(fn($h) => $h['_source'], $response['hits']['hits']);
// Get the facets
$facets = $response['aggregations'];
echo "Categories:\n";
foreach ($facets['categories']['buckets'] as $bucket) {
echo " {$bucket['key']}: {$bucket['doc_count']} products\n";
}
echo "\nPrice Ranges:\n";
foreach ($facets['price_ranges']['buckets'] as $bucket) {
echo " {$bucket['key']}: {$bucket['doc_count']} products\n";
}
echo "\nAverage rating: " . round($facets['avg_rating']['value'], 1) . "\n";
Syncing with the Database #
Elasticsearch isn’t the primary data source — it needs to be synced with the database:
<?php
class ProductSearchService
{
public function __construct(
private \Elastic\Elasticsearch\Client $es,
private \PDO $db,
) {}
// Sync one product (call after CREATE/UPDATE in the database)
public function sync(int $productId): void
{
$stmt = $this->db->prepare("
SELECT id, name, description, price, stock, category, rating, active, created_at
FROM products WHERE id = :id
");
$stmt->execute([':id' => $productId]);
$product = $stmt->fetch();
if ($product === false) {
// Product deleted from the DB — delete from ES too
try {
$this->es->delete(['index' => 'products', 'id' => (string) $productId]);
} catch (\Exception $e) {
// Maybe it's not in ES, ignore
}
return;
}
$this->es->index([
'index' => 'products',
'id' => (string) $product['id'],
'body' => $product,
]);
}
public function search(string $keyword, array $filter = [], int $page = 1, int $perPage = 12): array
{
$offset = ($page - 1) * $perPage;
$esFilter = [['term' => ['active' => true]]];
if (!empty($filter['category'])) {
$esFilter[] = ['term' => ['category' => $filter['category']]];
}
if (!empty($filter['min_price'])) {
$esFilter[] = ['range' => ['price' => ['gte' => $filter['min_price']]]];
}
if (!empty($filter['max_price'])) {
$esFilter[] = ['range' => ['price' => ['lte' => $filter['max_price']]]];
}
$response = $this->es->search([
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [['multi_match' => ['query' => $keyword, 'fields' => ['name^3', 'description'], 'fuzziness' => 'AUTO']]],
'filter' => $esFilter,
],
],
'from' => $offset,
'size' => $perPage,
],
]);
return [
'total' => $response['hits']['total']['value'],
'data' => array_map(fn($h) => $h['_source'], $response['hits']['hits']),
'page' => $page,
];
}
}
Summary #
- Elasticsearch isn’t a database replacement — it’s a search layer working alongside the primary database. Data lives in the database and is synced to Elasticsearch for searching.
- Correct mappings are the key to performance —
textfor full-text search with analysis,keywordfor exact match and aggregations,integer/float/doublefor numbers,datefor times.- The boolean query (
must,should,filter,must_not) is the main way to build complex queries.filteris faster thanmustbecause it doesn’t compute scores and its results can be cached.fuzziness: AUTOmakes search typo-tolerant —laptpcan still findlaptop. The relevance score ensures the most accurate results appear first.- The bulk API for mass syncing — 500-1000 documents per batch is a common size. Indexing one by one for large volumes is very slow.
- Aggregations for faceted search —
termsfor category lists with counts,rangefor price filters,avg/min/maxfor statistics.- Edge n-gram analyzers enable autocomplete — the token
laptcan findlaptopbecauselaptis a prefix of the indexed token._scoreis the relevance score — the higher, the more relevant. Boost fields with^(e.g.name^3) makes matches innamethree times more influential than matches in other fields.