glueful/meilisearch

Meilisearch full-text search integration for Glueful Framework

Maintainers

Package info

github.com/glueful/meilisearch

Type:glueful-extension

pkg:composer/glueful/meilisearch

Statistics

Installs: 23

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.6.0 2026-06-15 01:45 UTC

This package is auto-updated.

Last update: 2026-06-15 01:45:57 UTC


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:

  • auth
  • meilisearch.search permission
  • 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 search
  • GET /api/search/{index}?q={query} - Search specific index

Query parameters:

  • q - Search query (optional, empty returns all)
  • filter - Filter expression
  • facets - Attributes for facet distribution
  • sort - Sort criteria
  • limit - 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 uuid property: uuid value stored as id
  • Models without uuid: id value stored as id

This ensures consistent behavior across all searchable models and proper document hydration.

Performance Considerations

  • Batch size: Configure MEILISEARCH_BATCH_SIZE for 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() and getSearchableSortableAttributes() for optimal index settings

Troubleshooting

Common Issues

  1. Models not appearing in search: Ensure shouldBeSearchable() returns true and the model was saved after adding the trait.

  2. Filters not working: Verify the attribute is listed in getSearchableFilterableAttributes() and run php glueful search:sync.

  3. Sort not working: Verify the attribute is listed in getSearchableSortableAttributes() and run php glueful search:sync.

  4. Connection errors: Check MEILISEARCH_HOST and MEILISEARCH_KEY are correct. Verify Meilisearch is running.

  5. 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: