Full-text search integration for the Glueful Framework using Meilisearch.
The Meilisearch extension provides seamless integration between Glueful Framework and Meilisearch, an open-source, lightning-fast search engine. This extension enables models to be searchable with minimal configuration while providing advanced search features like typo tolerance, filtering, faceting, and geo-search.
- Searchable trait: Make any model searchable with a simple trait
- Automatic syncing: Keep search index in sync with database changes via model events
- Transaction-safe indexing: Indexing deferred until after database transactions commit
- Fluent query builder: Intuitive API for building complex search queries
- Filters and facets: Full support for Meilisearch filtering and faceted search
- Geo-search: Location-based search with radius and bounding box filters
- Pagination: Built-in pagination with metadata
- Queue support: Optional async indexing via queue workers
- Batch operations: Efficient bulk indexing with configurable batch sizes
- Index prefixing: Multi-tenant friendly with configurable index prefixes
- CLI commands: Index management and debugging tools
Install via Composer
composer require glueful/meilisearch
# Rebuild the extensions cache after adding new packages
php glueful extensions:cacheGlueful auto-discovers packages of type glueful-extension and boots their service providers.
Enable/disable in development (these commands edit config/extensions.php):
# Enable the extension (adds to config/extensions.php)
php glueful extensions:enable Meilisearch
# Disable the extension (comments out in config/extensions.php)
php glueful extensions:disable Meilisearch
# Preview changes without writing
php glueful extensions:enable Meilisearch --dry-run
# Create backup before editing
php glueful extensions:enable Meilisearch --backupIf you're working locally (without Composer), place the extension in extensions/meilisearch, ensure config/extensions.php has local_path pointing to extensions (non-prod).
Check status and details:
php glueful extensions:list
php glueful extensions:info Meilisearch- PHP 8.3 or higher
- Glueful Framework 1.27.0 or higher
- Meilisearch server (v1.6+ recommended)
- meilisearch/meilisearch-php ^1.6 (installed automatically)
The extension requires a running Meilisearch server. Choose one of the following installation methods:
Docker (Recommended)
docker run -d -p 7700:7700 \
-v $(pwd)/meili_data:/meili_data \
-e MEILI_MASTER_KEY='your-master-key' \
getmeili/meilisearch:v1.6Homebrew (macOS)
brew install meilisearch
meilisearch --master-key="your-master-key"Binary Download
Download the latest binary for your platform from the Meilisearch releases page or follow the official installation guide.
# Example for Linux
curl -L https://install.meilisearch.com | sh
./meilisearch --master-key="your-master-key"Meilisearch Cloud
For production, consider Meilisearch Cloud for a fully managed solution.
Set the following environment variables in your .env file:
# Meilisearch connection
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key
# Index prefix (optional, useful for multi-tenant or staging/production separation)
MEILISEARCH_PREFIX=myapp_
# Queue configuration (optional, for async indexing)
MEILISEARCH_QUEUE=false
MEILISEARCH_QUEUE_CONNECTION=redis
MEILISEARCH_QUEUE_NAME=search
# Batch configuration
MEILISEARCH_BATCH_SIZE=500
MEILISEARCH_BATCH_TIMEOUT=30
# Search defaults
MEILISEARCH_DEFAULT_LIMIT=20
MEILISEARCH_SOFT_DELETE=trueThe extension configuration is located at config/meilisearch.php:
<?php
return [
'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
'key' => env('MEILISEARCH_KEY', null),
'prefix' => env('MEILISEARCH_PREFIX', ''),
'queue' => [
'enabled' => (bool) env('MEILISEARCH_QUEUE', false),
'connection' => env('MEILISEARCH_QUEUE_CONNECTION', null),
'queue' => env('MEILISEARCH_QUEUE_NAME', 'search'),
],
'batch' => [
'size' => (int) env('MEILISEARCH_BATCH_SIZE', 500),
'timeout' => (int) env('MEILISEARCH_BATCH_TIMEOUT', 30),
],
'soft_delete' => (bool) env('MEILISEARCH_SOFT_DELETE', true),
'search' => [
'limit' => (int) env('MEILISEARCH_DEFAULT_LIMIT', 20),
'attributes_to_highlight' => ['*'],
'highlight_pre_tag' => '<em>',
'highlight_post_tag' => '</em>',
],
];Add the Searchable trait to any model. Implementing SearchableInterface is recommended for static analysis type checking:
<?php
namespace App\Models;
use Glueful\Database\ORM\Model;
use Glueful\Extensions\Meilisearch\Contracts\SearchableInterface;
use Glueful\Extensions\Meilisearch\Model\Searchable;
class Post extends Model implements SearchableInterface
{
use Searchable;
protected string $table = 'posts';
/**
* Customize the data indexed in Meilisearch.
*/
public function toSearchableArray(): array
{
return [
'id' => $this->uuid,
'title' => $this->title,
'body' => $this->body,
'author_name' => $this->author->name ?? null,
'tags' => $this->tags->pluck('name')->toArray(),
'category' => $this->category?->name,
'status' => $this->status,
'published_at' => $this->published_at?->timestamp,
];
}
/**
* Define filterable attributes.
*/
public function getSearchableFilterableAttributes(): array
{
return ['status', 'category', 'tags', 'author_name', 'published_at'];
}
/**
* Define sortable attributes.
*/
public function getSearchableSortableAttributes(): array
{
return ['published_at', 'title'];
}
/**
* Only index published posts.
*/
public function shouldBeSearchable(): bool
{
return $this->status === 'published';
}
}// $context is an ApplicationContext instance
// Simple search
$results = Post::search($context, 'laravel tutorial')->get();
// Access results
foreach ($results as $post) {
echo $post->title;
}
// Get raw hits without model hydration
$rawResults = Post::search($context, 'laravel')->raw();// Single filter
$results = Post::search($context, 'php')
->where('status', 'published')
->get();
// Multiple filters
$results = Post::search($context, 'api')
->where('status', 'published')
->where('published_at', '>=', strtotime('-30 days'))
->whereIn('category', ['tutorials', 'guides'])
->get();
// Using raw filter syntax
$results = Post::search($context, 'docker')
->filter('status = "published" AND category IN ["tutorials", "guides"]')
->get();$results = Post::search($context, 'api design')
->orderBy('published_at', 'desc')
->get();
// Multiple sort criteria
$results = Post::search($context, '')
->orderBy('category', 'asc')
->orderBy('published_at', 'desc')
->get();$results = Post::search($context, 'docker')
->where('status', 'published')
->paginate(page: 1, perPage: 15);
// Access pagination metadata
$meta = $results->paginationMeta();
// ['current_page' => 1, 'per_page' => 15, 'total' => 42, 'total_pages' => 3, 'has_more' => true]$results = Post::search($context, '')
->facets(['category', 'tags', 'author_name'])
->where('status', 'published')
->get();
// Access facet distribution
$categoryFacets = $results->facets('category');
// ['tutorials' => 45, 'guides' => 23, 'news' => 12]
// All facets
$allFacets = $results->facets();For location-based models, add geo data to your searchable array:
class Store extends Model implements SearchableInterface
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->uuid,
'name' => $this->name,
'_geo' => [
'lat' => (float) $this->latitude,
'lng' => (float) $this->longitude,
],
];
}
public function getSearchableFilterableAttributes(): array
{
return ['_geo', 'category'];
}
public function getSearchableSortableAttributes(): array
{
return ['_geo', 'name'];
}
}Search by location:
// Find stores within 5km of a point
$results = Store::search($context, 'coffee')
->whereGeoRadius(40.7128, -74.0060, 5000)
->get();
// Find within bounding box
$results = Store::search($context, '')
->whereGeoBoundingBox([45.0, -73.0], [40.0, -74.0])
->get();
// Sort by distance (nearest first)
$results = Store::search($context, 'coffee')
->orderByGeo(40.7128, -74.0060, 'asc')
->get();$results = Post::search($context, 'important topic')
->highlight(['title', 'body'])
->get();
// Access highlighted results in raw hits
$rawResults = Post::search($context, 'important')->highlight(['title'])->raw();
foreach ($rawResults['hits'] as $hit) {
echo $hit['_formatted']['title']; // Contains <em>important</em>
}// Index a single model
$post = Post::find($context, $uuid);
$post->searchableSync();
// Remove from index
$post->searchableRemove();
// Batch indexing via BatchIndexer
$indexer = app($context, BatchIndexer::class);
$posts = Post::query($context)->where('status', 'published')->get();
$indexer->indexMany($posts);use Glueful\Extensions\Meilisearch\Indexing\IndexManager;
$manager = app($context, IndexManager::class);
// Create index with settings
$manager->createIndex('posts');
// Update index settings
$manager->updateSettings('posts', [
'filterableAttributes' => ['status', 'category'],
'sortableAttributes' => ['published_at', 'title'],
'searchableAttributes' => ['title', 'body', 'tags'],
]);
// Sync settings from model
$manager->syncSettingsForModel(new Post([], $context));
// Get index statistics
$stats = $manager->getStats('posts');
// Delete all documents from index
$manager->flush('posts');
// Delete the index entirely
$manager->deleteIndex('posts');# Index all records for a model
php glueful search:index --model=App\\Models\\Post
# Index specific IDs
php glueful search:index --model=App\\Models\\Post --id=uuid1,uuid2,uuid3
# Fresh index (clear before indexing)
php glueful search:index --model=App\\Models\\Post --fresh# Show all indexes
php glueful search:status
# Show specific index stats
php glueful search:status posts
# Output as JSON
php glueful search:status --json# Sync settings from model to Meilisearch
php glueful search:sync --model=App\\Models\\Post
# Dry run (show settings without applying)
php glueful search:sync --model=App\\Models\\Post --dry-run# Flush specific index
php glueful search:flush posts
# Flush all indexes
php glueful search:flush --all
# Skip confirmation
php glueful search:flush posts --force# Search an index
php glueful search:search posts "search query"
# With filters
php glueful search:search posts "query" --filter="status = published"
# Limit results
php glueful search:search posts "query" --limit=5
# Raw JSON output
php glueful search:search posts "query" --rawAll endpoints are prefixed with /api/search and require authentication.
GET /api/search?index={index}&q={query}- Universal searchGET /api/search/{index}?q={query}- Search specific index
Query parameters:
q- Search query (optional, empty returns all)filter- Filter expressionfacets- Attributes for facet distributionsort- Sort criterialimit- Maximum results (default: 20)offset- Pagination offset
GET /api/search/admin/status- Get all index status (requires admin middleware)
The extension automatically defers indexing operations until after database transactions commit:
// Using db() helper with transaction()
db($context)->transaction(function () use ($context) {
$post = Post::create($context, [
'title' => 'New Post',
'body' => 'Content here...',
]);
// Indexing is deferred, not executed yet
});
// After commit, the post is indexed
// If transaction rolls back, nothing is indexed
try {
db($context)->transaction(function () use ($context) {
$post = Post::create($context, ['title' => 'Will be rolled back']);
throw new \Exception('Rollback!');
});
} catch (\Exception $e) {
// Transaction rolled back - post is NOT indexed
}Enable queue-based indexing for better performance in production:
MEILISEARCH_QUEUE=true
MEILISEARCH_QUEUE_CONNECTION=redis
MEILISEARCH_QUEUE_NAME=searchWhen enabled, indexing operations are dispatched to the queue after transaction commit, ensuring both data consistency and non-blocking request handling.
Run the queue worker:
php glueful queue:work --queue=searchThe extension uses id as the Meilisearch primary key field name for all indexes. The model's actual key (uuid or id) is mapped to this field:
- Models with
uuidproperty:uuidvalue stored asid - Models without
uuid:idvalue stored asid
This ensures consistent behavior across all searchable models and proper document hydration.
- Batch size: Configure
MEILISEARCH_BATCH_SIZEfor bulk operations (default: 500) - Queue indexing: Enable for production to avoid blocking requests
- Selective indexing: Use
shouldBeSearchable()to skip irrelevant records - Attribute selection: Define
getSearchableFilterableAttributes()andgetSearchableSortableAttributes()for optimal index settings
-
Models not appearing in search: Ensure
shouldBeSearchable()returns true and the model was saved after adding the trait. -
Filters not working: Verify the attribute is listed in
getSearchableFilterableAttributes()and runphp glueful search:sync. -
Sort not working: Verify the attribute is listed in
getSearchableSortableAttributes()and runphp glueful search:sync. -
Connection errors: Check
MEILISEARCH_HOSTandMEILISEARCH_KEYare correct. Verify Meilisearch is running. -
Index not found: The extension auto-creates indexes on first use. If issues persist, manually create with
search:index --fresh.
# Check Meilisearch connection and indexes
php glueful search:status
# Test search directly
php glueful search:search posts "test query" --raw
# Verify index settings match model
php glueful search:sync --model=App\\Models\\Post --dry-runThis extension is licensed under the same license as the Glueful Framework.
For issues, feature requests, or questions:
- Create an issue in the repository
- See Meilisearch Documentation for search engine specifics