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

Searchable Laravel Package

wkjagt/searchable

wkjagt/searchable adds lightweight full-text search to Laravel Eloquent models. Mark fields as searchable and query across them with a simple, clean API, making it easy to build search results without pulling in a heavy external search engine.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Verify it will actually install before you write any code. With 10 stars, zero dependents, and an ElasticSearch integration, the version constraints are the whole ballgame. Look at the repo's composer.json first:

curl -s https://raw.githubusercontent.com/wkjagt/laravel-searchable/master/composer.json

Check the illuminate/* and ElasticSearch client constraints against your app's Laravel version. If it pins illuminate/support: 4.x or ~5.0, it will not resolve on a modern app without --ignore-platform-reqs hacks that you should not ship. Treat a failed composer require as the answer to the assessment.

2. Read the trait, not the README. The README is a decade old and likely shows an API that has drifted. The public surface of a package like this lives in one or two files:

vendor/wkjagt/searchable/src/

You are looking for three things: the trait name (e.g. Wkjagt\Searchable\Searchable), the property or method the trait reads to decide which columns get indexed (commonly $searchable or a searchable() method), and the entry point for querying (commonly a search() scope or a static Model::search($term)).

3. Wire up the connection. There is almost certainly a config file to publish:

php artisan vendor:publish --provider="Wkjagt\Searchable\SearchableServiceProvider"

You need a host, a port, and an index name. Point it at a locally running ElasticSearch that matches the client version the package depends on — not the version you happen to have. Mismatch here is the #1 source of confusing failures.

4. First real use case: search an existing table. Install the trait on a model and confirm a round trip before adding a second model.

use Wkjagt\Searchable\Searchable;

class Post extends Model
{
    use Searchable;

    protected $searchable = ['title', 'body'];
}

Then backfill the index (the package likely indexes on save, so existing rows are invisible until you re-save them):

Post::query()->chunk(200, fn ($posts) => $posts->each->searchable());

Confirm the documents exist in ElasticSearch, not just in your app, then try the query path. If you can't see the document with a curl against the index, no amount of PHP debugging will help.

Implementation Patterns

Model events are the indexing path. These packages hook saved and deleted on the model to push documents to ES. That makes the happy path trivially good and the unhappy path surprisingly bad — see Gotchas. The pattern that survives production:

class Post extends Model
{
    use Searchable;

    protected $searchable = ['title', 'body', 'author_name'];
}

Keep $searchable to denormalized, human-readable strings. Compute derived fields into real columns on save (author_name) rather than trying to make the package understand relations.

Separate "index the model" from "respond to the request." Wrap the trait's indexing call behind your own method so you can control failure and queueing:

protected static function booted()
{
    static::saved(fn ($model) => dispatch(new IndexModel($model))->onQueue('search'));
    static::deleted(fn ($model) => dispatch(new RemoveFromIndex($model))->onQueue('search'));
}

If you disable the package's automatic hooks (usually a static property or a config flag — grep the trait for boot), you get retries, backoff, and a dead-letter path for free. Search indexing is a side effect; it should never be able to 500 a "save post" request.

Backfill as a first-class command. You will reindex more often than you expect: new fields added to $searchable, analyzer changes, ES upgrades. Write it once:

class ReindexPosts extends Command
{
    protected $signature = 'search:reindex-posts {--fresh : Drop and recreate the index}';

    public function handle()
    {
        if ($this->option('fresh')) {
            // drop index + recreate with mapping, if the package exposes it
        }

        Post::query()->chunkById(200, function ($posts) {
            foreach ($posts as $post) {
                $post->searchable(); // whatever the trait's index method is named
            }
        });
    }
}

Use chunkById, not chunk — you'll be modifying updated_at on models as you touch them if the trait re-saves, and offset pagination will skip rows.

Compose search with database filters carefully. ES returns IDs; getting models back is a second query. The pattern:

$ids = Post::search($request->q)->take(500)->pluck('id');

$posts = Post::whereIn('id', $ids)
    ->where('status', 'published')   // filters ES doesn't know about
    ->paginate(20);

Cap the ES candidate set (500–1000). Without a cap, a common term returns 50k IDs and your whereIn becomes the slowest query in the app. For any serious volume, put status into the indexed document so ES does the filtering and the ID list stays small.

Testing. Never let the test suite write to a shared index. Use a dedicated index name per test run (env var), and truncate between tests:

protected function setUp(): void
{
    parent::setUp();
    config(['searchable.index' => 'posts_test_' . getenv('TEST_TOKEN')]);
    // delete + recreate index
}

protected function tearDown(): void
{
    // delete index
    parent::tearDown();
}

If the package has no clean "delete index" helper, call ES directly with the HTTP client it already depends on. Integration tests that hit a real ES are worth it here — mocking ES tests the mock, not the analyzer behavior that will actually bite you.

Gotchas and Tips

ElasticSearch version drift is the existential risk. This package predates Laravel Scout and almost certainly targets ES 1.x/2.x semantics. ES 6 removed mapping types, ES 7 removed them entirely, ES 8 defaults to TLS and auth. If you're on a modern ES, the package's index-creation and query DSL will likely be rejected outright. Check the client version pinned in composer.json and match your server to it, or budget for a rewrite. For anything greenfield, Laravel Scout with the ElasticSearch driver is the answer; this package is only defensible if you're maintaining a legacy app that already uses it.

Automatic indexing means a silent outage is a silent data divergence. If ES is down when a user saves a post, what happens? Either the save throws (bad — users can't publish because search is down) or the write is swallowed (worse — the index quietly rots and nobody notices for a month). Grep the trait for its save hook and find out which. Whichever it is, put indexing behind a queue with retries so you get a failed_jobs entry instead of mystery.

Bulk and mass updates bypass model events entirely. This is the classic trap:

Post::where('status', 'draft')->update(['status' => 'published']); // no events, no reindex

Same for DB::table(...), raw imports, and seeders. Any mutation that doesn't go through a hydrated model instance leaves stale documents. Maintain an explicit reindex step in your deploy or data-migration runbook.

Newly indexed documents are not immediately searchable. ES refreshes on an interval (~1s default). Any code that writes then immediately searches — "create post, redirect to search results" — will appear broken. Either pass refresh=wait_for on the index call if the package exposes it, or accept the delay and don't write tests that assume immediate visibility.

No soft-delete awareness, most likely. Verify that a soft-deleted model is actually removed from the index. If the trait only hooks deleted and not restored, deletes may not propagate, and restored models may be missing. Handle both explicitly in your booted().

Analyzer and case sensitivity surprises. Searches on a text field with the standard analyzer are lowercased and tokenized — "iPhone" becomes iphone, and a term query for iPhone matches nothing. If results look arbitrarily empty, test the raw ES query with curl before touching PHP:

curl -s 'localhost:9200/posts/_search?pretty' -H 'Content-Type: application/json' \
  -d '{"query":{"match":{"title":"iphone"}}}'

That single command separates "package bug" from "my mapping is wrong."

Extension points to look for, in priority order: the index name (must be configurable, per-environment), the mapping/analyzer definition (you'll want at least lowercase + a synonym or edge-ngram for prefix search), the document-building method (override in the model to add computed fields), and the query builder hook (so you can add fuzziness, boosting, or filters without forking). If any of these four is hardcoded in the trait, expect to fork the package — and if you're forking it, seriously reconsider whether Scout is the cheaper option.

Debugging order of operations: (1) Is the document in ES? curl .../index/_doc/ID. (2) Does a raw ES query return it? (3) Does the package's query return it? Don't skip to step 3. Ninety percent of "search is broken" reports are step 1 — the doc was never indexed, usually because of a mass update or an ES outage at save time.

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.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics