AboutBlogContact
Backend EngineeringMay 24, 2016 5 min read 145Updated: June 22, 2026

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

AunimedaAunimeda
📋 Table of Contents

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%.


Aunimeda builds production-grade backend systems - APIs, microservices, real-time applications, and system integrations.

Contact us for backend engineering services. See also: Custom Software Development, Web Development

Read Also

Node.js + TypeScript: Building a Production REST API from Scratch in 2026aunimeda
Backend Engineering

Node.js + TypeScript: Building a Production REST API from Scratch in 2026

A complete guide to building a production-ready REST API with Node.js and TypeScript - authentication, validation, error handling, rate limiting, logging, and deployment. No shortcuts.

PostgreSQL Performance Optimization: The Practical Guide for 2026aunimeda
Backend Engineering

PostgreSQL Performance Optimization: The Practical Guide for 2026

Slow queries, missing indexes, N+1 problems, and connection pool exhaustion account for 90% of PostgreSQL performance issues. Here's how to diagnose and fix each one with real queries.

Redis Data Structures in Production: Beyond SET and GETaunimeda
Backend Engineering

Redis Data Structures in Production: Beyond SET and GET

Most teams use Redis as a glorified hash map. This guide covers the data structures that solve real production problems - sorted sets for leaderboards, streams for durable event queues, HyperLogLog for UV counting at scale, and Lua scripts for atomic operations you can't otherwise do safely.

Need IT development for your business?

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

Get Consultation All articles