glueful / meilisearch
Meilisearch full-text search integration for Glueful Framework
Requires
- php: ^8.3
- meilisearch/meilisearch-php: ^1.6
Requires (Dev)
- glueful/framework: ^1.57.0
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.6
README
Full-text search integration for the Glueful Framework using Meilisearch.
Overview
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.
Features
- 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
Installation
Installation (Recommended)
Install via Composer
composer require glueful/meilisearch
Composer discovers packages of type glueful-extension, but installing does not auto-enable them — the provider must be in config/extensions.php's enabled allow-list. Enable/disable in development (these commands edit config/extensions.php and recompile the cache):
# Enable the extension (adds the provider FQCN to `enabled`) php glueful extensions:enable meilisearch # Disable the extension (removes it from `enabled`) php glueful extensions:disable meilisearch # Preview changes without writing php glueful extensions:enable meilisearch --dry-run # Create a .bak backup before editing php glueful extensions:enable meilisearch --backup
In production, manage the enabled list in config and run php glueful extensions:cache in your deploy step.
Local Development Installation
To develop the extension locally, register it as a Composer path repository in your app's composer.json, then require and enable it:
// composer.json "repositories": [ { "type": "path", "url": "extensions/meilisearch", "options": { "symlink": true } } ]
composer require glueful/meilisearch:@dev php glueful extensions:enable meilisearch
Verify Installation
Check status and details:
php glueful extensions:list php glueful extensions:info meilisearch
Requirements
- PHP 8.3 or higher
- Glueful Framework 1.27.0 or higher
- Meilisearch server (v1.6+ recommended)
- meilisearch/meilisearch-php ^1.6 (installed automatically)
Installing Meilisearch Server
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.6
Homebrew (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.
Configuration
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_ # HTTP search routes are deny-by-default. Add index names without prefix. MEILISEARCH_ALLOWED_INDEXES=posts,parps,entities # Indexes that do not need a server-side scope filter, such as public documents MEILISEARCH_PUBLIC_INDEXES=posts # 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=true
Configuration File
The 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', ''), 'allowed_indexes' => env('MEILISEARCH_ALLOWED_INDEXES', ''), 'http_search' => [ 'allowed_indexes' => env('MEILISEARCH_ALLOWED_INDEXES', ''), 'public_indexes' => env('MEILISEARCH_PUBLIC_INDEXES', ''), 'require_server_filter' => true, 'server_filters' => [ // 'posts' => 'tenant_uuid = "{claims.tenant_uuid}"', ], 'retrievable_attributes' => [ // 'posts' => ['id', 'title', 'excerpt'], ], ], '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>', ], ];
Usage
Making Models Searchable
Add the Searchable trait to any model. Implementing SearchableInterface is recommended for static analysis type checking:
By default, the trait indexes only the primary identifier. Override toSearchableArray() to expose fields intentionally; do not return a full model row unless every field is safe for search indexing and retrieval.
<?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']; } /** * Define attributes Meilisearch may return to search callers. */ public function getSearchableDisplayedAttributes(): array { return ['id', 'title', 'body', 'author_name', 'tags', 'category', 'status', 'published_at']; } /** * Only index published posts. */ public function shouldBeSearchable(): bool { return $this->status === 'published'; } }
HTTP Search Routes
The request-facing search routes require:
authmeilisearch.searchpermission- route rate limiting
- an index listed in
MEILISEARCH_ALLOWED_INDEXES
Private indexes also require a configured server-side filter. For example:
'http_search' => [ 'allowed_indexes' => ['posts'], 'server_filters' => [ 'posts' => 'tenant_uuid = "{claims.tenant_uuid}"', ], 'retrievable_attributes' => [ 'posts' => ['id', 'title', 'excerpt'], ], ],
Caller-provided filter values are combined with the server-side filter using AND. attributesToRetrieve is rejected unless every requested field is in the configured retrievable list for that index. If the caller omits attributesToRetrieve, the configured retrievable list is used; if none is configured, only id is returned.
Basic Searching
// $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();
Filtering
// 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();
Sorting
$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();
Pagination
$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]
Faceted Search
$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();
Geo-Search
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();
Highlighting
$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> }
Manual Indexing
// 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);
Index Management
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');
CLI Commands
Index Models
# 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
Check Index Status
# 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 Index Settings
# 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 Index
# Flush specific index php glueful search:flush posts # Flush all indexes php glueful search:flush --all # Skip confirmation php glueful search:flush posts --force
Debug Search
# 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" --raw
API Endpoints
All endpoints are prefixed with /api/search and require authentication.
Search
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
Admin
GET /api/search/admin/status- Get all index status (requires admin middleware)
Transaction-Safe Indexing
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 }
Queue Support
Enable queue-based indexing for better performance in production:
MEILISEARCH_QUEUE=true MEILISEARCH_QUEUE_CONNECTION=redis MEILISEARCH_QUEUE_NAME=search
When 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=search
Primary Key Strategy
The 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.
Performance Considerations
- 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
Troubleshooting
Common Issues
-
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.
Debugging
# 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-run
License
This extension is licensed under the same license as the Glueful Framework.
Support
For issues, feature requests, or questions:
- Create an issue in the repository
- See Meilisearch Documentation for search engine specifics