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