matchory / elasticsearch

The missing Elasticsearch ORM for Laravel
MIT License
31 stars 13 forks source link
caching elasticsearch elasticsearch-client eloquent indexing laravel laravel-package lumen orm php query-builder scout search-engine

Latest Stable Version Total Downloads Latest Unstable Version License

Laravel Elasticsearch Integration

This is a fork of the excellent library by @basemkhirat, who sadly seems to have abandoned it by now.
As we rely on this library quite heavily, we will attempt to keep it up to date and compatible with newer Laravel and Elasticsearch versions.

Changes in this fork:

If you're interested in contributing, please submit a PR or open an issue!

Features:

Table of Contents

Requirements

Installation

This section describes the installation process for all supported application types.

Install package using composer

Whether you're using Laravel, Lumen or another framework, start by installing the package using composer:

composer require matchory/elasticsearch

Laravel Installation

If you have package autodiscovery disabled, add the service provider and facade to your config/app.php:

    'providers' => [
        // ...

        Matchory\Elasticsearch\ElasticsearchServiceProvider::class,

        // ...
    ],

    // ...

    'aliases' => [
        // ...

        'ES' => Matchory\Elasticsearch\Facades\ES::class,

        // ...
    ],

Lastly, publish the service provider to your configuration directory:

php artisan vendor:publish --provider="Matchory\Elasticsearch\ElasticsearchServiceProvider"

Lumen Installation

After installing the package from composer, add package service provider in bootstrap/app.php:

$app->register(Matchory\Elasticsearch\ElasticsearchServiceProvider::class);

Copy the package config directory at vendor/matchory/elasticsearch/src/config/ to your project root folder alongside with your app/ directory:

cp -r ./vendor/matchory/elasticsearch/src/config ./config

If you haven't already, make Lumen work with facades by uncommenting this line in bootstrap/app.php:

$app->withFacades();

If you don't want to enable facades in Lumen, you can access the query builder using app("elasticsearch"):

app("elasticsearch")->index("my_index")->type("my_type")->get();

# This is similar to:
ES::index("my_index")->type("my_type")->get();

Generic app installation

You can install package with any composer-based application. While we can't provide general instructions, the following example should give you an idea of how it works:

require "vendor/autoload.php";

use Matchory\Elasticsearch\ConnectionManager;
use Matchory\Elasticsearch\Factories\ClientFactory;

$connectionManager = new ConnectionManager([
 'servers' => [
        [
            "host" => '127.0.0.1',
            "port" => 9200,
            'user' => '',
            'pass' => '',
            'scheme' => 'http',
        ],
    ],

    // Custom handlers
    // 'handler' => new MyCustomHandler(),

    'index' => 'my_index',
], new ClientFactory());

$connection = $connectionManager->connection();

// Access the query builder using created connection
$documents = $connection->search("hello")->get();

Configuration (Laravel & Lumen)

After publishing the service provider, a configuration file has been created at config/es.php. Here, you can add one or more Elasticsearch connections, with multiple servers each. Take a look at the following example:

# Here you can define the default connection name.
'default' => env('ELASTIC_CONNECTION', 'default'),

# Here you can define your connections.
'connections' => [
    'default' => [
        'servers' => [
            [
                "host" => env("ELASTIC_HOST", "127.0.0.1"),
                "port" => env("ELASTIC_PORT", 9200),
                'user' => env('ELASTIC_USER', ''),
                'pass' => env('ELASTIC_PASS', ''),
                'scheme' => env('ELASTIC_SCHEME', 'http'),
            ]
        ],

        // Custom handlers
        // 'handler' => new MyCustomHandler(),
        'index' => env('ELASTIC_INDEX', 'my_index')
    ]
],

# Here you can define your indices.
'indices' => [
    'my_index_1' => [
        "aliases" => [
            "my_index"
        ],
        'settings' => [
            "number_of_shards" => 1,
            "number_of_replicas" => 0,
        ],
        'mappings' => [
            'posts' => [
                'properties' => [
                    'title' => [
                        'type' => 'string'
                    ]
                ]
            ]
        ]
    ]
]

If you'd like to use Elasticsearch with Laravel Scout, you can find the scout specific settings in config/scout.php.

Artisan commands (Laravel & Lumen)

With the artisan commands included with this package, you can create or update settings, mappings and aliases. Note that all commands use the default connection by default. You can change this by passing the --connection <your_connection_name> option.

The following commands are available:

es:indices:list: List all indices on server

$ php artisan es:indices:list
+----------------------+--------+--------+----------+------------------------+-----+-----+------------+--------------+------------+----------------+
| configured (es.php)  | health | status | index    | uuid                   | pri | rep | docs.count | docs.deleted | store.size | pri.store.size |
+----------------------+--------+--------+----------+------------------------+-----+-----+------------+--------------+------------+----------------+
| yes                  | green  | open   | my_index | 5URW60KJQNionAJgL6Q2TQ | 1   | 0   | 0          | 0            | 260b       | 260b           |
+----------------------+--------+--------+----------+------------------------+-----+-----+------------+--------------+------------+----------------+

es:indices:create: Create indices defined in config/es.php

Note that creating operation skips the index if exists.

# Create all indices in config file.
php artisan es:indices:create

# Create only 'my_index' index in config file
php artisan es:indices:create my_index 

es:indices:update: Update indices defined in config/es.php

Note that updating operation updates indices setting, aliases and mapping and doesn't delete the indexed data.

# Update all indices in config file.
php artisan es:indices:update

# Update only 'my_index' index in config file
php artisan es:indices:update my_index 

es:indices:drop: Drop index

Be careful when using this command, as you will lose your index data!
Running drop command with --force option will skip all confirmation messages.

# Drop all indices in config file.
php artisan es:indices:drop

# Drop specific index on sever. Not matter for index to be exist in config file or not.
php artisan es:indices:drop my_index 

Reindexing data (with zero downtime)

First, why reindexing?
Changing index mapping doesn't reflect without data reindexing, otherwise your search results will not work on the right way.
To avoid down time, your application should work with index alias not index name.
The index alias is a constant name that application should work with to avoid change index names.

Assume that we want to change mapping for my_index, this is how to do that:

  1. Add alias as example my_index_alias to my_index configuration and make sure your application is working with it.

    "aliases" => [
       "my_index_alias"
    ]       
  2. Update index with command:

    php artisan es:indices:update my_index
  3. Create a new index as example my_new_index with your new mapping in configuration file.

    $ php artisan es:indices:create my_new_index
  4. Reindex data from my_index into my_new_index with command:

    php artisan es:indices:reindex my_index my_new_index
    
    # Control bulk size. Adjust it with your server.
    php artisan es:indices:reindex my_index my_new_index --bulk-size=2000
    
    # Control query scroll value.
    php artisan es:indices:reindex my_index my_new_index --bulk-size=2000 --scroll=2m
    
    # Skip reindexing errors such as mapper parsing exceptions.
    php artisan es:indices:reindex my_index my_new_index --bulk-size=2000 --skip-errors 
    
    # Hide all reindexing errors and show the progres bar only.
    php artisan es:indices:reindex my_index my_new_index --bulk-size=2000 --skip-errors --hide-errors
  5. Remove my_index_alias alias from my_index and add it to my_new_index in configuration file and update with command:

    php artisan es:indices:update

