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:#dcfce7
Use 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";

<?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 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 performancetext for full-text search with analysis, keyword for exact match and aggregations, integer/float/double for numbers, date for times.
  • The boolean query (must, should, filter, must_not) is the main way to build complex queries. filter is faster than must because it doesn’t compute scores and its results can be cached.
  • fuzziness: AUTO makes search typo-tolerant — laptp can still find laptop. 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 — terms for category lists with counts, range for price filters, avg/min/max for statistics.
  • Edge n-gram analyzers enable autocomplete — the token lapt can find laptop because lapt is a prefix of the indexed token.
  • _score is the relevance score — the higher, the more relevant. Boost fields with ^ (e.g. name^3) makes matches in name three times more influential than matches in other fields.

← Previous: MongoDB   Next: Redis →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact