Search by

laraveladmin / think-scout

laraveladmin

Laravel Scout style full-text search for ThinkPHP ORM, with MySQL, PostgreSQL, MongoDB, Meilisearch, collection and null engines.

Package info

gitee.com/zsping1989/think-scout.git

pkg:composer/laraveladmin/think-scout

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

v1.1.0 2026-10-30 10:29 UTC

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-scoutThinkPHP 6 时代、以 Elasticsearch 为主,模型事件需要手工注册,未覆盖 MySQL/PG/Mongo 原生全文索引。
业务里直接写 LIKE / regex没有相关度、没有分页总数、没有索引生命周期,中文与多字段检索要各写一遍。
laravel/scoutAPI 设计成熟,但依赖 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 使用 Composer path 仓库 (../packages/think-scout,symlink);生产只部署 server/ 时请把包发布到 Packagist 或改为 VCS 仓库,不能保留指向 ../packages 的 path 仓库。

依赖

引擎前置条件
mysqlMySQL 5.7+/8.0,建议 ngram parser(5.7.6+ 内置)
pgsqlPHP pdo_pgsql;中文/子串检索建议安装 pg_trgm(无权限时自动降级)
mongoPHP ext-mongodb、MongoDB 4.2+
meilisearchMeilisearch 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 11 config/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 modeauto / fulltext / likeauto:查询短于 ngram_size 时走 LIKE
PostgreSQL modeauto / fulltext / trigramauto:含 CJK 走 ILIKE,否则 tsvector
MongoDB modeauto / text / regexauto:含 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 / simplePaginatethink\Paginator
withTrashed / onlyTrashed软删除
cursor一次性惰性集合包装
semantic / hybridScout 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/