Usage as a Laravel Scout driver

First, follow Laravel Scout installation.
All you have to do is updating the following lines in config/scout.php:

# change the default driver to 'elasticsearch'
'driver' => env('SCOUT_DRIVER', 'elasticsearch'),

# link `elasticsearch` driver with default elasticsearch connection in config/es.php
'elasticsearch' => [
    'connection' => env('ELASTIC_CONNECTION', 'default'),
],

Have a look at Laravel Scout documentation, too!

Elasticsearch models

Each index type has a corresponding "Model" which is used to interact with that type. Models allow you to query for data in your types or indices, as well as insert new documents into the type. Elasticsearch Models mimic Eloquent models as closely as possible: You can use model events, route bindings, advanced attribute methods and more. If there is any Eloquent functionality you're missing, open an issue, and we'll be happy to add it!.

Supported features:

  • Attributes
  • Events
  • Route bindings
  • Global and Local Query Scopes
  • Replicating models

A minimal model might look like this:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    // ...
}

Index Names

This model is not specifically bound to any index and will simply use the index configured for the given Elasticsearch connection. To specifically target an index, you may define an index property on the model:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    protected $index = 'posts';
}

Connection Names

By default, all Elasticsearch models will use the default connection that's configured for your application. If you would like to specify a different connection that should be used when interacting with a particular model, you should define a $connection property on the model:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    protected $connection = 'blag';
}

Mapping type

If you're still using mapping types, you may add a type property to your model to indicate the mapping _type to be used for queries.

Mapping Types are deprecated:
Please note that Elastic has deprecated mapping types and will remove them in the next major release. You should not rely on them to continue working.

namespace App;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    protected $type = 'posts';
}

Default Attribute Values

By default, a newly instantiated model instance will not contain any attribute values. If you would like to define the default values for some of your model's attributes, you may define an attributes property on your model:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    protected $attributes = [
        'published' => false,
    ];
}

Retrieving Models

Once you have created a model and its associated index type, you are ready to start retrieving data from your index. You can think of your Elasticsearch model as a powerful query builder allowing you to fluently query the index associated with the model. The model's all method will retrieve all the documents from the model's associated Elasticsearch index:

use App\Models\Post;

foreach (Post::all() as $post) {
    echo $post->title;
}

Adding additional constraints

The all method will return all the results in the model's index. However, since each Elasticsearch model serves as a query builder, you may add additional constraints to queries, and then invoke the get() method to retrieve the results:

use App\Models\Post;

$posts = Post::where('status', 1)
             ->orderBy('created_at', 'desc')
             ->take(10)
             ->get();

Collections

As we have seen, Elasticsearch methods like all and get retrieve multiple documents from the index. However, these methods don't return a plain PHP array. Instead, an instance of Matchory\Elasticsearch\Collection is returned.

The Elasticsearch Collection class extends Laravel's base Illuminate\Support\Collection class, which provides a variety of helpful methods for interacting with data collections. For example, the reject method may be used to remove models from a collection based on the results of an invoked closure:

use App\Models\Post;

$posts = Post::where('sponsored', true)->get();
$posts = $posts->reject($post => $post->in_review);

In addition to the methods provided by Laravel's base collection class, the Elasticsearch collection class provides a few extra methods that are specifically intended for interacting with collections of Elasticsearch models:

Result Meta data

Elasticsearch provides a few additional fields in addition to the hits of a query, like the total result amount, or the query execution time. The Elasticsearch collection provides getters for these properties:

use App\Models\Post;

$posts = Post::all();
$total = $posts->getTotal();
$maxScore = $posts->getMaxScore();
$duration = $posts->getDuration();
$isTimedOut = $posts->isTimedOut();
$scrollId = $posts->getScrollId();
$shards = $posts->getShards();

Iterating

Since all of Laravel's collections implement PHP's iterable interfaces, you may loop over collections as if they were an array:

foreach ($title as $title) {
    echo $post->title;
}

Chunking Results

Elasticsearch indices can grow quite huge. Your application may run out of memory if you would attempt to load tens of thousands of Elasticsearch documents via the all or get methods without an upper bound. Therefore, the default amount of documents fetched is set to 10. To change this, use the take method:

use App\Models\Post;

$posts = Post::take(500)->get();

Retrieving individual Models

In addition to retrieving all the documents matching a given query, you may also retrieve single documents using the find, first, or firstWhere methods. Instead of returning a collection of models, these methods return a single model instance:

use App\Models\Post;

// Retrieve a model by its ID...
$posts = Post::find('AVp_tCaAoV7YQD3Esfmp');

// Retrieve the first model matching the query constraints...
$post = Post::where('published', 1)->first();

