Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Elasticsearch Laravel Package

mailerlite/laravel-elasticsearch

A Laravel wrapper for Elasticsearch that makes indexing and searching Eloquent models simple, with a fluent query builder, customizable index mapping, and support for multiple Elasticsearch connections.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up

composer require mailerlite/laravel-elasticsearch
php artisan vendor:publish --tag=laravel-elasticsearch   # config/elasticsearch.php

The package is a thin Laravel manager over the official elasticsearch/elasticsearch PHP client. The config array is essentially the client's connection config (hosts, retries, auth, TLS, handler) plus connection-name support, so anything you'd normally pass to ClientBuilder lives there.

// config/elasticsearch.php (shape, not exhaustive)
return [
    'hosts'   => [env('ELASTICSEARCH_HOST', 'localhost:9200')],
    'retries' => 2,
    // basic_auth / api_key / ssl / caBundle go here
];
use MailerLite\LaravelElasticsearch\Facades\Elasticsearch;

Elasticsearch::indices()->create([
    'index' => 'posts',
    'body'  => ['mappings' => ['properties' => [
        'title' => ['type' => 'text'],
        'tags'  => ['type' => 'keyword'],
    ]]],
]);

Elasticsearch::index('posts')->create(['title' => 'Skiing in the Alps'], 1);

Version 12.0.0 is the "Elasticsearch 8" release. The single substantive change since 11.x is ES8 support (#173); everything else in the changelog is Renovate dependency/CI churn. Practically: pin the package major to your cluster major (11.x → ES7, 12.x → ES8). If your production cluster is still 7.x, do not take this upgrade as routine maintenance.

Where to look first

  1. config/elasticsearch.php — the only config surface; it is the client config.
  2. src/ — the manager + builder wrappers. Coverage of endpoints is deliberately partial; anything not wrapped is a raw request array or a proxy call to the underlying client. Read it once so you know where the wrapper ends and raw DSL begins.
  3. Your cluster's GET / output — confirm version.number is 8.x before installing.

First real use case: an index you can rebuild from scratch — php artisan es:create-index, an indexer command, then Elasticsearch::index('posts')->search([...]) from a controller. Get that loop working before touching models.


Implementation Patterns

Index lifecycle, not ad-hoc index creation. Treat mappings as code in database/elasticsearch/ or a dedicated artisan command, and always write through an alias (posts → posts_20240612). The only sane way to change a mapping is index → reindex → atomic alias swap, and that requires the alias from day one.

Index from model events via jobs, never synchronously.

class PostObserver
{
    public function saved(Post $post): void
    {
        IndexPost::dispatch($post)->onQueue('search');
    }

    public function deleted(Post $post): void
    {
        DeletePostFromIndex::dispatch($post->id)->onQueue('search');
    }
}

Use version_type: external (or a monotonically increasing updated_at version) so out-of-order queue workers can't resurrect stale documents.

Bulk for anything batchy. Single-document calls are one HTTP round-trip each; a backfill of 100k rows will crawl. Build NDJSON action pairs and send them in chunks of ~500–1000:

Elasticsearch::bulk(['body' => $actions]); // ['index' => ['_index' => 'posts', '_id' => $id]], $doc, ...

Wrap it in a job that retries only failed items (see Gotchas).

Two ways to query, use both deliberately.

// 1. Wrapper builder — ergonomic for simple cases
Elasticsearch::index('posts')->search([...]);

// 2. Raw client request array — for aggregations, PIT, search_after, etc.
Elasticsearch::search(['index' => 'posts', 'body' => $dsl]);

Since the request body is just the DSL, don't fight the wrapper: build query bodies with plain arrays (or a small SearchQuery value object), and let the package handle transport/config/auth. That keeps your queries testable as pure PHP.

Response shape. The client returns Elastic\Elasticsearch\Response\Elasticsearch, which is array-accessible and has asArray() / asObject(). Normalize once at your repository boundary ($hits = $response['hits']['hits']) so the rest of the app never sees transport objects — this is also what makes swapping to an in-memory fake in tests trivial.

Testing strategy: pick one, be consistent.

  • Unit/logic tests: Elasticsearch::shouldReceive('search')->andReturn([...]) on the facade, asserting the DSL you send. Fast, but asserts nothing about validity.
  • Feature tests: a real container (Testcontainers/GitHub service, or Sail), with index creation in setUp. Pass 'refresh' => true (or ?refresh=wait_for) on writes or your assertions will flake on the 1s refresh interval.

Multi-connection for read/write separation. Connections are named in config; use the default for writes and a read connection pointed at coordinating nodes or a read replica for user-facing search. Also name indexes per environment (posts_production, posts_test) — sharing an index across environments is a data-loss bug waiting to happen.

Degrade gracefully. Search is a nice-to-have in most apps and a single point of failure in some. Wrap user-facing search in a try/catch for connection exceptions and fall back to a database query; log and alert on the fallback rate. Use a short retries and a per-request timeout so a sick cluster doesn't exhaust PHP-FPM workers.


Gotchas and Tips

ES8 enables security by default. Fresh ES8 nodes ship with TLS + authentication on. If you're used to http://localhost:9200 just working, it won't. Either configure ssl/caBundle + basic_auth (or api_key) in config/elasticsearch.php, or explicitly disable security in dev only (xpack.security.enabled=false). Self-signed certs in CI are the #1 cause of "it worked on my machine."

Mapping types are gone. ES8 removed _type. Any leftover 'type' => 'doc' in request bodies, any ->type('...') call copied from an ES6-era tutorial, and any include_type_name in a mapping will fail. This is the main mechanical break when moving code from 11.x-era habits. Since v12 dropped ES7 support, you cannot mix the two — you need a cluster upgrade and an app upgrade planned together.

Bulk requests succeed even when they fail. HTTP 200 with 'errors' => true is the normal partial-failure case. Always:

$response = Elasticsearch::bulk(['body' => $actions]);
if ($response['errors']) {
    foreach ($response['items'] as $item) {
        $op = reset($item); // index|create|update|delete
        if (($op['status'] ?? 200) >= 300) { /* retry or dead-letter */ }
    }
}

Silently ignoring errors is how indexes drift out of sync with the database.

Refresh semantics. Writes are visible to search after the refresh interval (1s by default), not immediately. Never rely on read-after-write in application code; pass refresh explicitly in tests, and prefer wait_for over true in production if you must have it. Note that refresh=true on every write is a real performance cost at volume.

The 10,000-result window. from/size paging breaks past index.max_result_window. For deep pagination or exports, use search_after with a sort on a tiebreaker field, plus a point-in-time (PIT) for consistency. Don't just raise max_result_window — it's a memory footgun.

Config caching. env() only works inside config/*.php. After changing ES credentials/hosts, php artisan config:clear; a stale bootstrap/cache/config.php is a very common "why is it hitting the wrong cluster" mystery.

Reindexing is not optional. Mapping changes to existing fields (e.g. text → keyword) require a new index and a full reindex. Build the alias-swap script early — the day you need it, you won't have time to write it. Keep an artisan command that can rebuild any index from the database as the source of truth.

Extension points. The manager's __call proxies to the underlying client for endpoints the package doesn't wrap — that's your escape hatch for _cat, _cluster, _snapshot, indices()->putAlias, etc. Bind your own thin repository/search-service class around the facade rather than scattering facade calls; it keeps the surface you must audit on the next major bump small.

Octane/queue workers. Long-lived workers hold the client (and its connection pool) across requests/jobs. That's usually fine, but if you rotate credentials or fail over hosts, restart workers; don't assume a config change takes effect in a running daemon.

Upgrade checklist for 12.0.0. Confirm cluster is 8.x → check the transitive elasticsearch/elasticsearch constraint and PHP version bump against your CI matrix → grep for type( and _type → rebuild all indexes into the new cluster (an ES7→ES8 move is a reindex, not an in-place patch) → run the bulk-error and refresh assertions in your test suite before flipping traffic.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route