laraveladmin / think-scout
Laravel Scout style full-text search for ThinkPHP ORM, with MySQL, PostgreSQL, MongoDB, Meilisearch, collection and null engines.
Requires
- php: >=8.0
- meilisearch/meilisearch-php: ^1.0
- topthink/framework: ^8.0
- topthink/think-orm: ^4.0
Requires (Dev)
- phpunit/phpunit: ^9.5
Suggests
- ext-mongodb: 使用 mongo 引擎时需要
- ext-pdo_pgsql: 使用 pgsql 引擎时需要
- topthink/think-queue: 队列方式同步索引(未安装时自动降级为同步)
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-31 05:47:00 UTC
README
ThinkPHP 8 ORM 的 Laravel Scout 风格全文搜索:
Model::search()一套 API, 底层可切 MySQL / PostgreSQL / MongoDB / Meilisearch / Collection / Null 六种引擎。
use LaravelAdmin\ThinkScout\Searchable;
class CustomerModel extends \think\Model
{
use Searchable;
}
$customers = CustomerModel::search('张三')
->where('status', 1)
->orderBy('updated_at', 'desc')
->paginate(15);
与 laravel/scout 的模型侧 API 保持一致:
Searchable、search()、searchable()、unsearchable()、makeAllSearchable()、
toSearchableArray()、searchableAs()、shouldBeSearchable()、getScoutKey()、
getScoutKeyName()、searchableUsing()、withoutSyncingToSearch()、scout:import 等。
一、为什么又做一个包
| 方案 | 结论 |
|---|---|
tp5er/think-scout | ThinkPHP 6 时代、以 Elasticsearch 为主,模型事件需要手工注册,未覆盖 MySQL/PG/Mongo 原生全文索引。 |
业务里直接写 LIKE / regex | 没有相关度、没有分页总数、没有索引生命周期,中文与多字段检索要各写一遍。 |
laravel/scout | API 设计成熟,但依赖 Eloquent / Laravel 容器,不能直接用于 think-orm。 |
本包保留 Scout 的用法,把 Eloquent 依赖替换为 think-orm,并补齐三种数据库的索引存储实现。
二、安装
composer require laraveladmin/think-scout
包通过 extra.think.services 自动注册 scout:* 命令,通过 extra.think.config
自动合并 config/scout.php,无需手工改 config/console.php。
PostgreSQL 源模型额外一步:think-orm 4 的 Pgsql 连接器依赖
table_msg()函数, 且官方pgsql.sql使用 PostgreSQL 12 已删除的pg_attrdef.adsrc。如果模型本身 连接 PostgreSQL(而不是只在 PG 里存索引),先在业务库执行一次:php think scout:pgsql-install --connection=pgsql等价于执行包内
resources/pgsql.sql(已改为pg_get_expr,可重复执行)。
本仓库开发时
server/composer.json使用 Composerpath仓库 (../packages/think-scout,symlink);生产只部署server/时请把包发布到 Packagist 或改为 VCS 仓库,不能保留指向../packages的 path 仓库。
依赖
| 引擎 | 前置条件 |
|---|---|
mysql | MySQL 5.7+/8.0,建议 ngram parser(5.7.6+ 内置) |
pgsql | PHP pdo_pgsql;中文/子串检索建议安装 pg_trgm(无权限时自动降级) |
mongo | PHP ext-mongodb、MongoDB 4.2+ |
meilisearch | Meilisearch 1.x 服务;meilisearch/meilisearch-php 已作为包依赖自动安装 |
collection / null | 无 |
队列同步需要 topthink/think-queue;未安装时自动同步,不影响使用。
三、配置
包自带 config/scout.php,业务可新建 config/scout.php 覆盖同名配置。
return [
'driver' => env('SCOUT_DRIVER', 'mysql'), // 全局默认引擎
'prefix' => env('SCOUT_PREFIX', ''), // 索引名前缀
'queue' => env('SCOUT_QUEUE', false), // 模型事件是否入队
'queue_connection' => env('SCOUT_QUEUE_CONNECTION', ''),
'queue_name' => env('SCOUT_QUEUE_NAME', 'scout'),
'after_commit' => env('SCOUT_AFTER_COMMIT', false), // Scout 11 兼容项
'identify' => env('SCOUT_IDENTIFY', false), // Scout 11 兼容项
'sync' => env('SCOUT_SYNC', true), // 总开关
'soft_delete' => env('SCOUT_SOFT_DELETE', false),
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
'per_page' => 15,
// Meilisearch 顶层配置(与 Scout 11 config/scout.php 对齐)
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
'key' => env('MEILISEARCH_KEY', ''),
'wait' => env('SCOUT_MEILISEARCH_WAIT', false),
'enable_contains' => env('SCOUT_MEILISEARCH_CONTAINS', false),
'index-settings' => [
// 'crm_customer' => ['filterableAttributes' => ['status']]
],
'model-settings' => [
// \app\common\model\plugin\CustomerModel::class => ['sortableAttributes' => ['updated_at']]
],
],
'engines' => [
'mysql' => [
'driver' => 'mysql',
'connection' => env('SCOUT_MYSQL_CONNECTION', 'mysql'),
'table' => env('SCOUT_MYSQL_TABLE', 'scout_index'),
'parser' => env('SCOUT_MYSQL_PARSER', 'ngram'),
'ngram_size' => 2,
'mode' => 'auto',
],
'pgsql' => [
'driver' => 'pgsql',
'connection' => env('SCOUT_PGSQL_CONNECTION', 'pgsql'),
'table' => env('SCOUT_PGSQL_TABLE', 'scout_index'),
'language' => env('SCOUT_PGSQL_LANGUAGE', 'simple'),
'mode' => 'auto',
'extension' => 'pg_trgm',
],
'mongo' => [
'driver' => 'mongo',
'connection' => env('SCOUT_MONGO_CONNECTION', 'mongo'),
'collection' => env('SCOUT_MONGO_COLLECTION', 'scout_index'),
'mode' => 'auto',
],
'meilisearch' => [
'driver' => 'meilisearch',
// host/key/wait/index-settings/model-settings 默认取顶层 scout.meilisearch
],
'collection' => ['driver' => 'collection'],
'null' => ['driver' => 'null'],
],
// 模型 → 引擎名
'models' => [
// \app\common\model\plugin\CustomerModel::class => 'pgsql',
],
];
引擎优先级:模型 searchableUsing() > scout.models[模型类] > scout.driver。
Meilisearch 的配置放在哪
- 顶层
scout.meilisearch是单一配置源(与 Scout 11config/scout.php对齐):host/key:Meilisearch HTTP 服务地址与 API Key;wait:写入/建索引后是否等待任务;enable_contains:是否允许like映射为CONTAINS(需开启 Meilisearch 实验特性);index-settings[索引名]:filterable/sortable/ranking 等索引设置;model-settings[模型类]:按模型覆盖索引设置。
engines.meilisearch是引擎注册项,driver=meilisearch必需;其中的同名键会覆盖顶层配置。 所以只写driver时全部取顶层,不会重复。- 不要放进
config/database.php:database.connections是 ThinkPHP 的 PDO/Mongo 连接,Db::connect()需要ConnectionInterface;Meilisearch 是 HTTP API,放进去不会被使用, 语义也不对。需要集中管理时用scout.meilisearch(或单独config/meilisearch.php再在此引用)。
数据库连接不重复配置:
engines.mysql/pgsql/mongo.connection只写config/database.php里的连接名;host/port/database/username/password/prefix 全部由Db::connect($connection)从database.connections.<name>读取。scout.php只保留table/collection、parser、language、mode等索引专属参数。 Meilisearch 不是数据库连接,host/key 仍放在scout.meilisearch(与 Scout 11 一致)。
| 模式 | 取值 | 说明 |
|---|---|---|
MySQL mode | auto / fulltext / like | auto:查询短于 ngram_size 时走 LIKE |
PostgreSQL mode | auto / fulltext / trigram | auto:含 CJK 走 ILIKE,否则 tsvector |
MongoDB mode | auto / text / regex | auto:含 CJK 走 regex,否则 $text |
四、模型接入
declare(strict_types=1);
namespace app\common\model\plugin;
use app\common\model\OrmModel;
use LaravelAdmin\ThinkScout\Searchable;
class CustomerModel extends OrmModel
{
use Searchable;
protected $name = 'customer';
/**
* 只索引需要检索的字段(默认 toArray() 会把整行写入索引)。
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'id' => (int) $this->id,
'name' => (string) $this->fullname,
'phone' => (string) $this->phone,
'company' => (string) $this->company,
'status' => (int) $this->status,
];
}
public function shouldBeSearchable(): bool
{
return (int) $this->status === 1;
}
}
索引名
默认 SCOUT_PREFIX + 表名。需要固定名称时覆盖:
public function searchableAs(): string
{
return 'crm_customer';
}
自动同步
AfterInsert / AfterUpdate / AfterDelete / AfterRestore 自动维护索引。
如果模型自己实现了同名事件方法,需要手动调用 searchableSync() / unsearchableSync()。
CustomerModel::disableSearchSyncing();
// 批量操作...
CustomerModel::enableSearchSyncing();
CustomerModel::withoutSyncingToSearch(function () {
// ...
});
Model::where(...)->update()不触发模型事件;批量改状态后请显式makeAllSearchable()或searchableSync(),与 Laravel Scout 一致。
软删除
scout.soft_delete=false(默认):软删除 = 从索引删除;scout.soft_delete=true:索引保留文档并写__soft_deleted=1, 查询默认只返回未删除,withTrashed()/onlyTrashed()可切换。
五、搜索 API
use app\common\model\plugin\CustomerModel;
CustomerModel::search('张三')->get();
CustomerModel::search('张三')->first();
CustomerModel::search('张三')->keys();
CustomerModel::search('张三')->allKeys(); // 翻页取全部命中主键,适合源库二次过滤
CustomerModel::search('张三')->count();
CustomerModel::search('张三')->raw();
CustomerModel::search('张三')
->where('status', 1)
->whereIn('department_id', [12, 26])
->whereNotIn('type', ['test'])
->whereNull('deleted_at')
->whereNotNull('phone')
->orderByDesc('updated_at')
->take(20)
->skip(0)
->get();
CustomerModel::search('张三')
->query(fn ($query) => $query->where('tenant_id', 1))
->paginate(15);
CustomerModel::search('张三')->within('crm_customer_alias')->get();
CustomerModel::search('')->withTrashed()->onlyTrashed()->get();
| 方法 | 说明 |
|---|---|
where($field, $value) / where($field, $operator, $value) | 索引文档字段过滤;三参数支持 = != <> > >= < <= like / not like |
whereIn / whereNotIn | 集合过滤 |
whereNull / whereNotNull | 空值 |
whereBetween($field, [min, max]) | 区间 |
orderBy / orderByDesc / latest / oldest | 排序 |
take / skip | 限制/偏移 |
within($index) | 指定索引名 |
query($callback) | 修改源库查询(租户隔离等) |
options(array) | 传给引擎的额外参数 |
withRawResults($callback) | 观察/改写原始结果 |
get / first / keys / allKeys / count / raw | 结果;allKeys 对分页引擎翻页取全量 |
paginate / simplePaginate | think\Paginator |
withTrashed / onlyTrashed | 软删除 |
cursor | 一次性惰性集合包装 |
semantic / hybrid | Scout 11 API 对齐;内置数据库引擎没有向量索引,执行时抛 NotSupportedException |
过滤字段必须是 [A-Za-z_][A-Za-z0-9_]*(可带 . 表示嵌套),非法字段/操作符抛
InvalidArgumentException。三参数 where 与 Scout 11 对齐,例如:
CustomerModel::search('张三')->where('age', '>=', 18)->get();
CustomerModel::search('张三')->where('name', 'like', '张')->get();
分页返回与 ThinkPHP 列表一致:
$page = CustomerModel::search('张三')->paginate(15);
$page->total();
$page->items();
六、索引存储
索引与业务表分离:MySQL/PG 建 scout_index 表,Mongo 建 scout_index 集合。
同一个索引存储可服务多个模型,用 index_name + model_type 区分。
MySQL
CREATE TABLE `crm_scout_index` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`index_name` VARCHAR(191) NOT NULL,
`model_type` VARCHAR(191) NOT NULL,
`model_key` VARCHAR(191) NOT NULL,
`document` LONGTEXT NOT NULL,
`search_text` LONGTEXT NOT NULL,
`updated_at` TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `scout_index_unique` (`index_name`, `model_type`, `model_key`),
FULLTEXT KEY `scout_fulltext` (`search_text`) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
- 查询:
MATCH(search_text) AGAINST (? IN NATURAL LANGUAGE MODE); 短查询走 LIKE; - 写入:
INSERT ... ON DUPLICATE KEY UPDATE,按 200 条批量; - 过滤:
JSON_EXTRACT(document, '$."field"')。
PostgreSQL
CREATE TABLE "scout_index" (
id BIGSERIAL PRIMARY KEY,
index_name VARCHAR(191) NOT NULL,
model_type VARCHAR(191) NOT NULL,
model_key VARCHAR(191) NOT NULL,
document JSONB NOT NULL,
search_text TEXT NOT NULL,
updated_at TIMESTAMP NULL,
CONSTRAINT scout_index_unique UNIQUE (index_name, model_type, model_key)
);
CREATE INDEX scout_index_tsv ON "scout_index"
USING GIN (to_tsvector('simple', search_text));
CREATE INDEX scout_index_trgm ON "scout_index"
USING GIN (search_text gin_trgm_ops);
- 英文/数字:
to_tsvector @@ plainto_tsquery+ts_rank; - 中文/子串:
ILIKE+similarity(pg_trgm不可用时 rank 为 0,仍可检索); - 写入:
INSERT ... ON CONFLICT ... DO UPDATE。
MongoDB
// 索引集合:{prefix}scout_index
db.scout_index.createIndex(
{ index_name: 1, model_type: 1, model_key: 1 },
{ unique: true, name: "scout_index_unique" }
);
db.scout_index.createIndex(
{ search_text: "text" },
{ name: "scout_text" }
);
- 英文:
$text+textScore; - 中文:
$regex子串匹配; - 写入:
update命令 upsert;删除:delete命令。
Meilisearch
- 索引名默认
SCOUT_PREFIX + 表名,可通过searchableAs()/indexableAs()覆盖; - 写入:
addDocuments($objects, $primaryKey);删除:deleteDocuments();清空:deleteAllDocuments(); - 搜索:
rawSearch(),支持filter/sort/hitsPerPage/page; - 索引设置:
filterableAttributes/sortableAttributes优先取scout.meilisearch.index-settings[索引名], 再合并model-settings[模型类];未配置时自动使用toSearchableArray()的字段,保证where/orderBy可用; - 软删除:
__soft_deleted会自动加入filterableAttributes; - 写入后默认异步(不等待任务),
wait=true或SCOUT_MEILISEARCH_WAIT=true时等待任务完成;scout:index/scout:sync-index-settings会等待建索引/设置任务; - 修改 settings 后执行
php think scout:sync-index-settings --all,不必重建索引。
Collection / Null
collection:直接查源模型,PHP 层过滤;适合开发与测试;null:写入 no-op、搜索恒空;用于关闭搜索。
七、命令
# 一条命令:建索引 + 同步设置 + 导入所有 use Searchable 的模型
php think scout:build
# 只处理单个模型 / config("scout.models") 列表 / 清空后重导
php think scout:build "app\\common\\model\\plugin\\CustomerModel"
php think scout:build --all
php think scout:build --fresh --chunk=500
# 建索引(表/集合 + 全文索引)
php think scout:index "app\\common\\model\\plugin\\CustomerModel"
php think scout:index --all --recreate
# 分批导入(幂等 upsert)
php think scout:import "app\\common\\model\\plugin\\CustomerModel" --chunk=500
php think scout:import --all
# 清空某模型索引数据(保留结构)
php think scout:flush "app\\common\\model\\plugin\\CustomerModel" --force
php think scout:flush --all --force
# 删除某模型索引数据(不删表结构)
php think scout:delete-index "app\\common\\model\\plugin\\CustomerModel"
php think scout:delete-index --all
# PostgreSQL 源模型:安装 table_msg() 辅助函数
php think scout:pgsql-install --connection=pgsql
# 同步外部引擎(Meilisearch)的索引设置,不重建索引
php think scout:sync-index-settings "app\\common\\model\\plugin\\CustomerModel"
php think scout:sync-index-settings --all
--all 从 config('scout.models') 的键读取模型列表。模型必须 use Searchable。
八、队列
'queue' => true,
'queue_connection' => 'redis', // 留空 = queue.default
'queue_name' => 'scout',
- 事件只入队,不阻塞请求;
- Job 只带模型类与主键,worker 端重新查源库;
- 未安装
think-queue或queue=false时同步执行; - 入队失败抛
ScoutException,不静默丢索引; - 队列失败记录日志(模型、主键数量、异常),超过 3 次删除。
九、自定义引擎
实现 LaravelAdmin\ThinkScout\Engine:
use LaravelAdmin\ThinkScout\Engine;
class MyEngine extends Engine
{
public function update(iterable $models): void {}
public function delete(iterable $models): void {}
public function search(Builder $builder): array { return ['keys' => [], 'total' => 0]; }
public function paginate(Builder $builder, int $perPage, int $page): array { /* ... */ }
public function mapIds(array $results): array { return []; }
public function map(Builder $builder, array $results, \think\Model $model): \think\model\Collection { /* ... */ }
public function getTotalCount(array $results): int { return 0; }
public function count(Builder $builder): int { return 0; }
public function flush(\think\Model $model): void {}
public function createIndex(\think\Model $model, array $options = []): void {}
public function deleteIndex(\think\Model $model): void {}
}
注册:
'engines' => [
'my' => [
'driver' => \app\search\MyEngine::class,
],
],
模型覆盖:
public function searchableUsing(): \LaravelAdmin\ThinkScout\Engine
{
return app(\LaravelAdmin\ThinkScout\ScoutManager::class)->engine('my');
}
十、限制与注意事项
- 一期只支持单主键;复合主键会抛
ScoutException。 - Meilisearch 的
where依赖filterableAttributes、orderBy依赖sortableAttributes; 未配置时包会自动把toSearchableArray()字段写入 settings,显式配置可覆盖。 - Meilisearch 的
like过滤默认不支持:CONTAINS需要开启contains filter实验特性; 开启后设置enable_contains=true才会映射为CONTAINS。建议改用where等值/IN, 或把关键词放在search()查询里。 - Meilisearch 写入默认异步,搜索前未完成的任务可能暂时不可见;测试/导入可用
wait=true。 document过滤字段不是独立索引列/字段,超大数据量下过滤性能有限; MySQL 可后续加函数索引,Mongo 可加复合索引。- MongoDB 一个集合只能有一个 text index,文本字段固定为
search_text; 中文$regex子串匹配不走索引。 - PostgreSQL 的中文分词依赖
pg_trgm子串匹配,不做拼音/同义词/语义搜索。 - MySQL ngram 对 1 个汉字查询会回退 LIKE;
ngram_size可配置。 - 索引命中但源库已删除的记录在回表时自动跳过;分页总数以索引命中数为准。
- 软删除记录是否保留由
scout.soft_delete决定,不是每个模型独立配置。 after_commit/identify是 Scout 11 配置兼容项:think-orm 没有事务提交事件, 本包不会延迟到 commit 后同步;identify仅外部引擎使用,内置引擎忽略。semantic()/hybrid()是 Scout 11 API 兼容项,内置数据库引擎抛NotSupportedException(没有向量列/向量索引);需要向量检索时实现自定义引擎。
十一、测试
包内测试优先使用 server/vendor 的依赖(monorepo 本地开发):
cd packages/think-scout
../../server/vendor/bin/phpunit -c phpunit.xml
- MySQL / MongoDB 使用临时表/集合,测试后清理;
- Meilisearch 需要
MEILISEARCH_HOST/MEILISEARCH_KEY,无服务时自动markTestSkipped; - PostgreSQL 无连接时自动
markTestSkipped; - 不调用外部网络服务。
服务端安装验收:
cd server
vendor/bin/phpunit -c phpunit.xml --testsuite scout
十二、目录
packages/think-scout/
├── config/scout.php
├── src/
│ ├── Searchable.php
│ ├── Builder.php
│ ├── Engine.php
│ ├── ScoutManager.php
│ ├── Service.php
│ ├── Engines/
│ │ ├── AbstractIndexEngine.php
│ │ ├── MySqlEngine.php
│ │ ├── PgsqlEngine.php
│ │ ├── MongoEngine.php
│ │ ├── MeilisearchEngine.php
│ │ ├── CollectionEngine.php
│ │ └── NullEngine.php
│ ├── Commands/
│ ├── Jobs/
│ └── Support/
└── tests/