// Alternative to retrieving the first model matching the query constraints...
$post = Post::firstWhere('published', 1);```

Sometimes you may wish to retrieve the first result of a query or perform some other action if no results are found. The firstOr method will return the first result matching the query or, if no results are found, execute the given closure. The value returned by the closure will be considered the result of the firstOr method:

use App\Models\Post;

$model = Post::where('tags', '>', 3)->firstOr(function () {
    // ...
});

Not Found Exceptions

Sometimes you may wish to throw an exception if a model is not found. This is particularly useful in routes or controllers. The findOrFail and firstOrFail methods will retrieve the first result of the query; however, if no result is found, a Matchory\Elasticsearch\Exceptions\DocumentNotFoundException will be thrown:

$post = Post::findOrFail('AVp_tCaAoV7YQD3Esfmp');

$post = Post::where('published', true)->firstOrFail();

If the DocumentNotFoundException is not caught, a 404 HTTP response is automatically sent back to the client:

use App\Models\Post;

Route::get('/api/posts/{id}', function ($id) {
    return Post::findOrFail($id);
});

Inserting and Updating Models

Inserts

To insert a new document into the index, you should instantiate a new model instance and set attributes on the model. Then, call the save method on the model instance:

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Controllers\Controller;

class PostController extends Controller
{
    /**
     * Create a new post instance.
     *
     * @param  Request  $request
     * @return Response
     */
    public function store(Request $request): Response
    {
        // Validate the request...

        $post = new Post;
        $post->title = $request->title;

        $post->save();
    }
}

In this example, we assign the name field from the incoming HTTP request to the name attribute of the App\Models\Post model instance. When we call the save method, a document will be inserted into the index.

Alternatively, you may use the create method to "save" a new model using a single PHP statement. The inserted model instance will be returned to you by the create method:

use App\Models\Post;

$post = Post::create([
'title' => 'Searching efficiently',
]);

However, before using the create method, you will need to specify either a fillable or guarded property on your model class. These properties are required because all Elasticsearch models are protected against mass assignment vulnerabilities by default. To learn more about mass assignment, please consult the mass assignment documentation.

Updates

The save method may also be used to update models that already exist in the index. To update a model, you should retrieve it and set any attributes you wish to update. Then, you should call the model's save method.

The save() method may also be used to update models that already exist. To update a model, you should retrieve it, set any attributes you wish to update, and then call the save method.

use App\Models\Post;

$post = Post::find('AVp_tCaAoV7YQD3Esfmp');

$post->title = 'Modified Post Title';

$post->save();

Examining Attribute Changes

Elasticsearch provides the isDirty, isClean, and wasChanged methods to examine the internal state of your model and determine how its attributes have changed from when the model was originally retrieved.

The isDirty method determines if any of the model's attributes have been changed since the model was retrieved. You may pass a specific attribute name to the isDirty method to determine if a particular attribute is dirty. The isClean will determine if an attribute has remained unchanged since the model was retrieved. This method also accepts an optional attribute argument:

use App\Models\Author;

$author = Author::create([
'first_name' => 'Moritz',
'last_name' => 'Friedrich',
'title' => 'Developer',
]);

$author->title = 'Painter';

$author->isDirty(); // true
$author->isDirty('title'); // true
$author->isDirty('first_name'); // false

$author->isClean(); // false
$author->isClean('title'); // false
$author->isClean('first_name'); // true

$author->save();

$author->isDirty(); // false
$author->isClean(); // true

The wasChanged method determines if any attributes were changed when the model was last saved within the current request cycle. If needed, you may pass an attribute name to see if a particular attribute was changed:

use App\Models\Author;

$author = Author::create([
'first_name' => 'Taylor',
'last_name' => 'Otwell',
'title' => 'Developer',
]);

$author->title = 'Painter';

$author->save();

$author->wasChanged(); // true
$author->wasChanged('title'); // true
$author->wasChanged('first_name'); // false

The getOriginal method returns an array containing the original attributes of the model regardless of any changes to the model since it was retrieved. If needed, you may pass a specific attribute name to get the original value of a particular attribute:

use App\Models\Author;

$author = Author::find(1);

$author->name; // John
$author->email; // john@example.com

$author->name = "Jack";
$author->name; // Jack

$author->getOriginal('name'); // John
$author->getOriginal(); // Array of original attributes...

Mass Assignment

You may use the create method to "save" a new model using a single PHP statement. The inserted model instance will be returned to you by the method:

use App\Models\Post;

$post = Post::create([
    'title' => 'Searching effectively',
]);

However, before using the create method, you will need to specify either a fillable or guarded property on your model class. These properties are required because all Elasticsearch models are protected against mass assignment vulnerabilities by default.

A mass assignment vulnerability occurs when a user passes an unexpected HTTP request field and that field changes a field in your index that you did not expect.

So, to get started, you should define which model attributes you want to make mass assignable. You may do this using the fillable property on the model. For example, let's make the title attribute of our Post model mass assignable:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['title'];
}

Once you have specified which attributes are mass assignable, you may use the create method to insert a new document in the index. The create method returns the newly created model instance:

$post = Post::create(['title' => 'Searching effectively']);

If you already have a model instance, you may use the fill method to populate it with an array of attributes:

$post->fill(['title' => 'Searching more effectively']);

Allowing Mass Assignment

If you would like to make all of your attributes mass assignable, you may define your model's guarded property as an empty array. If you choose to un-guard your model, you should take special care to always hand-craft the arrays passed to Elasticsearch's fill, create, and update methods:

/**
 * The attributes that aren't mass assignable.
 *
 * @var array
 */
protected $guarded = [];

Upserts

There is currently no convenience wrapper for upserting documents (inserting or updating depending on whether models exist). If you're interested in such a capability, please open an issue.

Deleting Models

To delete a model, call the delete method on a model instance:

use App\Models\Post;

$post = Post::find('AVp_tCaAoV7YQD3Esfmp');

$post->delete();

Deleting An Existing Model By Its ID

In the example above, we are retrieving the model from the index before calling the delete method. However, if you know the ID of the model, you may delete the model without explicitly retrieving it by calling the destroy method. In addition to accepting the single ID, the destroy method will accept multiple IDs, an array of IDs, or a collection of IDs:

use App\Models\Post;

Post::destroy(1);

Post::destroy(1, 2, 3);

Post::destroy([1, 2, 3]);

Post::destroy(collect([1, 2, 3]));

Important:
The destroy method loads each model individually and calls the delete method so that the deleting and deleted events are properly dispatched for each model.

Query Scopes

Query scopes are implemented exactly the way as they are in Eloquent.

Global Scopes

Global scopes allow you to add constraints to all queries for a given model. Writing your own global scopes can provide a convenient, easy way to make sure every query for a given model receives certain constraints.

Writing Global Scopes

Writing a global scope is simple. First, define a class that implements the Matchory\Elasticsearch\Interfaces\ScopeInterface interface. Laravel does not have a conventional location that you should place scope classes, so you are free to place this class in any directory that you wish.

The ScopeInterface requires you to implement one method: apply. The apply method may add constraints or other types of clauses to the query as needed:

namespace App\Scopes;

use Matchory\Elasticsearch\Query;
use Matchory\Elasticsearch\Model;
use Matchory\Elasticsearch\Interfaces\ScopeInterface;

class AncientScope implements ScopeInterface
{
    /**
     * Apply the scope to a given Elasticsearch query builder.
     *
     * @param  \Matchory\Elasticsearch\Query  $query
     * @param  \Matchory\Elasticsearch\Model  $model
     * @return void
     */
    public function apply(Query $query, Model $model)
    {
        $query->where('created_at', '<', now()->subYears(2000));
    }
}
Applying Global Scopes

To assign a global scope to a model, you should override the model's booted method and invoke the model's addGlobalScope method. The addGlobalScope method accepts an instance of your scope as its only argument:

namespace App\Models;

use App\Scopes\AncientScope;
use Matchory\Elasticsearch\Model;

class Post extends Model
{
    /**
     * The "booted" method of the model.
     *
     * @return void
     */
    protected static function booted()
    {
        static::addGlobalScope(new AncientScope);
    }
}
Anonymous Global Scopes

Elasticsearch also allows you to define global scopes using closures, which is particularly useful for simple scopes that do not warrant a separate class of their own. When defining a global scope using a closure, you should provide a scope name of your own choosing as the first argument to the addGlobalScope method:

namespace App\Models;

use Matchory\Elasticsearch\Query;
use Matchory\Elasticsearch\Model;

class Post extends Model
{
    /**
     * The "booted" method of the model.
     *
     * @return void
     */
    protected static function booted(): void
    {
        static::addGlobalScope('ancient', function (Query $query) {
            $query->where('created_at', '<', now()->subYears(2000));
        });
    }
}
Removing Global Scopes

If you would like to remove a global scope for a given query, you may use the withoutGlobalScope method. This method accepts the class name of the global scope as its only argument:

Post::withoutGlobalScope(AncientScope::class)->get();

Or, if you defined the global scope using a closure, you should pass the string name that you assigned to the global scope:

Post::withoutGlobalScope('ancient')->get();

If you would like to remove several or even all of the query's global scopes, you may use the withoutGlobalScopes method:

// Remove all of the global scopes...
Post::withoutGlobalScopes()->get();
// Remove some of the global scopes...
Post::withoutGlobalScopes([
    FirstScope::class,
    SecondScope::class
])->get();

Local Scopes

Local scopes allow you to define common sets of query constraints that you may easily re-use throughout your application. For example, you may need to frequently retrieve all posts that are considered "popular".

Writing local scopes

To define a scope, prefix an Elasticsearch model method with scope. Scopes should always return a query builder instance:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    /**
     * Scope a query to only include popular posts.
     *
     * @param  \Matchory\Elasticsearch\Query  $query
     * @return \Matchory\Elasticsearch\Query
     */
    public function scopePopular(Query $query): Query
    {
        return $query->where('votes', '>', 100);
    }

    /**
     * Scope a query to only include published posts.
     *
     * @param  \Matchory\Elasticsearch\Query  $query
     * @return \Matchory\Elasticsearch\Query
     */
    public function scopePublished(Query $query): Query
    {
        return $query->where('published', 1);
    }
}
Utilizing local scopes

Once the scope has been defined, you may call the scope methods when querying the model. However, you should not include the scope prefix when calling the method. You can even chain calls to various scopes:

use App\Models\Post;

$posts = Post::popular()->published()->orderBy('created_at')->get();

Dynamic Scopes

Sometimes you may wish to define a scope that accepts parameters. To get started, just add your additional parameters to your scope method's signature. Scope parameters should be defined after the $query parameter:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    /**
     * Scope a query to only include posts of a given type.
     *
     * @param  \Matchory\Elasticsearch\Query  $query
     * @param  mixed  $type
     * @return \Matchory\Elasticsearch\Query
     */
    public function scopeOfType(Query $query, $type): Query
    {
        return $query->where('type', $type);
    }
}

Once the expected arguments have been added to your scope method's signature, you may pass the arguments when calling the scope:

$posts = Post::ofType('news')->get();

Comparing Models

Sometimes you may need to determine if two models are the "same". The is method may be used to quickly verify two models have the same ID, index, type, and connection:

if ($post->is($anotherPost)) {
    //
}

Events

Elasticsearch models dispatch several events, allowing you to hook into the following moments in a model's lifecycle: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, and replicating.

The retrieved event will dispatch when an existing model is retrieved from the index. When a new model is saved for the first time, the creating and created events will dispatch. The updating / updated events will dispatch when an existing model is modified, and the save method is called. The saving / saved events will dispatch when a model is created or updated - even if the model's attributes have not been changed.

To start listening to model events, define a dispatchesEvents property on your Elasticsearch model. This property maps various points of the Elasticsearch model's lifecycle to your own event classes. Each model event class should expect to receive an instance of the affected model via its constructor:

namespace App\Models;

use Matchory\Elasticsearch\Model;
use App\Events\UserDeleted;
use App\Events\UserSaved;

class Post extends Model
{
    /**
     * The event map for the model.
     *
     * @var array
     */
    protected $dispatchesEvents = [
        'saved' => PostSaved::class,
        'deleted' => PostDeleted::class,
    ];
}

After defining and mapping your events, you may use event listeners to handle the events.

Using Closures

Instead of using custom event classes, you may register closures that execute when various model events are dispatched. Typically, you should register these closures in the booted method of your model:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class Post extends Model
{
    /**
     * The "booted" method of the model.
     *
     * @return void
     */
    protected static function booted(): void
    {
        static::created(function ($post) {
            //
        });
    }
}

If needed, you may utilize queueable anonymous event listeners when registering model events. This will instruct Laravel to execute the model event listener in the background using your application's queue:

use function Illuminate\Events\queueable;

static::created(queueable(function ($post): void {
    //
}));
Accessors & Mutators
Defining An Accessor

To define an accessor, create a getFooAttribute method on your model where Foo is the "studly" cased name of the field you wish to access. In this example, we'll define an accessor for the title attribute. The accessor will automatically be called by model when attempting to retrieve the value of the title attribute:


namespace App;

use Matchory\Elasticsearch\Model;

class post extends Model
{
    /**
     * Get the post title.
     *
     * @param  string  $value
     * @return string
     */
    public function getTitleAttribute(string $value): string
    {
        return ucfirst($value);
    }
}

As you can see, the original value of the field is passed to the accessor, allowing you to manipulate and return the value. To access the value of the accessor, you may simply access the title attribute on a model instance:

$post = App\Post::find(1);

$title = $post->title;

Occasionally, you may need to add array attributes that do not have a corresponding field in your index. To do so, simply define an accessor for the value:

public function getIsPublishedAttribute(): bool
{
    return $this->attributes['status'] === 1;
}

Once you have created the accessor, just add the value to the appends property on the model:

protected $appends = ['is_published'];

Once the attribute has been added to the appends list, it will be included in model's array.

Defining A Mutator

To define a mutator, define a setFooAttribute method on your model where Foo is the "studly" cased name of the field you wish to access. So, again, let's define a mutator for the title attribute. This mutator will be automatically called when we attempt to set the value of the titleattribute on the model:

namespace App;

use Matchory\Elasticsearch\Model;

class post extends Model
{
    /**
     * Set the post title.
     *
     * @param  string  $value
     * @return string
     */
    public function setTitleAttribute(string $value): string
    {
        return strtolower($value);
    }
}

The mutator will receive the value that is being set on the attribute, allowing you to manipulate the value and set the manipulated value on the model's internal $attributes property. So, for example, if we attempt to set the title attribute to Awesome post to read:

$post = App\Post::find(1);

$post->title = 'Awesome post to read';

In this example, the setTitleAttribute function will be called with the value Awesome post to read. The mutator will then apply the strtolower function to the name and set its resulting value in the internal $attributes array.

Muting Events

You may occasionally need to temporarily "mute" all events fired by a model. You may achieve this using the withoutEvents method. The withoutEvents method accepts a closure as its only argument. Any code executed within this closure will not dispatch model events. For example, the following example will fetch and delete an App\Models\Post instance without dispatching any model events. Any value returned by the closure will be returned by the withoutEvents method:

use App\Models\Post;

$post = Post::withoutEvents(function () use () {
Post::findOrFail(1)->delete();

    return Post::find(2);
});

Saving A Single Model Without Events

Sometimes you may wish to "save" a given model without dispatching any events. You may accomplish this using the saveQuietly method:

$post = Post::findOrFail(1);

$post->title = 'Other search strategies';

$post->saveQuietly();

Replicating Models

You may create an unsaved copy of an existing model instance using the replicate method. This method is particularly useful when you have model instances that share many of the same attributes:

use App\Models\Address;

$shipping = Address::create([
    'type' => 'shipping',
    'line_1' => '123 Example Street',
    'city' => 'Victorville',
    'state' => 'CA',
    'postcode' => '90001',
]);

$billing = $shipping->replicate()->fill([
    'type' => 'billing'
]);

$billing->save();

Mutators and Casting

Accessors, mutators, and attribute casting allow you to transform Elasticsearch attribute values when you retrieve or set them on model instances. For example, you may want to use the Laravel encrypter to encrypt a value while it is stored in the index, and then automatically decrypt the attribute when you access it on an Elasticsearch model. Or, you may want to convert a JSON string that is stored in your index to an array when it is accessed via your Elasticsearch model.

Accessors & Mutators

Defining An Accessor

An accessor transforms an Elasticsearch attribute value when it is accessed. To define an accessor, create a get{Attribute}Attribute method on your model where {Attribute} is the "studly" cased name of the field you wish to access.

In this example, we'll define an accessor for the first_name attribute. The accessor will automatically be called by Elasticsearch when attempting to retrieve the value of the first_name attribute:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class User extends Model
{
    /**
     * Get the user's first name.
     *
     * @param  string  $value
     * @return string
     */
    public function getFirstNameAttribute(string $value): string
    {
        return ucfirst($value);
    }
}

As you can see, the original value of the field is passed to the accessor, allowing you to manipulate and return the value. To access the value of the accessor, you may simply access the first_name attribute on a model instance:

use App\Models\User;

$user = User::find(1);

$firstName = $user->first_name;

You are not limited to interacting with a single attribute within your accessor. You may also use accessors to return new, computed values from existing attributes:

/**
 * Get the user's full name.
 *
 * @return string
 */
public function getFullNameAttribute(): string
{
    return "{$this->first_name} {$this->last_name}";
}
Defining A Mutator

A mutator transforms an Elasticsearch attribute value when it is set. To define a mutator, define a set{Attribute}Attribute method on your model where {Attribute} is the "studly" cased name of the field you wish to access.

Let's define a mutator for the first_name attribute. This mutator will be automatically called when we attempt to set the value of the first_name attribute on the model:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class User extends Model
{
    /**
     * Set the user's first name.
     *
     * @param  string  $value
     * @return void
     */
    public function setFirstNameAttribute(string $value): void
    {
        $this->attributes['first_name'] = strtolower($value);
    }
}

The mutator will receive the value that is being set on the attribute, allowing you to manipulate the value and set the manipulated value on the Elasticsearch model's internal $attributes property. To use our mutator, we only need to set the first_name attribute on an Elasticsearch model:

use App\Models\User;

$user = User::find(1);

$user->first_name = 'Sally';

In this example, the setFirstNameAttribute function will be called with the value Sally. The mutator will then apply the strtolower function to the name and set its resulting value in the internal $attributes array.

Attribute Casting

Attribute casting provides functionality similar to accessors and mutators without requiring you to define any additional methods on your model. Instead, your model's $casts property provides a convenient method of converting attributes to common data types.

The $casts property should be an array where the key is the name of the attribute being cast, and the value is the type you wish to cast the field to. The supported cast types are:

To demonstrate attribute casting, let's cast the is_admin attribute, which is stored in our index as an integer (0 or 1) to a boolean value:

namespace App\Models;

use Matchory\Elasticsearch\Model;

class User extends Model
{
    /**
     * The attributes that should be cast.
     *
     * @var array
     */
    protected $casts = [
        'is_admin' => 'boolean',
    ];
}

After defining the cast, the is_admin attribute will always be cast to a boolean when you access it, even if the underlying value is stored in the index as an integer:

$user = App\Models\User::find(1);

if ($user->is_admin) {
    //
}

Note: Attributes that are null will not be cast.

Date Casting

You may cast date attributes by defining them within your model's $cast property array. Typically, dates should be cast using the datetime cast.

When defining a date or datetime cast, you may also specify the date's format. This format will be used when the model is serialized to an array or JSON:

/**
 * The attributes that should be cast.
 *
 * @var array
 */
protected $casts = [
    'created_at' => 'datetime:Y-m-d',
];

When a field is cast as a date, you may set its value to a UNIX timestamp, date string (Y-m-d), date-time string, or a DateTime / Carbon instance. The date's value will be correctly converted and stored in your index:

You may customize the default serialization format for all of your model's dates by defining a serializeDate method on your model. This method does not affect how your dates are formatted for storage in the index:

/**
 * Prepare a date for array / JSON serialization.
 *
 * @param  \DateTimeInterface  $date
 * @return string
 */
protected function serializeDate(DateTimeInterface $date)
{
    return $date->format('Y-m-d');
}

To specify the format that should be used when actually storing a model's dates within your index, you should define a $dateFormat property on your model:

/**
 * The storage format of the model's date fields.
 *
 * @var string
 */
protected $dateFormat = 'U';

Custom Casts

Laravel has a variety of built-in, helpful cast types; however, you may occasionally need to define your own cast types. You may accomplish this by defining a class that implements the CastsAttributes interface.

Classes that implement this interface must define a get and set method. The get method is responsible for transforming a raw value from the index into a cast value, while the set method should transform a cast value into a raw value that can be stored in the index. As an example, we will re-implement the built-in json cast type as a custom cast type:

Note: Due to type incompatibility, you will need to use different casts for Eloquent and Elasticsearch models, or omit the parameter type.

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class Json implements CastsAttributes
{
    /**
     * Cast the given value.
     *
     * @param  \Illuminate\Database\Eloquent\Model|\Matchory\Elasticsearch\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return array
     */
    public function get($model, $key, $value, $attributes)
    {
        return json_decode($value, true);
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model|\Matchory\Elasticsearch\Model  $model
     * @param  string  $key
     * @param  array  $value
     * @param  array  $attributes
     * @return string
     */
    public function set($model, $key, $value, $attributes)
    {
        return json_encode($value);
    }
}

Once you have defined a custom cast type, you may attach it to a model attribute using its class name:

    namespace App\Models;

    use App\Casts\Json;
    use Matchory\Elasticsearch\Model;

    class User extends Model
    {
        /**
         * The attributes that should be cast.
         *
         * @var array
         */
        protected $casts = [
            'options' => Json::class,
        ];
    }
Value Object Casting

You are not limited to casting values to primitive types. You may also cast values to objects. Defining custom casts that cast values to objects is very similar to casting to primitive types; however, the set method should return an array of key / value pairs that will be used to set raw, storable values on the model.

As an example, we will define a custom cast class that casts multiple model values into a single Address value object. We will assume the Address value has two public properties: lineOne and lineTwo:

namespace App\Casts;

use App\Models\Address as AddressModel;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use InvalidArgumentException;

class Address implements CastsAttributes
{
    /**
     * Cast the given value.
     *
     * @param  \Illuminate\Database\Eloquent\Model|\Matchory\Elasticsearch\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return \App\Models\Address
     */
    public function get($model, $key, $value, $attributes)
    {
        return new AddressModel(
            $attributes['address_line_one'],
            $attributes['address_line_two']
        );
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model|\Matchory\Elasticsearch\Model  $model
     * @param  string  $key
     * @param  \App\Models\Address  $value
     * @param  array  $attributes
     * @return array
     */
    public function set($model, $key, $value, $attributes)
    {
        if (! $value instanceof AddressModel) {
            throw new InvalidArgumentException('The given value is not an Address instance.');
        }

        return [
            'address_line_one' => $value->lineOne,
            'address_line_two' => $value->lineTwo,
        ];
    }
}

When casting to value objects, any changes made to the value object will automatically be synced back to the model before the model is saved:

use App\Models\User;

$user = User::find(1);

$user->address->lineOne = 'Updated Address Value';

$user->save();

Tip: If you plan to serialize your Elasticsearch models containing value objects to JSON or arrays, you should implement the Illuminate\Contracts\Support\Arrayable and JsonSerializable interfaces on the value object.

Array / JSON Serialization

When an Elasticsearch model is converted to an array or JSON using the toArray and toJson methods, your custom cast value objects will typically be serialized as well as long as they implement the Illuminate\Contracts\Support\Arrayable and JsonSerializable interfaces. However, when using value objects provided by third-party libraries, you may not have the ability to add these interfaces to the object.

Therefore, you may specify that your custom cast class will be responsible for serializing the value object. To do so, your custom class cast should implement the Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes interface. This interface states that your class should contain a serialize method which should return the serialized form of your value object:

/**
 * Get the serialized representation of the value.
 *
 * @param  \Illuminate\Database\Eloquent\Model|\Matchory\Elasticsearch\Model  $model
 * @param  string  $key
 * @param  mixed  $value
 * @param  array  $attributes
 * @return mixed
 */
public function serialize($model, string $key, $value, array $attributes)
{
    return (string) $value;
}
Inbound Casting

Occasionally, you may need to write a custom cast that only transforms values that are being set on the model and does not perform any operations when attributes are being retrieved from the model. A classic example of an inbound only cast is a "hashing" cast. Inbound only custom casts should implement the CastsInboundAttributes interface, which only requires a set method to be defined.

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;

class Hash implements CastsInboundAttributes
{
    /**
     * The hashing algorithm.
     *
     * @var string
     */
    protected $algorithm;

    /**
     * Create a new cast class instance.
     *
     * @param  string|null  $algorithm
     * @return void
     */
    public function __construct($algorithm = null)
    {
        $this->algorithm = $algorithm;
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model|\Matchory\Elasticsearch\Model  $model
     * @param  string  $key
     * @param  array  $value
     * @param  array  $attributes
     * @return string
     */
    public function set($model, $key, $value, $attributes)
    {
        return is_null($this->algorithm)
                    ? bcrypt($value)
                    : hash($this->algorithm, $value);
    }
}
Cast Parameters

When attaching a custom cast to a model, cast parameters may be specified by separating them from the class name using a : character and comma-delimiting multiple parameters. The parameters will be passed to the constructor of the cast class:

/**
 * The attributes that should be cast.
 *
 * @var array
 */
protected $casts = [
    'secret' => Hash::class.':sha256',
];
Castables

You may want to allow your application's value objects to define their own custom cast classes. Instead of attaching the custom cast class to your model, you may alternatively attach a value object class that implements the Illuminate\Contracts\Database\Eloquent\Castable interface:

use App\Models\Address;

protected $casts = [
    'address' => Address::class,
];

Objects that implement the Castable interface must define a castUsing method that returns the class name of the custom caster class that is responsible for casting to and from the Castable class:

namespace App\Models;

use Illuminate\Contracts\Database\Eloquent\Castable;
use App\Casts\Address as AddressCast;

class Address implements Castable
{
    /**
     * Get the name of the caster class to use when casting from / to this cast target.
     *
     * @param  array  $arguments
     * @return string
     */
    public static function castUsing(array $arguments): string
    {
        return AddressCast::class;
    }
}

When using Castable classes, you may still provide arguments in the $casts definition. The arguments will be passed to the castUsing method:

use App\Models\Address;

protected $casts = [
    'address' => Address::class.':argument',
];
Castables & Anonymous Cast Classes

By combining "castables" with PHP's anonymous classes, you may define a value object and its casting logic as a single castable object. To accomplish this, return an anonymous class from your value object's castUsing method. The anonymous class should implement the CastsAttributes interface:

namespace App\Models;

use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class Address implements Castable
{
    // ...

    /**
     * Get the caster class to use when casting from / to this cast target.
     *
     * @param  array  $arguments
     * @return object|string
     */
    public static function castUsing(array $arguments)
    {
        return new class implements CastsAttributes
        {
            public function get($model, $key, $value, $attributes)
            {
                return new Address(
                    $attributes['address_line_one'],
                    $attributes['address_line_two']
                );
            }

            public function set($model, $key, $value, $attributes)
            {
                return [
                    'address_line_one' => $value->lineOne,
                    'address_line_two' => $value->lineTwo,
                ];
            }
        };
    }
}

Route Model Binding

When injecting a model ID to a route or controller action, you will often query the Elasticsearch index to retrieve the model that corresponds to that ID. Laravel route model binding provides a convenient way to automatically inject the model instances directly into your routes. For example, instead of injecting a user's ID, you can inject the entire User model instance that matches the given ID.

Implicit Binding

Laravel automatically resolves Elasticsearch models defined in routes or controller actions whose type-hinted variable names match a route segment name. For example:

use App\Models\Post;

Route::get('/posts/{post}', function (Post $post) {
    return $post->content;
});

Since the $post variable is type-hinted as the App\Models\Post Elasticsearch model, and the variable name matches the {post} URI segment, Laravel will automatically inject the model instance that has an ID matching the corresponding value from the request URI. If a matching model instance is not found in the database, a 404 HTTP response will automatically be generated.

Of course, implicit binding is also possible when using controller methods. Again, note the {post} URI segment matches the $post variable in the controller which contains an App\Models\Post type-hint:

use App\Http\Controllers\PostController;
use App\Models\Post;

// Route definition...
Route::get('/posts/{post}', [PostController::class, 'show']);

// Controller method definition...
public function show(Post $post): View
{
    return view('post.full', ['post' => $post]);
}

Customizing The Key

Sometimes you may wish to resolve Elasticsearch models using a field other than _id. To do so, you may specify the field in the route parameter definition:

use App\Models\Post;

Route::get('/posts/{post:slug}', fn(Post $post): Post => $post);

If you would like model binding to always use an index field other than _id when retrieving a given model class, you may override the getRouteKeyName method on the Elasticsearch model:

/**
 * Get the route key for the model.
 *
 * @return string
 */
public function getRouteKeyName(): string
{
    return 'slug';
}

Customizing Missing Model Behavior

Typically, a 404 HTTP response will be generated if an implicitly bound model is not found. However, you may customize this behavior by calling the missing method when defining your route. The missing method accepts a closure that will be invoked if an implicitly bound model can not be found:

use App\Http\Controllers\LocationsController;
use Illuminate\Http\Request;

Route::get('/locations/{location:slug}', [LocationsController::class, 'show'])
    ->missing(fn(Request $request) => Redirect::route('locations.index')
    ->name('locations.view');

Explicit Binding

You are not required to use Laravel's implicit, convention based model resolution in order to use model binding. You can also explicitly define how route parameters correspond to models. To register an explicit binding, use the router's model method to specify the class for a given parameter. You should define your explicit model bindings at the beginning of the boot method of your RouteServiceProvider class:

use App\Models\Post;
use Illuminate\Support\Facades\Route;

/**
 * Define your route model bindings, pattern filters, etc.
 *
 * @return void
 */
public function boot():void
{
    Route::model('post', Post::class);

    // ...
}

Next, define a route that contains a {post} parameter:

use App\Models\Post;

Route::get('/posts/{post}', function (Post $post) {
    // ...
});

Since we have bound all {post} parameters to the App\Models\Post model, an instance of that class will be injected into the route. So, for example, a request to posts/1 will inject the Post instance from the index which has an ID of 1.

If a matching model instance is not found in the index, a 404 HTTP response will be automatically generated.

Customizing The Resolution Logic

If you wish to define your own model binding resolution logic, you may use the Route::bind method. The closure you pass to the bind method will receive the value of the URI segment and should return the instance of the class that should be injected into the route. Again, this customization should take place in the boot method of your application's RouteServiceProvider:

use App\Models\Post;
use Illuminate\Support\Facades\Route;

/**
 * Define your route model bindings, pattern filters, etc.
 *
 * @return void
 */
public function boot(): void
{
    Route::bind('post', function (string $value): Post {
        return Post::where('title', $value)->firstOrFail();
    });

    // ...
}

Alternatively, you may override the resolveRouteBinding method on your Elasticsearch model. This method will receive the value of the URI segment and should return the instance of the class that should be injected into the route:

/**
 * Retrieve the model for a bound value.
 *
 * @param  mixed  $value
 * @param  string|null  $field
 * @return \Matchory\Elasticsearch\Model|null
 */
public function resolveRouteBinding($value, ?string $field = null): ?self
{
    return $this->where('name', $value)->firstOrFail();
}

If a route is utilizing implicit binding scoping, the resolveChildRouteBinding method will be used to resolve the child binding of the parent model:

/**
 * Retrieve the child model for a bound value.
 *
 * @param  string  $childType
 * @param  mixed  $value
 * @param  string|null  $field
 * @return \Matchory\Elasticsearch\Model|null
 */
public function resolveChildRouteBinding(string $childType, $value, ?string $field): ?self
{
    return parent::resolveChildRouteBinding($childType, $value, $field);
}

Usage as a query builder

You can use the ES facade to access the query builder directly, from anywhere in your application.

Creating a new index

ES::create('my_index');

# or 

ES::index('my_index')->create();

Creating index with custom options (optional)

use Matchory\Elasticsearch\Facades\ES;
use Matchory\Elasticsearch\Index;

ES::index('my_index')->create(function(Index $index) {
    $index->shards(5)->replicas(1)->mapping([
        'my_type' => [
            'properties' => [
                'first_name' => [
                    'type' => 'string',
                ],
                'age' => [
                    'type' => 'integer'
                ]
            ]
        ]
    ])
});

# or

ES::create('my_index', function(Index $index){

      $index->shards(5)->replicas(1)->mapping([
          'my_type' => [
              'properties' => [
                  'first_name' => [
                      'type' => 'string',
                  ],
                  'age' => [
                      'type' => 'integer'
                  ]
              ]
          ]
      ])
});

Dropping an index

ES::drop("my_index");

# or

ES::index("my_index")->drop();

Running queries

To run a query, start by (optionally) selecting the connection and index.

$documents = ES::connection("default")
                ->index("my_index")
                ->type("my_type")
                ->get();    # return a collection of results

You can shorten the above query to:

$documents = ES::type("my_type")->get();    # return a collection of results

Explicitly setting connection or index name in the query overrides configuration in config/es.php.

Getting documents by id

ES::type("my_type")->id(3)->first();

# or

ES::type("my_type")->_id(3)->first();

Sorting

ES::type("my_type")->orderBy("created_at", "desc")->get();

# Sorting with text search score

ES::type("my_type")->orderBy("_score")->get();

Limit and offset

ES::type("my_type")->take(10)->skip(5)->get();

Select only specific fields

ES::type("my_type")->select("title", "content")->take(10)->skip(5)->get();

Where clause

ES::type("my_type")->where("status", "published")->get();

# or

ES::type("my_type")->where("status", "=", "published")->get();

Where greater than

ES::type("my_type")->where("views", ">", 150)->get();

Where greater than or equal

ES::type("my_type")->where("views", ">=", 150)->get();

Where less than

ES::type("my_type")->where("views", "<", 150)->get();

Where less than or equal

ES::type("my_type")->where("views", "<=", 150)->get();

Where like

ES::type("my_type")->where("title", "like", "foo")->get();

Where field exists

ES::type("my_type")->where("hobbies", "exists", true)->get(); 

# or 

ES::type("my_type")->whereExists("hobbies", true)->get();

Where in clause

ES::type("my_type")->whereIn("id", [100, 150])->get();

Where between clause

ES::type("my_type")->whereBetween("id", 100, 150)->get();

# or 

ES::type("my_type")->whereBetween("id", [100, 150])->get();

Where not clause

ES::type("my_type")->whereNot("status", "published")->get(); 

# or

ES::type("my_type")->whereNot("status", "=", "published")->get();

Where not greater than

ES::type("my_type")->whereNot("views", ">", 150)->get();

Where not greater than or equal

ES::type("my_type")->whereNot("views", ">=", 150)->get();

Where not less than

ES::type("my_type")->whereNot("views", "<", 150)->get();

Where not less than or equal

ES::type("my_type")->whereNot("views", "<=", 150)->get();

Where not like

ES::type("my_type")->whereNot("title", "like", "foo")->get();

Where not field exists

ES::type("my_type")->whereNot("hobbies", "exists", true)->get(); 

# or

ES::type("my_type")->whereExists("hobbies", true)->get();

Where not in clause

ES::type("my_type")->whereNotIn("id", [100, 150])->get();

Where not between clause

ES::type("my_type")->whereNotBetween("id", 100, 150)->get();

# or

ES::type("my_type")->whereNotBetween("id", [100, 150])->get();

Search by a distance from a geo point

ES::type("my_type")->distance("location", ["lat" => -33.8688197, "lon" => 151.20929550000005], "10km")->get();

# or

ES::type("my_type")->distance("location", "-33.8688197,151.20929550000005", "10km")->get();

# or

ES::type("my_type")->distance("location", [151.20929550000005, -33.8688197], "10km")->get();  

Search using array queries

ES::type("my_type")->body([
    "query" => [
         "bool" => [
             "must" => [
                 [ "match" => [ "address" => "mill" ] ],
                 [ "match" => [ "address" => "lane" ] ]
             ]
         ]
     ]
])->get();

# Note that you can mix between query builder and array queries.
# The query builder will will be merged with the array query.

ES::type("my_type")->body([
    "_source" => ["content"]

    "query" => [
         "bool" => [
             "must" => [
                 [ "match" => [ "address" => "mill" ] ]
             ]
         ]
    ],

    "sort" => [
        "_score"
    ]

])->select("name")->orderBy("created_at", "desc")->take(10)->skip(5)->get();

# The result query will be
/*
Array
(
    [index] => my_index
    [type] => my_type
    [body] => Array
        (
            [_source] => Array
                (
                    [0] => content
                    [1] => name
                )
            [query] => Array
                (
                    [bool] => Array
                        (
                            [must] => Array
                                (
                                    [0] => Array
                                        (
                                            [match] => Array
                                                (
                                                    [address] => mill
                                                )
                                        )
                                )
                        )
                )
            [sort] => Array
                (
                    [0] => _score
                    [1] => Array
                        (
                            [created_at] => desc
                        )
                )
        )
    [from] => 5
    [size] => 10
    [client] => Array
        (
            [ignore] => Array
                (
                )
        )
)
*/

Search the entire document

ES::type("my_type")->search("hello")->get();

# search with Boost = 2

ES::type("my_type")->search("hello", 2)->get();

# search within specific fields with different weights

ES::type("my_type")->search("hello", function($search){
    $search->boost(2)->fields(["title" => 2, "content" => 1])
})->get();

Search with highlight fields

$doc = ES::type("my_type")->highlight("title")->search("hello")->first();

# Multiple fields Highlighting is allowed.

$doc = ES::type("my_type")->highlight("title", "content")->search("hello")->first();

# Return all highlights as array using $doc->getHighlights() method.

$doc->getHighlights();

# Also you can return only highlights of specific field.

$doc->getHighlights("title");

Return only first document

ES::type("my_type")->search("hello")->first();

Return only count

ES::type("my_type")->search("hello")->count();

Scan-and-Scroll queries

# These queries are suitable for large amount of data. 
# A scrolled search allows you to do an initial search and to keep pulling batches of results
# from Elasticsearch until there are no more results left.
# It’s a bit like a cursor in a traditional database

$documents = ES::type("my_type")->search("hello")
                 ->scroll("2m")
                 ->take(1000)
                 ->get();

# Response will contain a hashed code `scroll_id` will be used to get the next result by running

$documents = ES::type("my_type")->search("hello")
                 ->scroll("2m")
                 ->scrollID("DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAAFMFlJQOEtTdnJIUklhcU1FX2VqS0EwZncAAAAAAAABSxZSUDhLU3ZySFJJYXFNRV9laktBMGZ3AAAAAAAAAU4WUlA4S1N2ckhSSWFxTUVfZWpLQTBmdwAAAAAAAAFPFlJQOEtTdnJIUklhcU1FX2VqS0EwZncAAAAAAAABTRZSUDhLU3ZySFJJYXFNRV9laktBMGZ3")
                 ->get();

# And so on ...
# Note that you don't need to write the query parameters in every scroll. All you need the `scroll_id` and query scroll time.

# To clear `scroll_id` 

ES::type("my_type")->scrollID("DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAAFMFlJQOEtTdnJIUklhcU1FX2VqS0EwZncAAAAAAAABSxZSUDhLU3ZySFJJYXFNRV9laktBMGZ3AAAAAAAAAU4WUlA4S1N2ckhSSWFxTUVfZWpLQTBmdwAAAAAAAAFPFlJQOEtTdnJIUklhcU1FX2VqS0EwZncAAAAAAAABTRZSUDhLU3ZySFJJYXFNRV9laktBMGZ3")
        ->clear();

Paginate results with 5 documents per page

$documents = ES::type("my_type")->search("hello")->paginate(5);

# Getting pagination links

$documents->links();

# Bootstrap 4 pagination

$documents->links("bootstrap-4");

# Simple bootstrap 4 pagination

$documents->links("simple-bootstrap-4");

# Simple pagination

$documents->links("simple-default");

These are all pagination methods you may use:

$documents->count()
$documents->currentPage()
$documents->firstItem()
$documents->hasMorePages()
$documents->lastItem()
$documents->lastPage()
$documents->nextPageUrl()
$documents->perPage()
$documents->previousPageUrl()
$documents->total()
$documents->url($page)

Getting the query array without execution

ES::type("my_type")->search("hello")->where("views", ">", 150)->toArray();

Getting the original elasticsearch response

ES::type("my_type")->search("hello")->where("views", ">", 150)->response();

Ignoring bad HTTP response

ES::type("my_type")->ignore(404, 500)->id(5)->first();

Query Caching (Laravel & Lumen)

Package comes with a built-in caching layer based on laravel cache.

ES::type("my_type")->search("hello")->remember(10)->get();

# Specify a custom cache key
ES::type("my_type")->search("hello")->remember(10, "last_documents")->get();

# Caching using other available driver
ES::type("my_type")->search("hello")->cacheDriver("redis")->remember(10, "last_documents")->get();

# Caching with cache key prefix
ES::type("my_type")->search("hello")->cacheDriver("redis")->cachePrefix("docs")->remember(10, "last_documents")->get();

Executing elasticsearch raw queries

ES::raw()->search([
    "index" => "my_index",
    "type"  => "my_type",
    "body" => [
        "query" => [
            "bool" => [
                "must" => [
                    [ "match" => [ "address" => "mill" ] ],
                    [ "match" => [ "address" => "lane" ] ]
                ]
            ]
        ]
    ]
]);

Insert a new document

ES::type("my_type")->id(3)->insert([
    "title" => "Test document",
    "content" => "Sample content"
]);

# A new document will be inserted with _id = 3.
# [id is optional] if not specified, a unique hash key will be generated.

Bulk insert a multiple of documents at once.

# Main query
ES::index("my_index")->type("my_type")->bulk(function ($bulk){

    # Sub queries
    $bulk->index("my_index_1")->type("my_type_1")->id(10)->insert(["title" => "Test document 1","content" => "Sample content 1"]);
    $bulk->index("my_index_2")->id(11)->insert(["title" => "Test document 2","content" => "Sample content 2"]);
    $bulk->id(12)->insert(["title" => "Test document 3", "content" => "Sample content 3"]);

});

# Notes from the above query:

# As index and type names are required for insertion, Index and type names are extendable. This means that: 

# If index() is not specified in subquery:
# -- The builder will get index name from the main query.
# -- if index is not specified in main query, the builder will get index name from configuration file.

# And

# If type() is not specified in subquery:
# -- The builder will get type name from the main query.
# you can use old bulk code style using multidimensional array of [id => data] pairs

ES::type("my_type")->bulk([

    10 => [
        "title" => "Test document 1",
        "content" => "Sample content 1"
    ],

    11 => [
        "title" => "Test document 2",
        "content" => "Sample content 2"
    ]

]);

# The two given documents will be inserted with its associated ids

Update an existing document

ES::type("my_type")->id(3)->update([
   "title" => "Test document",
   "content" => "sample content"
]);

# Document has _id = 3 will be updated.

# [id is required]
# Bulk update

ES::type("my_type")->bulk(function ($bulk){
    $bulk->id(10)->update(["title" => "Test document 1","content" => "Sample content 1"]);
    $bulk->id(11)->update(["title" => "Test document 2","content" => "Sample content 2"]);
});

Incrementing field

ES::type("my_type")->id(3)->increment("views");

# Document has _id = 3 will be incremented by 1.

ES::type("my_type")->id(3)->increment("views", 3);

# Document has _id = 3 will be incremented by 3.

# [id is required]

Decrementing field

ES::type("my_type")->id(3)->decrement("views");

# Document has _id = 3 will be decremented by 1.

ES::type("my_type")->id(3)->decrement("views", 3);

# Document has _id = 3 will be decremented by 3.

# [id is required]

Update using script

# increment field by script
ES::type("my_type")->id(3)->script(
    "ctx._source.$field += params.count",
    ["count" => 1]
);

# add php tag to tags array list
ES::type("my_type")->id(3)->script(
    "ctx._source.tags.add(params.tag)",
    ["tag" => "php"]
);

# delete the doc if the tags field contain mongodb, otherwise it does nothing (noop)
ES::type("my_type")->id(3)->script(
    "if (ctx._source.tags.contains(params.tag)) { ctx.op = 'delete' } else { ctx.op = 'none' }",
    ["tag" => "mongodb"]
);

Delete a document

ES::type("my_type")->id(3)->delete();

# Document has _id = 3 will be deleted.
# [id is required]
# Bulk delete
ES::type("my_type")->bulk(function ($bulk){
    $bulk->id(10)->delete();
    $bulk->id(11)->delete();
});

Releases

See the release page.

Authors

Basem Khirat - basemkhirat@gmail.com - @basemkhirat
Moritz Friedrich - moritz@matchory.com

Bugs, Suggestions and Contributions

Thanks to everyone who has contributed to the original project and everyone else who has contributed to this fork!
Please use Github for reporting bugs, and making comments or suggestions.

If you're interested in helping out, the most pressing issues would be modernizing the query builder to provide better support for Elasticsearch features as well as completing the test suite!

License

MIT

Have a happy searching..