Laravel 13 与 Meilisearch 的集成提供了轻量级但强大的搜索能力,本文介绍如何深度集成 Meilisearch。

Meilisearch 配置

连接配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

return [
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY', null),

'indices' => [
'products' => [
'model' => \App\Models\Product::class,
'settings' => [
'searchableAttributes' => ['name', 'description', 'sku'],
'filterableAttributes' => ['category_id', 'brand_id', 'price', 'status'],
'sortableAttributes' => ['price', 'created_at', 'popularity'],
'rankingRules' => [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
],
'displayedAttributes' => ['id', 'name', 'description', 'price', 'image'],
'stopWords' => ['the', 'a', 'an', 'and', 'or'],
'synonyms' => [
'phone' => ['smartphone', 'mobile'],
'laptop' => ['notebook', 'computer'],
],
],
],

'articles' => [
'model' => \App\Models\Article::class,
'settings' => [
'searchableAttributes' => ['title', 'content', 'author_name'],
'filterableAttributes' => ['category_id', 'status', 'author_id'],
'sortableAttributes' => ['published_at', 'view_count'],
],
],
],
],
];

Meilisearch 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

namespace App\Services\Meilisearch;

use Http;
use Illuminate\Http\Client\Response;

class MeilisearchClient
{
protected string $host;
protected ?string $key;

public function __construct(string $host, ?string $key = null)
{
$this->host = rtrim($host, '/');
$this->key = $key;
}

protected function request(string $method, string $path, array $data = []): Response
{
$client = Http::withHeaders([
'Content-Type' => 'application/json',
]);

if ($this->key) {
$client = $client->withHeaders([
'Authorization' => 'Bearer ' . $this->key,
]);
}

return $client->{$method}($this->host . $path, $data);
}

public function get(string $path): Response
{
return $this->request('get', $path);
}

public function post(string $path, array $data = []): Response
{
return $this->request('post', $path, $data);
}

public function put(string $path, array $data = []): Response
{
return $this->request('put', $path, $data);
}

public function delete(string $path): Response
{
return $this->request('delete', $path);
}

public function health(): array
{
return $this->get('/health')->json();
}

public function stats(): array
{
return $this->get('/stats')->json();
}
}

索引管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace App\Services\Meilisearch;

class IndexManager
{
protected MeilisearchClient $client;

public function __construct(MeilisearchClient $client)
{
$this->client = $client;
}

public function create(string $uid, array $options = []): array
{
return $this->client->post('/indexes', array_merge([
'uid' => $uid,
'primaryKey' => 'id',
], $options))->json();
}

public function get(string $uid): array
{
return $this->client->get("/indexes/{$uid}")->json();
}

public function list(): array
{
return $this->client->get('/indexes')->json();
}

public function delete(string $uid): array
{
return $this->client->delete("/indexes/{$uid}")->json();
}

public function updateSettings(string $uid, array $settings): array
{
return $this->client->patch("/indexes/{$uid}/settings", $settings)->json();
}

public function getSettings(string $uid): array
{
return $this->client->get("/indexes/{$uid}/settings")->json();
}

public function addDocuments(string $uid, array $documents): array
{
return $this->client->post("/indexes/{$uid}/documents", $documents)->json();
}

public function updateDocuments(string $uid, array $documents): array
{
return $this->client->put("/indexes/{$uid}/documents", $documents)->json();
}

public function deleteDocuments(string $uid, array $ids): array
{
return $this->client->post("/indexes/{$uid}/documents/delete-batch", $ids)->json();
}

public function deleteAllDocuments(string $uid): array
{
return $this->client->delete("/indexes/{$uid}/documents")->json();
}

public function getDocument(string $uid, string $id): array
{
return $this->client->get("/indexes/{$uid}/documents/{$id}")->json();
}
}

搜索服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace App\Services\Meilisearch;

class SearchService
{
protected MeilisearchClient $client;

public function __construct(MeilisearchClient $client)
{
$this->client = $client;
}

public function search(string $index, string $query, array $options = []): array
{
$params = array_merge([
'q' => $query,
], $options);

return $this->client->post("/indexes/{$index}/search", $params)->json();
}

public function multiSearch(array $queries): array
{
return $this->client->post('/multi-search', [
'queries' => $queries,
])->json();
}

public function searchProducts(string $query, array $filters = [], array $options = []): array
{
$params = [
'q' => $query,
'limit' => $options['limit'] ?? 20,
'offset' => $options['offset'] ?? 0,
];

if (!empty($filters)) {
$params['filter'] = $this->buildFilter($filters);
}

if (!empty($options['sort'])) {
$params['sort'] = $options['sort'];
}

if (!empty($options['facets'])) {
$params['facets'] = $options['facets'];
}

if (!empty($options['highlight'])) {
$params['attributesToHighlight'] = $options['highlight'];
}

return $this->search('products', $query, $params);
}

protected function buildFilter(array $filters): array
{
$result = [];

foreach ($filters as $field => $value) {
if (is_array($value)) {
$result[] = $field . ' IN ' . json_encode($value);
} else {
$result[] = $field . ' = ' . json_encode($value);
}
}

return $result;
}
}

可搜索模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<?php

namespace App\Models;

use App\Services\Meilisearch\IndexManager;
use Illuminate\Database\Eloquent\Model;

trait Meilisearchable
{
protected static function bootMeilisearchable(): void
{
static::created(function ($model) {
$model->indexToMeilisearch();
});

static::updated(function ($model) {
$model->indexToMeilisearch();
});

static::deleted(function ($model) {
$model->removeFromMeilisearch();
});
}

public function indexToMeilisearch(): void
{
$manager = app(IndexManager::class);

$manager->updateDocuments(
$this->getMeilisearchIndex(),
[$this->toMeilisearchArray()]
);
}

public function removeFromMeilisearch(): void
{
$manager = app(IndexManager::class);

$manager->deleteDocuments(
$this->getMeilisearchIndex(),
[$this->id]
);
}

public function getMeilisearchIndex(): string
{
return $this->meilisearchIndex ?? strtolower(class_basename($this)) . 's';
}

public function toMeilisearchArray(): array
{
return $this->toArray();
}

public static function meilisearch(string $query, array $options = [])
{
$service = app(SearchService::class);

$results = $service->search(
(new static())->getMeilisearchIndex(),
$query,
$options
);

return static::hydrateMeilisearchResults($results);
}

protected static function hydrateMeilisearchResults(array $results): mixed
{
$ids = collect($results['hits'])->pluck('id')->toArray();

$models = static::whereIn('id', $ids)->get()->keyBy('id');

return collect($results['hits'])->map(function ($hit) use ($models) {
$model = $models->get($hit['id']);
if ($model) {
$model->_rankingScore = $hit['_rankingScore'] ?? null;
$model->_formatted = $hit['_formatted'] ?? null;
}
return $model;
})->filter();
}
}

class Product extends Model
{
use Meilisearchable;

protected $meilisearchIndex = 'products';

public function toMeilisearchArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'sku' => $this->sku,
'price' => (float) $this->price,
'category_id' => $this->category_id,
'category_name' => $this->category->name,
'brand_id' => $this->brand_id,
'brand_name' => $this->brand->name,
'status' => $this->status,
'image' => $this->image,
'created_at' => $this->created_at->timestamp,
];
}
}

Meilisearch 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
<?php

namespace App\Console\Commands;

use App\Services\Meilisearch\IndexManager;
use Illuminate\Console\Command;

