AboutBlogContact
BackendMay 24, 2016 5 min read 27

How to Add Full-Text Search to Your App with Elasticsearch 2.x (2016)

AunimedaAunimeda
📋 Table of Contents

How to Add Full-Text Search to Your App with Elasticsearch 2.x (2016)

Short answer: Install Elasticsearch 2.x, define an index mapping with analyzed fields for searchable text and not_analyzed for filters, index your data with the bulk API, search with a multi_match query. The critical decision is mapping: once set on an index, field types can't change — you must reindex.


Install Elasticsearch 2.x

# Ubuntu 14.04/16.04
wget -qO - https://packages.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
echo "deb http://packages.elastic.co/elasticsearch/2.x/debian stable main" | sudo tee /etc/apt/sources.list.d/elasticsearch-2.x.list
sudo apt-get update && sudo apt-get install elasticsearch

sudo service elasticsearch start
curl http://localhost:9200  # Check: {"name":"...", "version": {"number":"2.4.x"}}

Index Mapping (the most important part)

// PUT /products (create index with mapping)
// In ES 2.x, field types set on index creation cannot be changed
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "analysis": {
      "analyzer": {
        "russian": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      },
      "filter": {
        "russian_stop":    { "type": "stop",   "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer", "language": "russian" }
      }
    }
  },
  "mappings": {
    "product": {
      "properties": {
        "id":          { "type": "integer" },
        "name":        { "type": "string",  "analyzer": "russian", "copy_to": "search_all" },
        "description": { "type": "string",  "analyzer": "russian", "copy_to": "search_all" },
        "sku":         { "type": "string",  "index": "not_analyzed" },
        "category_id": { "type": "integer", "index": "not_analyzed" },
        "price":       { "type": "float" },
        "in_stock":    { "type": "boolean" },
        "tags":        { "type": "string",  "index": "not_analyzed" },
        "search_all":  { "type": "string",  "analyzer": "russian" }
      }
    }
  }
}

PHP Elasticsearch Client

<?php
// composer require elasticsearch/elasticsearch:~2.0

require 'vendor/autoload.php';

use Elasticsearch\ClientBuilder;

$client = ClientBuilder::create()
    ->setHosts(['localhost:9200'])
    ->build();

Indexing Data

<?php
// Index a single product
$client->index([
    'index' => 'products',
    'type'  => 'product',
    'id'    => $product['id'],
    'body'  => [
        'id'          => $product['id'],
        'name'        => $product['name'],
        'description' => strip_tags($product['description']),
        'sku'         => $product['sku'],
        'category_id' => $product['category_id'],
        'price'       => (float)$product['price'],
        'in_stock'    => $product['stock'] > 0,
        'tags'        => $product['tags'] ?? [],
    ]
]);

// Bulk indexing (much faster for initial load)
function bulkIndex(array $products): void {
    global $client;

    $body = [];
    foreach ($products as $product) {
        $body[] = ['index' => ['_index' => 'products', '_type' => 'product', '_id' => $product['id']]];
        $body[] = [
            'id'          => $product['id'],
            'name'        => $product['name'],
            'description' => strip_tags($product['description']),
            'sku'         => $product['sku'],
            'category_id' => $product['category_id'],
            'price'       => (float)$product['price'],
            'in_stock'    => $product['stock'] > 0,
        ];
    }

    $response = $client->bulk(['body' => $body]);

    if ($response['errors']) {
        foreach ($response['items'] as $item) {
            if (isset($item['index']['error'])) {
                error_log('ES index error: ' . json_encode($item['index']['error']));
            }
        }
    }
}

// Index all products in batches
$offset = 0;
$batchSize = 500;
do {
    $products = $db->query("SELECT * FROM products ORDER BY id LIMIT $batchSize OFFSET $offset")->fetchAll();
    if (empty($products)) break;
    bulkIndex($products);
    $offset += $batchSize;
    echo "Indexed " . ($offset) . " products\n";
} while (count($products) === $batchSize);

Search Query

<?php
function search(string $query, array $filters = [], int $page = 1, int $size = 20): array {
    global $client;

    $must    = [];
    $filter  = [];

    if (!empty($query)) {
        $must[] = [
            'multi_match' => [
                'query'     => $query,
                'fields'    => ['name^3', 'description', 'sku'],
                // name^3 = name field is 3x more important than others
                'fuzziness' => 'AUTO',  // Typo tolerance
                'operator'  => 'and',
            ]
        ];
    }

    // Filters (exact match — not text search)
    if (!empty($filters['category_id'])) {
        $filter[] = ['term' => ['category_id' => $filters['category_id']]];
    }
    if (isset($filters['in_stock']) && $filters['in_stock']) {
        $filter[] = ['term' => ['in_stock' => true]];
    }
    if (!empty($filters['price_min']) || !empty($filters['price_max'])) {
        $range = [];
        if (!empty($filters['price_min'])) $range['gte'] = (float)$filters['price_min'];
        if (!empty($filters['price_max'])) $range['lte'] = (float)$filters['price_max'];
        $filter[] = ['range' => ['price' => $range]];
    }

    $body = [
        'from' => ($page - 1) * $size,
        'size' => $size,
        'query' => [
            'bool' => [
                'must'   => $must ?: [['match_all' => new \stdClass()]],
                'filter' => $filter,
            ]
        ],
        'sort' => !empty($query) ? ['_score' => 'desc'] : ['price' => 'asc'],
        'aggs' => [
            // Faceted navigation
            'categories' => [
                'terms' => ['field' => 'category_id', 'size' => 20]
            ],
            'price_range' => [
                'stats' => ['field' => 'price']
            ],
        ]
    ];

    $response = $client->search([
        'index' => 'products',
        'type'  => 'product',
        'body'  => $body,
    ]);

    return [
        'total'   => $response['hits']['total'],
        'hits'    => array_map(fn($hit) => $hit['_source'], $response['hits']['hits']),
        'facets'  => [
            'categories' => $response['aggregations']['categories']['buckets'],
            'price_min'  => $response['aggregations']['price_range']['min'],
            'price_max'  => $response['aggregations']['price_range']['max'],
        ],
        'took_ms' => $response['took'],
    ];
}

Keep ES in Sync with MySQL

<?php
// After every product write, update ES
class ProductRepository {
    public function update(int $id, array $data): void {
        // Update MySQL
        $this->db->prepare("UPDATE products SET ? WHERE id = ?")->execute([$data, $id]);

        // Update Elasticsearch
        $this->es->update([
            'index' => 'products',
            'type'  => 'product',
            'id'    => $id,
            'body'  => ['doc' => $data],
            'retry_on_conflict' => 3,
        ]);
    }

    public function delete(int $id): void {
        $this->db->prepare("DELETE FROM products WHERE id = ?")->execute([$id]);
        try {
            $this->es->delete(['index' => 'products', 'type' => 'product', 'id' => $id]);
        } catch (\Elasticsearch\Common\Exceptions\Missing404Exception $e) {
            // Already not in ES — ok
        }
    }
}

Reindexing Without Downtime

# Problem: mapping changes require reindexing
# Solution: use aliases

# Create new index
curl -XPUT localhost:9200/products_v2 -d '{ ... new mapping ... }'

# Index all data into products_v2
php artisan es:reindex --target=products_v2

# When reindexing is complete, swap the alias
curl -XPOST localhost:9200/_aliases -d '{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add":    { "index": "products_v2", "alias": "products" } }
  ]
}'

# Application always reads from "products" alias — seamless switch

Before vs After

Metric MySQL LIKE Elasticsearch 2.x
Search time (200k docs) 4,200ms 48ms
Typo tolerance None Fuzzy matching
Relevance sorting None TF-IDF scoring
Faceted filters Slow (JOIN+GROUP BY) Fast (aggregations)
Infrastructure cost 0 (in MySQL) +1 server or +512MB RAM

The 87x speed improvement (4200ms → 48ms) transformed search from a page-abandonment trigger into a usable feature. Conversion on pages where users used search went from 2.1% to 6.8%.

Read Also

GraphQL vs REST API in 2026: A Practical Guide to Choosing the Right Approachaunimeda
Backend

GraphQL vs REST API in 2026: A Practical Guide to Choosing the Right Approach

GraphQL has been 'the future of APIs' for almost a decade. REST has been 'dying' for just as long. Both are still widely used in 2026 — because they solve different problems. Here's a practical framework for choosing, with real tradeoffs from production systems.

How to Implement Real-Time Features with WebSockets and Socket.io 1.x (2016)aunimeda
Backend

How to Implement Real-Time Features with WebSockets and Socket.io 1.x (2016)

Socket.io 1.x (2016) made WebSockets practical: automatic fallback to polling, rooms for namespacing, and a simple emit/on API. We used it to build a real-time order tracking dashboard. Here's the server setup, client integration, authentication via JWT, and scaling with Redis adapter.

How to Use Redis for Caching in PHP: Cutting Response Times from 800ms to 40ms (2015)aunimeda
Backend

How to Use Redis for Caching in PHP: Cutting Response Times from 800ms to 40ms (2015)

Redis caching reduced our PHP app's average response time from 800ms to 40ms on product listing pages. The pattern: cache database query results with TTL, invalidate on write. Here's the exact implementation with cache key strategy, stampede prevention, and cache warming.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles