imdhemy/berrylastic
Berrylastic is a Laravel/PHP package that simplifies integrating Elasticsearch into your applications, with a fluent query builder, model indexing, and configurable search support for building fast, scalable search experiences. It keeps search logic clean and idiomatic.
This is a v1.0.0-alpha release of an Eloquent-backed search package: models are indexed automatically, and you query them with match, multi-match, and match-phrase. Treat it as pre-stable — pin it explicitly, because Composer will not resolve an alpha by default:
composer require <vendor>/<package>:"^1.0@alpha"
Where to look first, in order:
config/<package>.php after php artisan vendor:publish — host/credentials, index prefix, and (if present) the queue setting for auto-indexing.src/ for the model trait (it holds the lifecycle hooks) and the query builder class (it holds the chainable match/multiMatch/matchPhrase methods and their argument order).First use case: make one existing model searchable end-to-end.
use Vendor\Package\Concerns\Searchable; // name from the README
class Article extends Model
{
use Searchable;
public function searchableAs(): string { return 'articles'; }
public function toSearchableArray(): array
{
return ['id' => $this->id, 'title' => $this->title, 'body' => $this->body];
}
}
Then backfill and query:
Article::query()->chunkById(500, fn ($rows) => /* on-demand index each row */);
Article::search() // exact method names per README
->match('title', 'laravel')
->get();
Confirm the index exists and the mapping is what you expect before backfilling a real table — a bad dynamic mapping means a reindex later.
Let the trait do writes, but keep it off the request path. Auto-indexing and auto-updating are wired to created/updated/deleted. For anything user-facing, route those through a queue (ShouldQueue listeners or the package's queued option if it ships one) so a slow search cluster can't fail a form submission.
Split the two write paths deliberately. Use auto-indexing for normal CRUD; use on-demand indexing for backfills, imports, seeders, and replaying after a mapping change. Wrap backfills in chunkById + a queued job per chunk so a failure is retryable and memory stays flat.
Hide the query DSL behind an action or repository. Controllers shouldn't know about boost factors or multi-match types:
final class SearchArticles
{
public function __invoke(string $term, int $page = 1): LengthAwarePaginator
{
return Article::search()
->multiMatch($term, ['title^3', 'body']) // verify signature in README
->paginate(20, 'page', $page);
}
}
That indirection is what makes an alpha survivable — when the fluent API shifts in beta, you edit one class.
Pick the query type by intent, not by habit.
match — one field, relevance-ranked; the default choice.multi-match — one term across several fields; boost the fields that matter (title^3), and check which type the package defaults to (best_fields vs most_fields vs cross_fields behave very differently when the same term appears in multiple fields).match-phrase — exact adjacency, so use it for titles/names/quoted user input, not for free-text search, or you'll get zero results for perfectly reasonable queries.Denormalize what you search. The index is a read model: flatten relationships into toSearchableArray() rather than trying to join at query time. If a related model changes, you own re-indexing the parent (on-demand update from the child's observer, queued).
Test without a cluster, verify with one. Stub/disable indexing in feature tests so the suite isn't network-bound, then keep a small integration suite against a real index for the query layer. Every integration test needs a clean index and a refresh before asserting — otherwise you're testing timing, not behavior.
Per-tenant indexes (articles_tenant_{id}) are straightforward with a dynamic searchableAs(); just remember the index has to be created/mapped per tenant before first write.
toSearchableArray() semantics can change. Don't build a public API directly on the package's fluent builder.Article::where(...)->update([...]), insert(), and raw SQL all skip updated — so auto-updating silently misses them. Any mass update needs an explicit on-demand reindex afterwards..keyword subfield) — fine for match, but match-phrase and sorting need the analyzer and field shape to be right. Set mappings explicitly before the first index, or you'll be reindexing into a fresh index and swapping aliases.deleted; with SoftDeletes, a soft delete fires updated, not deleted — filter deleted_at in the query or exclude trashed records from toSearchableArray().from/size pagination degrades on deep pages; use the package's cursor/search_after path if it exposes one. Cap size explicitly — an unbounded get() is a memory incident waiting to happen.Model::factory()->create() triggers indexing. Disable it globally in the test bootstrap unless the test is specifically about indexing.How can I help you explore Laravel packages today?