class MeilisearchCommand extends Command
{
protected $signature = 'meilisearch:manage {action} {index?}';
protected $description = 'Manage Meilisearch indices';

public function handle(IndexManager $manager): int
{
$action = $this->argument('action');

return match ($action) {
'list' => $this->listIndices($manager),
'create' => $this->createIndex($manager),
'delete' => $this->deleteIndex($manager),
'settings' => $this->showSettings($manager),
'reindex' => $this->reindex($manager),
default => $this->invalidAction(),
};
}

protected function listIndices(IndexManager $manager): int
{
$indices = $manager->list();

$this->table(
['UID', 'Primary Key', 'Created At'],
collect($indices['results'] ?? [])->map(fn($index) => [
$index['uid'],
$index['primaryKey'],
$index['createdAt'],
])
);

return self::SUCCESS;
}

protected function createIndex(IndexManager $manager): int
{
$index = $this->argument('index');

if (!$index) {
$this->error('Please specify an index name');
return self::FAILURE;
}

$result = $manager->create($index);

$this->info("Index {$index} created");

return self::SUCCESS;
}

protected function deleteIndex(IndexManager $manager): int
{
$index = $this->argument('index');

if (!$index) {
$this->error('Please specify an index name');
return self::FAILURE;
}

if (!$this->confirm("Are you sure you want to delete index {$index}?")) {
return self::SUCCESS;
}

$manager->delete($index);

$this->info("Index {$index} deleted");

return self::SUCCESS;
}

protected function showSettings(IndexManager $manager): int
{
$index = $this->argument('index');

if (!$index) {
$this->error('Please specify an index name');
return self::FAILURE;
}

$settings = $manager->getSettings($index);

$this->info("Settings for {$index}:");
$this->line(json_encode($settings, JSON_PRETTY_PRINT));

return self::SUCCESS;
}

protected function reindex(IndexManager $manager): int
{
$index = $this->argument('index');

if (!$index) {
$this->error('Please specify an index name');
return self::FAILURE;
}

$config = config("meilisearch.indices.{$index}", []);

if (empty($config['model'])) {
$this->error("No model configured for index {$index}");
return self::FAILURE;
}

$model = $config['model'];

$this->info("Reindexing {$index}...");

$manager->deleteAllDocuments($index);

if (!empty($config['settings'])) {
$manager->updateSettings($index, $config['settings']);
}

$count = 0;

$model::chunk(500, function ($models) use ($manager, $index, &$count) {
$documents = $models->map(fn($m) => $m->toMeilisearchArray())->toArray();
$manager->addDocuments($index, $documents);
$count += count($documents);
$this->info("Indexed {$count} documents...");
});

$this->info("Reindex complete. Total: {$count} documents");

return self::SUCCESS;
}

protected function invalidAction(): int
{
$this->error('Invalid action. Use: list, create, delete, settings, or reindex');
return self::FAILURE;
}
}

搜索控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\Meilisearch\SearchService;
use Illuminate\Http\Request;

class SearchController extends Controller
{
protected SearchService $search;

public function __construct(SearchService $search)
{
$this->search = $search;
}

public function products(Request $request)
{
$request->validate([
'q' => 'required|string|min:2',
'category' => 'nullable|integer',
'brand' => 'nullable|integer',
'min_price' => 'nullable|numeric',
'max_price' => 'nullable|numeric',
'sort' => 'nullable|string',
'limit' => 'nullable|integer|max:100',
]);

$filters = [];

if ($request->category) {
$filters['category_id'] = $request->category;
}

if ($request->brand) {
$filters['brand_id'] = $request->brand;
}

$options = [
'limit' => $request->limit ?? 20,
'facets' => ['category_id', 'brand_id'],
'highlight' => ['name', 'description'],
];

if ($request->sort) {
$options['sort'] = [$request->sort];
}

$results = $this->search->searchProducts(
$request->q,
$filters,
$options
);

return response()->json([
'hits' => $results['hits'],
'total' => $results['estimatedTotalHits'] ?? 0,
'processing_time' => $results['processingTimeMs'] ?? 0,
'facets' => $results['facetDistribution'] ?? [],
]);
}
}

总结

Laravel 13 与 Meilisearch 的集成提供了轻量级但功能强大的搜索能力。通过灵活的配置、自动索引同步和丰富的搜索选项,可以快速构建高性能的搜索功能。