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

Meiliscout Laravel Package

amphibee/meiliscout

MeiliScout is a Laravel Scout driver that connects Eloquent models to Meilisearch for fast, typo-tolerant full-text search. It supports indexing, filtering, sorting, pagination, and familiar Scout APIs, making it easy to add powerful search with minimal configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up the engine

composer require amphibee/meiliscout
php artisan vendor:publish --tag=meiliscout-config   # verify the tag in the provider

Set the driver and credentials in .env:

SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey

The package registers a Scout engine, so SCOUT_DRIVER must point at whatever alias its service provider registers (meilisearch / meiliscout — check config/scout.php after publishing, and confirm the provider isn't deferring registration in a way that races with Scout's own ScoutServiceProvider).

Where to look first

  1. config/meiliscout.php — this is the real API surface of the package: index settings defaults, batch sizes, queue connection, filterable/sortable attribute conventions, thumbnail size, and any suggestion/key settings.
  2. The Searchable trait / engine class under src/ — read search(), update(), and map() before you trust the README; with a package this small the source is the documentation.
  3. Meilisearch's own dashboard at http://127.0.0.1:7700 — every settings change the package makes is visible there under /indexes/<uid>/settings, which is the fastest way to diagnose "why doesn't my filter work".

Note on 2.5.0 before you start: the posts index moves to schema 6 (adds thumbnail). The change is additive — queries keep working against a schema 5 index — but the new field stays empty until you run a full indexation. Treat that as part of the upgrade, not an afterthought: scout:flush "App\Models\Post" && scout:import "App\Models\Post" (or the package's own status/reindex command if it ships one). If the package exposes a status command (wp meiliscout status in the release notes' vocabulary — grep src/ for the Artisan equivalent), run it and let it tell you the live schema version.

First use case

Add the trait and declare what you want to search and filter on:

use Laravel\Scout\Searchable;

class Product extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return [
            'id'       => $this->id,
            'name'     => $this->name,
            'brand'    => $this->brand->name,
            'price'    => (float) $this->price,
            'in_stock' => (bool) $this->stock > 0,
        ];
    }
}

Then push once and search:

php artisan scout:import "App\Models\Product"
Product::search('wool coat')
    ->where('in_stock', true)
    ->where('price', 200, '<=')
    ->orderBy('price', 'asc')
    ->paginate(20);

If that returns results, the engine is wired correctly and you can start layering the package's extras (multi-search, settings sync, custom ranking, and — in 2.5.0 — suggestion fields and image-bearing hits) on top.

Implementation Patterns

Declare index settings on the model, not in the dashboard

Hand-editing filterable attributes in the Meilisearch UI is a trap: the next deploy that pushes settings will silently wipe them, and your filters start throwing Attribute 'brand' is not filterable. Keep settings declarative and versioned:

public function searchableSettings(): array
{
    return [
        'filterableAttributes' => ['brand', 'in_stock', 'price', 'categories'],
        'sortableAttributes'   => ['price', 'created_at'],
        'searchableAttributes' => ['name', 'brand', 'description'],
        'rankingRules'         => ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
        'displayedAttributes'  => ['id', 'name', 'brand', 'price', 'in_stock', 'thumbnail'],
    ];
}

The exact method name is package-specific — grep for filterableAttributes in src/ and follow whichever hook it exposes. Then make settings sync part of your deploy script (scout:sync-index-settings or the package's equivalent), never a manual step.

Add thumbnail to the payload if your UI shows images

2.5.0 makes featured images a first-class, public index field: thumbnail with url, width, height, alt (or null). The payoff is real — suggestion cards and result lists can render an image straight from the hit, with no extra query per result. In Laravel you either let the package build it (check the thumbnail_size / per-record override hooks it exposes) or shape it yourself:

'thumbnail' => $this->featuredImage ? [
    'url'    => $this->featuredImage->url('thumb'),
    'width'  => 400,
    'height' => 300,
    'alt'    => $this->featuredImage->alt,
] : null,

Two things to be deliberate about: this field is public by design (browsers read it), so don't put signed or private URLs there; and it's additive, so existing documents simply have no thumbnail until you reindex.

Separate "searching" from "hydrating"

Scout results are lightweight IDs by default. Two patterns cover almost everything:

// Fast: you only need IDs (e.g. for a "load more" endpoint)
$ids = Product::search($q)->take(100)->keys();

// Rich: you need full models with relations
$products = Product::search($q)
    ->where('in_stock', true)
    ->query(fn ($builder) => $builder->with(['brand', 'images']))
    ->paginate(24);

->query() is where you re-apply Eloquent concerns (eager loading, global scopes, tenant constraints) that the search index knows nothing about. Any authorization or tenant isolation must happen there or as a filter — never assume the index respects your global scopes.

Use filters for facets, not multiple searches

A storefront page typically needs "results + brand counts + price range" simultaneously. One search with facet distribution beats three round-trips:

$results = Product::search($q)
    ->where('in_stock', true)
    ->options(['facets' => ['brand', 'categories']])
    ->raw();

$brands = $results['facetDistribution']['brand'] ?? [];
$hits   = Product::search($q)->where('in_stock', true)->paginate(24);

Calling ->raw() for the facet counts and paginating separately keeps the response shape clean while still issuing only two queries. If the package ships a multi-search / federated helper, this is exactly the case to use it for — one HTTP request across products and articles.

Suggestion fields: the backend half is yours, the front-end half may not be

2.5.0 introduces a whole suggestion subsystem: declared fields with groups of suggestions, per-field tenant tokens signed by a dedicated search-only Meilisearch key, a token-renewal route, template rendering, and a rest transport for browsers that can't reach Meilisearch directly. Read the release notes carefully against src/ — much of the surface (meiliscout_register_suggest(), meiliscout_suggest(), blocks, wp meiliscout check-suggest, Twig/theme parts) is WordPress-shaped, so in a Laravel app expect classes/services rather than global functions. The parts that port cleanly and are worth copying:

  • Declare the field server-side and validate it eagerly. The release notes emphasise that every error is reported on registration. Find the validating registration hook, call it from a service provider, and let a bad config fail loudly at boot rather than as an empty dropdown in production.
  • Mint tenant tokens with a scoped, search-only key. Not your master key, and not a key that can write. Scope it to the indexes the field actually reads (posts + taxonomies in the WP case; whatever your groups reference). Expose renewal behind your own auth middleware — the endpoint renews a token, it doesn't grant one.
  • Restrict what the public key can see. The taxonomies index only returns a term's own fields while browsers query it (meiliscout/term/displayed_attributes). If you expose a taxonomy-like index publicly, set displayedAttributes explicitly instead of relying on the default "everything".
  • Use a Blade view for template. The template can be HTML, a Blade view, a Twig template, a callable, or a JS function. In Laravel, Blade + a $hit binder is the ergonomic path — one view per group, rendered server-side or handed to JS as a partial.

Keep the index fed through events and queues

The default observer fires on every save(), which is fine for admin CRUD and disastrous for a 50k-row import. Standard setup:

// config/scout.php
'queue' => true,
class Product extends Model
{
    use Searchable;

    public function searchableAs(): string
    {
        return 'products_'.app()->environment();
    }

    public function shouldBeSearchable(): bool
    {
        return $this->status === 'published';
    }
}

shouldBeSearchable() is the cheapest way to keep drafts, soft-deleted rows, and tenant-private records out of the index entirely — much more reliable than filtering them out at query time.

Treat search as an adapter behind your own service

Wrap it so controllers aren't littered with engine-specific calls:

class ProductSearch
{
    public function handle(SearchRequest $request): LengthAwarePaginator
    {
        return Product::search($request->string('q'))
            ->when($request->brand, fn ($s, $b) => $s->where('brand', $b))
            ->when($request->min, fn ($s, $m) => $s->where('price', $m, '>='))
            ->query(fn ($q) => $q->with('brand'))
            ->paginate(24);
    }
}

When the package's API shifts (likely, at this maturity — 2.5.0 alone adds a schema bump and a new subsystem), you edit one class instead of twenty controllers.

Gotchas and Tips

Additive schema changes are still reindexes. The 2.5.0 notes say queries keep working against a schema 5 index — true, and that's exactly the problem. Nothing breaks; images and the new field are just silently absent until a full indexation runs. If you have a status command, put it in CI after deploy; otherwise assert on a known-populated field in a smoke test.

Filterable attributes are a hard requirement, not a hint. Meilisearch does not fall back to scanning. If where('brand', 'Nike') throws, the attribute is missing from filterableAttributes on the live index — check the dashboard, then check that your settings sync actually ran after the last deploy.

Changing settings usually forces a reindex. Adding a filterable attribute, adding a thumbnail, or reordering rankingRules can invalidate the existing index. Budget for scout:flush + scout:import on any settings change, and never make one five minutes before a demo.

Tenant tokens are only as tight as the key that signs them. A per-field, search-only key scoped to specific indexes is the whole security model: the token travels to the browser, so the key behind it must be able to read and nothing else, and only those indexes. Don't reuse MEILISEARCH_KEY (admin) for token signing, and replace the key deliberately (2.5.0 exposes a "replace" action for a reason) rather than reusing one across environments.

displayedAttributes is a privacy boundary, not a formatting preference. The meiliscout/term/displayed_attributes behaviour in 2.5.0 exists because public browsers query that index. Any field you widen displayedAttributes for is a field served to anonymous clients — audit before you widen it, and prefer explicit allow-lists over defaults.

->get() on search returns unhydrated models in some Scout versions. Relations come back as null and $model->brand->name explodes. Always pair search with ->query(fn ($q) => $q->with(...)) when the view touches relations, and add a test that asserts the relation is loaded.

Numeric strings break range filters. '19.99' stored as a string won't match where('price', 20, '<=') reliably. Cast in toSearchableArray() — (float), (bool), (int) — rather than letting Eloquent's attribute types leak through.

Index naming collisions across environments. Use searchableAs() to suffix with the environment. Otherwise a local scout:import against a shared Meilisearch instance destroys staging results, and everyone spends an afternoon debugging "search is broken".

Queued indexing hides failures. With 'queue' => true, a failing job is retried and then dropped into failed_jobs — your index silently drifts. Watch failed_jobs after imports, and run a periodic reconciliation (scout:import nightly for critical models) if the index must be trustworthy.

Debugging checklist, in order: (1) is the document actually in the index — query /indexes/products/documents?limit=1; (2) are the settings right — /indexes/products/settings; (3) is the query what you think — dump Product::search($q)->options([...])->raw(); (4) is hydration the problem — call ->keys() and compare to ->get(); (5) for suggestions, re-run the field validation and check the token's key scope before you suspect the front-end.

Release notes are drifting towards another ecosystem. 2.5.0's vocabulary — wp meiliscout, blocks, themes, Twig, taxonomies — is WordPress, while your integration is Laravel/Scout. That doesn't make the release irrelevant (schema 6, thumbnail, key scoping, and the rest transport all bite from Laravel), but it does mean you must verify every claim against src/ before planning around it, and that some documented features may simply not have a Laravel entry point.

Primary keys and UUIDs. Non-integer keys need getScoutKey()/getScoutKeyName() set correctly and a matching primaryKey in the index settings. Import will appear to succeed and search will return nothing — a classic silent failure with this class of package.

Small package, small blast radius — pin it. One star and no dependents means no crowd-sourced bug reports, and a release that adds a schema version plus an entire suggestion subsystem is a lot of surface for one maintainer. Use an exact version constraint ("amphibee/meiliscout": "2.5.0"), read the diff before composer update, and be prepared to fork. The extension point you'll want first is a custom engine class registered in config/scout.php, which lets you override one method without vendoring the whole package.

Cross-check against plain Scout. Before adopting a package-specific feature — especially a token/template/suggestion stack — ask whether stock laravel/scout + Meilisearch already does it. Meilisearch's own tenant-token API and the JS search client are well documented; fewer layers is a genuine feature when the layer has one maintainer.

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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core