├── .github └── workflows │ ├── pull-request.yml │ └── scout-import.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── scout.php ├── phpunit.xml ├── src ├── Classes │ └── TypesenseDocumentIndexResponse.php ├── Concerns │ └── HasScopedApiKey.php ├── Engines │ └── TypesenseEngine.php ├── Interfaces │ └── TypesenseDocument.php ├── Mixin │ └── BuilderMixin.php ├── Typesense.php ├── TypesenseFacade.php └── TypesenseServiceProvider.php └── tests ├── Feature ├── MultiSearchTest.php ├── PaginateTest.php └── SearchableTest.php ├── Fixtures └── SearchableUserModel.php └── TestCase.php /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Run Package Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 15 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | typesense-version: [0.24.0] 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | with: 19 | submodules: 'recursive' 20 | 21 | - name: Start Typesense 22 | uses: jirevwe/typesense-github-action@v1.0.1 23 | with: 24 | typesense-version: ${{ matrix.typesense-version }} 25 | typesense-port: 8108 26 | typesense-api-key: xyz 27 | 28 | - name: Composer Validate 29 | run: composer validate 30 | 31 | - name: Cache Composer Packages 32 | id: composer-cache 33 | uses: actions/cache@v2 34 | with: 35 | path: vendor 36 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 37 | restore-keys: | 38 | ${{ runner.os }}-php- 39 | 40 | - name: Cache dependencies 41 | uses: actions/cache@v2 42 | with: 43 | path: | 44 | ~/.npm 45 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 46 | 47 | - name: Install Dependencies 48 | run: | 49 | composer update --prefer-dist --no-interaction --no-progress 50 | 51 | - name: Run Tests 52 | run: vendor/bin/phpunit tests 53 | -------------------------------------------------------------------------------- /.github/workflows/scout-import.yml: -------------------------------------------------------------------------------- 1 | name: Run Project Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | scout-import-test: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 15 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | typesense-version: [0.24.0] 15 | 16 | steps: 17 | - name: Start Typesense 18 | uses: jirevwe/typesense-github-action@v1.0.1 19 | with: 20 | typesense-version: ${{ matrix.typesense-version }} 21 | typesense-port: 8108 22 | typesense-api-key: xyz 23 | 24 | - name: Curl Typesense 25 | run: sleep 10 && curl http://localhost:8108/health 26 | 27 | - name: Install MySQL client 28 | run: sudo apt-get update && sudo apt-get install mysql-client 29 | 30 | - name: Run MySQL client 31 | run: sudo service mysql start 32 | 33 | - name: Run SQL script 34 | run: | 35 | mysql -h 127.0.0.1 -u root -p'root' -e "CREATE DATABASE test;" 36 | 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | with: 40 | repository: arayiksmbatyan/scout-import-tests 41 | 42 | - name: Composer Validate 43 | run: composer validate 44 | 45 | - name: Cache Composer Packages 46 | id: composer-cache 47 | uses: actions/cache@v2 48 | with: 49 | path: vendor 50 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 51 | restore-keys: | 52 | ${{ runner.os }}-php- 53 | 54 | - name: Cache dependencies 55 | uses: actions/cache@v2 56 | with: 57 | path: | 58 | ~/.npm 59 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 60 | 61 | - name: Prepare Application Environment 62 | run: | 63 | cp .env.example .env 64 | 65 | - name: Install Dependencies 66 | run: | 67 | composer update --prefer-dist --no-interaction --no-progress 68 | 69 | - name: Generate Application Key 70 | run: php artisan key:generate 71 | 72 | - name: Run Migrations and Seeders 73 | run: php artisan migrate --seed 74 | 75 | - name: Run Tests 76 | run: php artisan test 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .tmp 3 | /composer.lock 4 | vendor 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present Typesense, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Scout Typesense Driver 2 | 3 | This package makes it easy to add full text search support to your models with Laravel 7.\* to 11.\*. 4 | 5 | > [!IMPORTANT] 6 | > The features from the Scout driver in this repo have been merged upstream into [Laravel Scout natively](https://laravel.com/docs/11.x/scout#typesense). 7 | > 8 | > So we've temporarily paused development in this repo and plan to instead address any issues or improvements in the native [Laravel Scout](https://github.com/laravel/scout) driver instead. 9 | > 10 | > If there are any Typesense-specific features that would be hard to implement in Laravel Scout natively (since we need to maintain consistency with all the other drivers), then at that point we plan to add those features into this driver and maintain it as a "Scout Extended Driver" of sorts. But it's too early to tell if we'd want to do this, so we're in a holding pattern on this repo for now. 11 | > 12 | > In the meantime, we recommend switching to the native Laravel Scout driver and report any issues in the [Laravel Scout repo](https://github.com/laravel/scout). 13 | 14 | 19 | 20 | ## Contents 21 | 22 | - [Installation](#installation) 23 | - [Usage](#usage) 24 | - [Migrating from devloopsnet/laravel-typesense](#migrating-from-devloopsnetlaravel-typesense) 25 | - [Authors](#authors) 26 | - [License](#license) 27 | 28 | 29 | ## Installation 30 | The Typesense PHP SDK uses httplug to interface with various PHP HTTP libraries through a single API. 31 | 32 | First, install the correct httplug adapter based on your `guzzlehttp/guzzle` version. For example, if you're on 33 | Laravel 8, which includes Guzzle 7, then run this: 34 | 35 | ```bash 36 | composer require php-http/guzzle7-adapter 37 | ``` 38 | 39 | Then install the driver: 40 | 41 | ```bash 42 | composer require typesense/laravel-scout-typesense-driver 43 | ``` 44 | 45 | And add the service provider: 46 | 47 | ```php 48 | // config/app.php 49 | 'providers' => [ 50 | // ... 51 | Typesense\LaravelTypesense\TypesenseServiceProvider::class, 52 | ], 53 | ``` 54 | 55 | Ensure you have Laravel Scout as a provider too otherwise you will get an "unresolvable dependency" error 56 | 57 | ```php 58 | // config/app.php 59 | 'providers' => [ 60 | // ... 61 | Laravel\Scout\ScoutServiceProvider::class, 62 | ], 63 | ``` 64 | 65 | Add `SCOUT_DRIVER=typesense` to your `.env` file 66 | 67 | Then you should publish `scout.php` configuration file to your config directory 68 | 69 | ```bash 70 | php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider" 71 | ``` 72 | 73 | In your `config/scout.php` add: 74 | 75 | ```php 76 | 77 | 'typesense' => [ 78 | 'api_key' => 'abcd', 79 | 'nodes' => [ 80 | [ 81 | 'host' => 'localhost', 82 | 'port' => '8108', 83 | 'path' => '', 84 | 'protocol' => 'http', 85 | ], 86 | ], 87 | 'nearest_node' => [ 88 | 'host' => 'localhost', 89 | 'port' => '8108', 90 | 'path' => '', 91 | 'protocol' => 'http', 92 | ], 93 | 'connection_timeout_seconds' => 2, 94 | 'healthcheck_interval_seconds' => 30, 95 | 'num_retries' => 3, 96 | 'retry_interval_seconds' => 1, 97 | ], 98 | ``` 99 | 100 | ## Usage 101 | 102 | If you are unfamiliar with Laravel Scout, we suggest reading it's [documentation](https://laravel.com/docs/11.x/scout) first. 103 | 104 | After you have installed scout and the Typesense driver, you need to add the 105 | `Searchable` trait to your models that you want to make searchable. Additionaly, 106 | define the fields you want to make searchable by defining the `toSearchableArray` method on the model and implement `TypesenseSearch`: 107 | 108 | ```php 109 | toArray(), 130 | [ 131 | // Cast id to string and turn created_at into an int32 timestamp 132 | // in order to maintain compatibility with the Typesense index definition below 133 | 'id' => (string) $this->id, 134 | 'created_at' => $this->created_at->timestamp, 135 | ] 136 | ); 137 | } 138 | 139 | /** 140 | * The Typesense schema to be created. 141 | * 142 | * @return array 143 | */ 144 | public function getCollectionSchema(): array { 145 | return [ 146 | 'name' => $this->searchableAs(), 147 | 'fields' => [ 148 | [ 149 | 'name' => 'id', 150 | 'type' => 'string', 151 | ], 152 | [ 153 | 'name' => 'name', 154 | 'type' => 'string', 155 | ], 156 | [ 157 | 'name' => 'created_at', 158 | 'type' => 'int64', 159 | ], 160 | ], 161 | 'default_sorting_field' => 'created_at', 162 | ]; 163 | } 164 | 165 | /** 166 | * The fields to be queried against. See https://typesense.org/docs/0.24.0/api/search.html. 167 | * 168 | * @return array 169 | */ 170 | public function typesenseQueryBy(): array { 171 | return [ 172 | 'name', 173 | ]; 174 | } 175 | } 176 | ``` 177 | 178 | Then, sync the data with the search service like: 179 | 180 | `php artisan scout:import App\\Models\\Todo` 181 | 182 | After that you can search your models with: 183 | 184 | `Todo::search('Test')->get();` 185 | 186 | ## Adding via Query 187 | The `searchable()` method will chunk the results of the query and add the records to your search index. Examples: 188 | 189 | ```php 190 | $todo = Todo::find(1); 191 | $todo->searchable(); 192 | 193 | $todos = Todo::where('created_at', '<', now())->get(); 194 | $todos->searchable(); 195 | ``` 196 | 197 | ### Multi Search 198 | You can send multiple search requests in a single HTTP request, using the Multi-Search feature. 199 | ```php 200 | $searchRequests = [ 201 | [ 202 | 'collection' => 'todo', 203 | 'q' => 'todo' 204 | ], 205 | [ 206 | 'collection' => 'todo', 207 | 'q' => 'foo' 208 | ] 209 | ]; 210 | 211 | Todo::search('')->searchMulti($searchRequests)->paginateRaw(); 212 | ``` 213 | 214 | ### Generate Scoped Search Key 215 | 216 | You can generate scoped search API keys that have embedded search parameters in them. This is useful in a few different scenarios: 217 | 1. You can index data from multiple users/customers in a single Typesense collection (aka multi-tenancy) and create scoped search keys with embedded `filter_by` parameters that only allow users access to their own subset of data. 218 | 2. You can embed any [search parameters](https://typesense.org/docs/0.24.0/api/search.html#search-parameters) (for eg: `exclude_fields` or `limit_hits`) to prevent users from being able to modify it client-side. 219 | 220 | When you use these scoped search keys in a search API call, the parameters you embedded in them will be automatically applied by Typesense and users will not be able to override them. 221 | 222 | ```php 223 | search('todo')->get(); 241 | ``` 242 | 243 | ## Migrating from devloopsnet/laravel-typesense 244 | - Replace `devloopsnet/laravel-typesense` in your composer.json requirements with `typesense/laravel-scout-typesense-driver` 245 | - The Scout driver is now called `typesense`, instead of `typesensesearch`. This should be reflected by setting the SCOUT_DRIVER env var to `typesense`, 246 | and changing the config/scout.php config key from `typesensesearch` to `typesense` 247 | - Instead of importing `Devloops\LaravelTypesense\*`, you should import `Typesense\LaravelTypesense\*` 248 | - Instead of models implementing `Devloops\LaravelTypesense\Interfaces\TypesenseSearch`, they should implement `Typesense\LaravelTypesense\Interfaces\TypesenseDocument` 249 | 250 | ## Authors 251 | This package was originally authored by [Abdullah Al-Faqeir](https://github.com/AbdullahFaqeir) and his company DevLoops: https://github.com/devloopsnet/laravel-scout-typesense-engine. It has since been adopted into the Typesense Github org. 252 | 253 | Other key contributors include: 254 | 255 | - [hi019](https://github.com/hi019) 256 | - [Philip Manavopoulos](https://github.com/manavo) 257 | 258 | ## License 259 | 260 | The MIT License (MIT). Please see the [License File](LICENSE.md) for more information. 261 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typesense/laravel-scout-typesense-driver", 3 | "description": "Laravel Scout Driver for Typesense", 4 | "keywords": [ 5 | "laravel", 6 | "typesense", 7 | "search" 8 | ], 9 | "type": "library", 10 | "homepage": "https://typesense.org", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Abdullah Al-Faqeir", 15 | "email": "abdullah@devloops.net", 16 | "homepage": "https://www.devloops.net", 17 | "role": "Developer" 18 | }, 19 | { 20 | "name": "hi019", 21 | "homepage": "https://github.com/hi019", 22 | "role": "Developer" 23 | } 24 | ], 25 | "minimum-stability": "stable", 26 | "autoload": { 27 | "psr-4": { 28 | "Typesense\\LaravelTypesense\\": "src/", 29 | "Typesense\\LaravelTypesense\\Tests\\": "tests/" 30 | } 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Typesense\\LaravelTypesense\\TypesenseServiceProvider" 36 | ], 37 | "aliases": { 38 | "Typesense": "Typesense\\LaravelTypesense\\TypesenseFacade" 39 | } 40 | } 41 | }, 42 | "require": { 43 | "php": "^8.0", 44 | "laravel/scout": "^8.0|^9.0|^10.0", 45 | "illuminate/bus": "^7.0|^8.0|^9.0|^10.0|^11.0", 46 | "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0", 47 | "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0", 48 | "illuminate/pagination": "^7.0|^8.0|^9.0|^10.0|^11.0", 49 | "illuminate/queue": "^7.0|^8.0|^9.0|^10.0|^11.0", 50 | "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0", 51 | "typesense/typesense-php": "^4.2" 52 | }, 53 | "config": { 54 | "platform": { 55 | "php": "8.0" 56 | }, 57 | "allow-plugins": { 58 | "pestphp/pest-plugin": true, 59 | "php-http/discovery": true 60 | } 61 | }, 62 | "suggest": { 63 | "typesense/typesense-php": "Required to use the Typesense php client." 64 | }, 65 | "require-dev": { 66 | "phpunit/phpunit": "^8.0|^9.0|^10.5", 67 | "mockery/mockery": "^1.3", 68 | "orchestra/testbench": "^6.17|^7.0|^8.0|^9.0", 69 | "symfony/http-client": "^5.4|^7.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/scout.php: -------------------------------------------------------------------------------- 1 | env('SCOUT_DRIVER', 'typesense'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Index Prefix 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may specify a prefix that will be applied to all search index 26 | | names used by Scout. This prefix may be useful if you have multiple 27 | | "tenants" or applications sharing the same search infrastructure. 28 | | 29 | */ 30 | 31 | 'prefix' => env('SCOUT_PREFIX', ''), 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Queue Data Syncing 36 | |-------------------------------------------------------------------------- 37 | | 38 | | This option allows you to control if the operations that sync your data 39 | | with your search engines are queued. When this is set to "true" then 40 | | all automatic data syncing will get queued for better performance. 41 | | 42 | */ 43 | 44 | 'queue' => env('SCOUT_QUEUE', false), 45 | 46 | /* 47 | |-------------------------------------------------------------------------- 48 | | Database Transactions 49 | |-------------------------------------------------------------------------- 50 | | 51 | | This configuration option determines if your data will only be synced 52 | | with your search indexes after every open database transaction has 53 | | been committed, thus preventing any discarded data from syncing. 54 | | 55 | */ 56 | 57 | 'after_commit' => false, 58 | 59 | /* 60 | |-------------------------------------------------------------------------- 61 | | Chunk Sizes 62 | |-------------------------------------------------------------------------- 63 | | 64 | | These options allow you to control the maximum chunk size when you are 65 | | mass importing data into the search engine. This allows you to fine 66 | | tune each of these chunk sizes based on the power of the servers. 67 | | 68 | */ 69 | 70 | 'chunk' => [ 71 | 'searchable' => 500, 72 | 'unsearchable' => 500, 73 | ], 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Soft Deletes 78 | |-------------------------------------------------------------------------- 79 | | 80 | | This option allows to control whether to keep soft deleted records in 81 | | the search indexes. Maintaining soft deleted records can be useful 82 | | if your application still needs to search for the records later. 83 | | 84 | */ 85 | 86 | 'soft_delete' => false, 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Identify User 91 | |-------------------------------------------------------------------------- 92 | | 93 | | This option allows you to control whether to notify the search engine 94 | | of the user performing the search. This is sometimes useful if the 95 | | engine supports any analytics based on this application's users. 96 | | 97 | | Supported engines: "algolia" 98 | | 99 | */ 100 | 101 | 'identify' => env('SCOUT_IDENTIFY', false), 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Algolia Configuration 106 | |-------------------------------------------------------------------------- 107 | | 108 | | Here you may configure your Algolia settings. Algolia is a cloud hosted 109 | | search engine which works great with Scout out of the box. Just plug 110 | | in your application ID and admin API key to get started searching. 111 | | 112 | */ 113 | 114 | 'algolia' => [ 115 | 'id' => env('ALGOLIA_APP_ID', ''), 116 | 'secret' => env('ALGOLIA_SECRET', ''), 117 | ], 118 | 119 | /* 120 | |-------------------------------------------------------------------------- 121 | | MeiliSearch Configuration 122 | |-------------------------------------------------------------------------- 123 | | 124 | | Here you may configure your MeiliSearch settings. MeiliSearch is an open 125 | | source search engine with minimal configuration. Below, you can state 126 | | the host and key information for your own MeiliSearch installation. 127 | | 128 | | See: https://docs.meilisearch.com/guides/advanced_guides/configuration.html 129 | | 130 | */ 131 | 132 | 'meilisearch' => [ 133 | 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), 134 | 'key' => env('MEILISEARCH_KEY', null), 135 | 'index-settings' => [ 136 | // 'users' => [ 137 | // 'filterableAttributes'=> ['id', 'name', 'email'], 138 | // ], 139 | ], 140 | ], 141 | 142 | 'typesense' => [ 143 | 'api_key' => 'xyz', 144 | 'nodes' => [ 145 | [ 146 | 'host' => 'localhost', 147 | 'port' => '8108', 148 | 'path' => '', 149 | 'protocol' => 'http', 150 | ], 151 | ], 152 | 'nearest_node' => [ 153 | 'host' => 'localhost', 154 | 'port' => '8108', 155 | 'path' => '', 156 | 'protocol' => 'http', 157 | ], 158 | 'connection_timeout_seconds' => 2, 159 | 'healthcheck_interval_seconds' => 30, 160 | 'num_retries' => 3, 161 | 'retry_interval_seconds' => 1, 162 | ], 163 | ]; 164 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests/Feature 10 | 11 | 12 | 13 | 14 | ./app 15 | ./src 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Classes/TypesenseDocumentIndexResponse.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class TypesenseDocumentIndexResponse 13 | { 14 | public function __construct(private ?int $code, private bool $success, private ?string $error = null, private ?array $document = null) 15 | { 16 | } 17 | 18 | /** 19 | * @return int|null 20 | */ 21 | public function getCode(): ?int 22 | { 23 | return $this->code; 24 | } 25 | 26 | /** 27 | * @return bool 28 | */ 29 | public function isSuccess(): bool 30 | { 31 | return $this->success; 32 | } 33 | 34 | /** 35 | * @return string|null 36 | */ 37 | public function getError(): ?string 38 | { 39 | return $this->error; 40 | } 41 | 42 | /** 43 | * @return array|null 44 | */ 45 | public function getDocument(): ?array 46 | { 47 | return $this->document; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Concerns/HasScopedApiKey.php: -------------------------------------------------------------------------------- 1 | setScopedApiKey($key); 16 | 17 | return new static; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Engines/TypesenseEngine.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class TypesenseEngine extends Engine 24 | { 25 | /** 26 | * @var Typesense 27 | */ 28 | private Typesense $typesense; 29 | 30 | /** 31 | * @var array 32 | */ 33 | private array $groupBy = []; 34 | 35 | /** 36 | * @var int 37 | */ 38 | private int $groupByLimit = 3; 39 | 40 | /** 41 | * @var string 42 | */ 43 | private string $startTag = ''; 44 | 45 | /** 46 | * @var string 47 | */ 48 | private string $endTag = ''; 49 | 50 | /** 51 | * @var int 52 | */ 53 | private int $limitHits = -1; 54 | 55 | /** 56 | * @var array 57 | */ 58 | private array $locationOrderBy = []; 59 | 60 | /** 61 | * @var array 62 | */ 63 | private array $facetBy = []; 64 | 65 | /** 66 | * @var int 67 | */ 68 | private int $maxFacetValues = 10; 69 | 70 | /** 71 | * @var bool 72 | */ 73 | private bool $useCache = false; 74 | 75 | /** 76 | * @var int 77 | */ 78 | private int $cacheTtl = 60; 79 | 80 | /** 81 | * @var int 82 | */ 83 | private int $snippetThreshold = 30; 84 | 85 | /** 86 | * @var bool 87 | */ 88 | private bool $exhaustiveSearch = false; 89 | 90 | /** 91 | * @var bool 92 | */ 93 | private bool $prioritizeExactMatch = true; 94 | 95 | /** 96 | * @var bool 97 | */ 98 | private bool $enableOverrides = true; 99 | 100 | /** 101 | * @var int 102 | */ 103 | private int $highlightAffixNumTokens = 4; 104 | 105 | /** 106 | * @var string 107 | */ 108 | private string $facetQuery = ''; 109 | 110 | /** 111 | * @var string 112 | */ 113 | private string $infix = 'off'; 114 | 115 | /** 116 | * @var array 117 | */ 118 | private array $includeFields = []; 119 | 120 | /** 121 | * @var array 122 | */ 123 | private array $excludeFields = []; 124 | 125 | /** 126 | * @var array 127 | */ 128 | private array $highlightFields = []; 129 | 130 | /** 131 | * @var array 132 | */ 133 | private array $highlightFullFields = []; 134 | 135 | /** 136 | * @var array 137 | */ 138 | private array $pinnedHits = []; 139 | 140 | /** 141 | * @var array 142 | */ 143 | private array $hiddenHits = []; 144 | 145 | /** 146 | * @var array 147 | */ 148 | private array $optionsMulti = []; 149 | 150 | /** 151 | * @var string|null 152 | */ 153 | private ?string $prefix = null; 154 | 155 | /** 156 | * TypesenseEngine constructor. 157 | * 158 | * @param Typesense $typesense 159 | */ 160 | public function __construct(Typesense $typesense) 161 | { 162 | $this->typesense = $typesense; 163 | } 164 | 165 | /** 166 | * @param \Illuminate\Database\Eloquent\Collection|Model[] $models 167 | * 168 | * @throws \Http\Client\Exception 169 | * @throws \JsonException 170 | * @throws \Typesense\Exceptions\TypesenseClientError 171 | * @noinspection NotOptimalIfConditionsInspection 172 | */ 173 | public function update($models): void 174 | { 175 | $collection = $this->typesense->getCollectionIndex($models->first()); 176 | 177 | if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) { 178 | $models->each->pushSoftDeleteMetadata(); 179 | } 180 | 181 | if (!$this->usesSoftDelete($models->first()) || is_null($models->first()?->deleted_at) || config('scout.soft_delete', false)) { 182 | $this->typesense->importDocuments($collection, $models->map(fn($m) => $m->toSearchableArray()) 183 | ->toArray()); 184 | } 185 | } 186 | 187 | /** 188 | * @param \Illuminate\Database\Eloquent\Collection $models 189 | * 190 | * @throws \Http\Client\Exception 191 | * @throws \Typesense\Exceptions\TypesenseClientError 192 | */ 193 | public function delete($models): void 194 | { 195 | $models->each(function (Model $model) { 196 | $collectionIndex = $this->typesense->getCollectionIndex($model); 197 | 198 | // TODO look into this vs $model->getKey() 199 | $this->typesense->deleteDocument($collectionIndex, $model->getScoutKey()); 200 | }); 201 | } 202 | 203 | /** 204 | * @param \Laravel\Scout\Builder $builder 205 | * 206 | * @return mixed 207 | * @throws \Http\Client\Exception 208 | * 209 | * @throws \Typesense\Exceptions\TypesenseClientError 210 | */ 211 | public function search(Builder $builder): mixed 212 | { 213 | return $this->performSearch($builder, array_filter($this->buildSearchParams($builder, 1, $builder->limit))); 214 | } 215 | 216 | /** 217 | * @param \Laravel\Scout\Builder $builder 218 | * @param int $perPage 219 | * @param int $page 220 | * 221 | * @return mixed 222 | * @throws \Http\Client\Exception 223 | * 224 | * @throws \Typesense\Exceptions\TypesenseClientError 225 | */ 226 | public function paginate(Builder $builder, $perPage, $page): mixed 227 | { 228 | return $this->performSearch($builder, array_filter($this->buildSearchParams($builder, $page, $perPage))); 229 | } 230 | 231 | /** 232 | * @param \Laravel\Scout\Builder $builder 233 | * @param int $page 234 | * @param int|null $perPage 235 | * 236 | * @return array 237 | */ 238 | private function buildSearchParams(Builder $builder, int $page, int|null $perPage): array 239 | { 240 | $params = [ 241 | 'q' => $builder->query, 242 | 'query_by' => implode(',', $builder->model->typesenseQueryBy()), 243 | 'filter_by' => $this->filters($builder), 244 | 'per_page' => $perPage, 245 | 'page' => $page, 246 | 'highlight_start_tag' => $this->startTag, 247 | 'highlight_end_tag' => $this->endTag, 248 | 'snippet_threshold' => $this->snippetThreshold, 249 | 'exhaustive_search' => $this->exhaustiveSearch, 250 | 'use_cache' => $this->useCache, 251 | 'cache_ttl' => $this->cacheTtl, 252 | 'prioritize_exact_match' => $this->prioritizeExactMatch, 253 | 'enable_overrides' => $this->enableOverrides, 254 | 'highlight_affix_num_tokens' => $this->highlightAffixNumTokens, 255 | 'infix' => $this->infix, 256 | ]; 257 | 258 | if ($this->limitHits > 0) { 259 | $params['limit_hits'] = $this->limitHits; 260 | } 261 | 262 | if (!empty($this->groupBy)) { 263 | $params['group_by'] = implode(',', $this->groupBy); 264 | $params['group_limit'] = $this->groupByLimit; 265 | } 266 | 267 | if (!empty($this->facetBy)) { 268 | $params['facet_by'] = implode(',', $this->facetBy); 269 | $params['max_facet_values'] = $this->maxFacetValues; 270 | } 271 | 272 | if (!empty($this->facetQuery)) { 273 | $params['facet_query'] = $this->facetQuery; 274 | } 275 | 276 | if (!empty($this->includeFields)) { 277 | $params['include_fields'] = implode(',', $this->includeFields); 278 | } 279 | 280 | if (!empty($this->excludeFields)) { 281 | $params['exclude_fields'] = implode(',', $this->excludeFields); 282 | } 283 | 284 | if (!empty($this->highlightFields)) { 285 | $params['highlight_fields'] = implode(',', $this->highlightFields); 286 | } 287 | 288 | if (!empty($this->highlightFullFields)) { 289 | $params['highlight_full_fields'] = implode(',', $this->highlightFullFields); 290 | } 291 | 292 | if (!empty($this->pinnedHits)) { 293 | $params['pinned_hits'] = implode(',', $this->pinnedHits); 294 | } 295 | 296 | if (!empty($this->hiddenHits)) { 297 | $params['hidden_hits'] = implode(',', $this->hiddenHits); 298 | } 299 | 300 | if (!empty($this->locationOrderBy)) { 301 | $params['sort_by'] = $this->parseOrderByLocation(...$this->locationOrderBy); 302 | } 303 | 304 | if (!empty($builder->orders)) { 305 | if (!empty($params['sort_by'])) { 306 | $params['sort_by'] .= ','; 307 | } else { 308 | $params['sort_by'] = ''; 309 | } 310 | $params['sort_by'] .= $this->parseOrderBy($builder->orders); 311 | } 312 | 313 | if (!empty($this->prefix)) { 314 | $params['prefix'] = $this->prefix; 315 | } 316 | 317 | return $params; 318 | } 319 | 320 | /** 321 | * Parse location order by for sort_by. 322 | * 323 | * @param string $column 324 | * @param float $lat 325 | * @param float $lng 326 | * @param string $direction 327 | * 328 | * @return string 329 | * @noinspection PhpPureAttributeCanBeAddedInspection 330 | */ 331 | private function parseOrderByLocation(string $column, float $lat, float $lng, string $direction = 'asc'): string 332 | { 333 | $direction = Str::lower($direction) === 'asc' ? 'asc' : 'desc'; 334 | $str = $column . '(' . $lat . ', ' . $lng . ')'; 335 | 336 | return $str . ':' . $direction; 337 | } 338 | 339 | /** 340 | * Parse sort_by fields. 341 | * 342 | * @param array $orders 343 | * 344 | * @return string 345 | */ 346 | private function parseOrderBy(array $orders): string 347 | { 348 | $sortByArr = []; 349 | foreach ($orders as $order) { 350 | $sortByArr[] = $order['column'] . ':' . $order['direction']; 351 | } 352 | 353 | return implode(',', $sortByArr); 354 | } 355 | 356 | /** 357 | * @param \Laravel\Scout\Builder $builder 358 | * @param array $options 359 | * 360 | * @return mixed 361 | * @throws \Http\Client\Exception 362 | * 363 | * @throws \Typesense\Exceptions\TypesenseClientError 364 | */ 365 | protected function performSearch(Builder $builder, array $options = []): mixed 366 | { 367 | $documents = $this->typesense->getCollectionIndex($builder->model) 368 | ->getDocuments(); 369 | if ($builder->callback) { 370 | return call_user_func($builder->callback, $documents, $builder->query, $options); 371 | } 372 | if(!$this->optionsMulti) 373 | { 374 | $documents = $this->typesense->getCollectionIndex($builder->model) 375 | ->getDocuments(); 376 | if ($builder->callback) { 377 | return call_user_func($builder->callback, $documents, $builder->query, $options); 378 | } 379 | 380 | return $documents->search($options); 381 | } else { 382 | return $this->typesense->multiSearch(["searches" => $this->optionsMulti], $options); 383 | } 384 | } 385 | 386 | /** 387 | * Prepare filters. 388 | * 389 | * @param Builder $builder 390 | * 391 | * @return string 392 | */ 393 | protected function filters(Builder $builder): string 394 | { 395 | $whereFilter = collect($builder->wheres) 396 | ->map([ 397 | $this, 398 | 'parseWhereFilter', 399 | ]) 400 | ->values() 401 | ->implode(' && '); 402 | 403 | $whereInFilter = collect($builder->whereIns) 404 | ->map([ 405 | $this, 406 | 'parseWhereInFilter', 407 | ]) 408 | ->values() 409 | ->implode(' && '); 410 | 411 | return $whereFilter . ( 412 | ($whereFilter !== '' && $whereInFilter !== '') ? ' && ' : '' 413 | ) . $whereInFilter; 414 | } 415 | 416 | /** 417 | * Parse typesense where filter. 418 | * 419 | * @param array|string $value 420 | * @param string $key 421 | * 422 | * @return string 423 | */ 424 | public function parseWhereFilter(array|string $value, string $key): string 425 | { 426 | if (is_array($value)) { 427 | return sprintf('%s:%s', $key, implode('', $value)); 428 | } 429 | 430 | return sprintf('%s:=%s', $key, $value); 431 | } 432 | 433 | /** 434 | * Parse typesense whereIn filter. 435 | * 436 | * @param array $value 437 | * @param string $key 438 | * 439 | * @return string 440 | */ 441 | public function parseWhereInFilter(array $value, string $key): string 442 | { 443 | return sprintf('%s:=%s', $key, '[' . implode(', ', $value) . ']'); 444 | } 445 | 446 | /** 447 | * @param mixed $results 448 | * 449 | * @return \Illuminate\Support\Collection 450 | */ 451 | public function mapIds($results): Collection 452 | { 453 | return collect($results['hits']) 454 | ->pluck('document.id') 455 | ->values(); 456 | } 457 | 458 | /** 459 | * @param \Laravel\Scout\Builder $builder 460 | * @param mixed $results 461 | * @param \Illuminate\Database\Eloquent\Model $model 462 | * 463 | * @return \Illuminate\Database\Eloquent\Collection 464 | */ 465 | public function map(Builder $builder, $results, $model): \Illuminate\Database\Eloquent\Collection 466 | { 467 | if ($this->getTotalCount($results) === 0) { 468 | return $model->newCollection(); 469 | } 470 | 471 | $hits = isset($results['grouped_hits']) && !empty($results['grouped_hits']) ? 472 | $results['grouped_hits'] : 473 | $results['hits']; 474 | $pluck = isset($results['grouped_hits']) && !empty($results['grouped_hits']) ? 475 | 'hits.0.document.id' : 476 | 'document.id'; 477 | 478 | $objectIds = collect($hits) 479 | ->pluck($pluck) 480 | ->values() 481 | ->all(); 482 | 483 | $objectIdPositions = array_flip($objectIds); 484 | 485 | return $model->getScoutModelsByIds($builder, $objectIds) 486 | ->filter(static function ($model) use ($objectIds) { 487 | return in_array($model->getScoutKey(), $objectIds, false); 488 | }) 489 | ->sortBy(static function ($model) use ($objectIdPositions) { 490 | return $objectIdPositions[$model->getScoutKey()]; 491 | }) 492 | ->values(); 493 | } 494 | 495 | /** 496 | * @inheritDoc 497 | */ 498 | public function getTotalCount($results): int 499 | { 500 | return (int)($results['found'] ?? 0); 501 | } 502 | 503 | /** 504 | * @param \Illuminate\Database\Eloquent\Model $model 505 | * 506 | * @throws \Http\Client\Exception 507 | * @throws \Typesense\Exceptions\TypesenseClientError 508 | */ 509 | public function flush($model): void 510 | { 511 | $collection = $this->typesense->getCollectionIndex($model); 512 | $collection->delete(); 513 | } 514 | 515 | /** 516 | * @param $model 517 | * 518 | * @return bool 519 | */ 520 | protected function usesSoftDelete($model): bool 521 | { 522 | return in_array(SoftDeletes::class, class_uses_recursive($model), true); 523 | } 524 | 525 | /** 526 | * @param \Laravel\Scout\Builder $builder 527 | * @param mixed $results 528 | * @param \Illuminate\Database\Eloquent\Model $model 529 | * 530 | * @return \Illuminate\Support\LazyCollection 531 | */ 532 | public function lazyMap(Builder $builder, $results, $model): LazyCollection 533 | { 534 | if ((int)($results['found'] ?? 0) === 0) { 535 | return LazyCollection::make($model->newCollection()); 536 | } 537 | 538 | $objectIds = collect($results['hits']) 539 | ->pluck('document.id') 540 | ->values() 541 | ->all(); 542 | 543 | $objectIdPositions = array_flip($objectIds); 544 | 545 | return $model->queryScoutModelsByIds($builder, $objectIds) 546 | ->cursor() 547 | ->filter(static function ($model) use ($objectIds) { 548 | return in_array($model->getScoutKey(), $objectIds, false); 549 | }) 550 | ->sortBy(static function ($model) use ($objectIdPositions) { 551 | return $objectIdPositions[$model->getScoutKey()]; 552 | }) 553 | ->values(); 554 | } 555 | 556 | /** 557 | * @param string $name 558 | * @param array $options 559 | * 560 | * @return void 561 | * @throws \Exception 562 | * 563 | */ 564 | public function createIndex($name, array $options = []): void 565 | { 566 | throw new Exception('Typesense indexes are created automatically upon adding objects.'); 567 | } 568 | 569 | /** 570 | * You can aggregate search results into groups or buckets by specify one or more group_by fields. Separate multiple fields with a comma. 571 | * 572 | * @param mixed $groupBy 573 | * 574 | * @return $this 575 | */ 576 | public function groupBy(array $groupBy): static 577 | { 578 | $this->groupBy = $groupBy; 579 | 580 | return $this; 581 | } 582 | 583 | /** 584 | * Maximum number of hits to be returned for every group. (default: 3). 585 | * 586 | * @param int $groupByLimit 587 | * 588 | * @return $this 589 | */ 590 | public function groupByLimit(int $groupByLimit): static 591 | { 592 | $this->groupByLimit = $groupByLimit; 593 | 594 | return $this; 595 | } 596 | 597 | /** 598 | * The start tag used for the highlighted snippets. (default: ). 599 | * 600 | * @param string $startTag 601 | * 602 | * @return $this 603 | */ 604 | public function setHighlightStartTag(string $startTag): static 605 | { 606 | $this->startTag = $startTag; 607 | 608 | return $this; 609 | } 610 | 611 | /** 612 | * The end tag used for the highlighted snippets. (default: ). 613 | * 614 | * @param string $endTag 615 | * 616 | * @return $this 617 | */ 618 | public function setHighlightEndTag(string $endTag): static 619 | { 620 | $this->endTag = $endTag; 621 | 622 | return $this; 623 | } 624 | 625 | /** 626 | * Maximum number of hits that can be fetched from the collection (default: no limit). 627 | * 628 | * (page * per_page) should be less than this number for the search request to return results. 629 | * 630 | * @param int $limitHits 631 | * 632 | * @return $this 633 | */ 634 | public function limitHits(int $limitHits): static 635 | { 636 | $this->limitHits = $limitHits; 637 | 638 | return $this; 639 | } 640 | 641 | /** 642 | * A list of fields that will be used for faceting your results on. Separate multiple fields with a comma. 643 | * 644 | * @param mixed $facetBy 645 | * 646 | * @return $this 647 | */ 648 | public function facetBy(array $facetBy): static 649 | { 650 | $this->facetBy = $facetBy; 651 | 652 | return $this; 653 | } 654 | 655 | /** 656 | * Maximum number of facet values to be returned. 657 | * 658 | * @param int $maxFacetValues 659 | * 660 | * @return $this 661 | */ 662 | public function setMaxFacetValues(int $maxFacetValues): static 663 | { 664 | $this->maxFacetValues = $maxFacetValues; 665 | 666 | return $this; 667 | } 668 | 669 | /** 670 | * Facet values that are returned can now be filtered via this parameter. 671 | * 672 | * The matching facet text is also highlighted. For example, when faceting by category, 673 | * you can set facet_query=category:shoe to return only facet values that contain the prefix "shoe". 674 | * 675 | * @param string $facetQuery 676 | * 677 | * @return $this 678 | */ 679 | public function facetQuery(string $facetQuery): static 680 | { 681 | $this->facetQuery = $facetQuery; 682 | 683 | return $this; 684 | } 685 | 686 | /** 687 | * Comma-separated list of fields from the document to include in the search result. 688 | * 689 | * @param mixed $includeFields 690 | * 691 | * @return $this 692 | */ 693 | public function setIncludeFields(array $includeFields): static 694 | { 695 | $this->includeFields = $includeFields; 696 | 697 | return $this; 698 | } 699 | 700 | /** 701 | * Comma-separated list of fields from the document to exclude in the search result. 702 | * 703 | * @param mixed $excludeFields 704 | * 705 | * @return $this 706 | */ 707 | public function setExcludeFields(array $excludeFields): static 708 | { 709 | $this->excludeFields = $excludeFields; 710 | 711 | return $this; 712 | } 713 | 714 | /** 715 | * Comma separated list of fields that should be highlighted with snippetting. 716 | * 717 | * You can use this parameter to highlight fields that you don't query for, as well. 718 | * 719 | * @param mixed $highlightFields 720 | * 721 | * @return $this 722 | */ 723 | public function setHighlightFields(array $highlightFields): static 724 | { 725 | $this->highlightFields = $highlightFields; 726 | 727 | return $this; 728 | } 729 | 730 | /** 731 | * A list of records to unconditionally include in the search results at specific positions. 732 | * 733 | * @param mixed $pinnedHits 734 | * 735 | * @return $this 736 | */ 737 | public function setPinnedHits(array $pinnedHits): static 738 | { 739 | $this->pinnedHits = $pinnedHits; 740 | 741 | return $this; 742 | } 743 | 744 | /** 745 | * A list of records to unconditionally hide from search results. 746 | * 747 | * @param mixed $hiddenHits 748 | * 749 | * @return $this 750 | */ 751 | public function setHiddenHits(array $hiddenHits): static 752 | { 753 | $this->hiddenHits = $hiddenHits; 754 | 755 | return $this; 756 | } 757 | 758 | /** 759 | * Comma separated list of fields which should be highlighted fully without snippeting. 760 | * 761 | * @param mixed $highlightFullFields 762 | * 763 | * @return $this 764 | */ 765 | public function setHighlightFullFields(array $highlightFullFields): static 766 | { 767 | $this->highlightFullFields = $highlightFullFields; 768 | 769 | return $this; 770 | } 771 | 772 | /** 773 | * The number of tokens that should surround the highlighted text on each side. 774 | * 775 | * This controls the length of the snippet. 776 | * 777 | * @param int $highlightAffixNumTokens 778 | * 779 | * @return $this 780 | */ 781 | public function setHighlightAffixNumTokens(int $highlightAffixNumTokens): static 782 | { 783 | $this->highlightAffixNumTokens = $highlightAffixNumTokens; 784 | 785 | return $this; 786 | } 787 | 788 | /** 789 | * Set the infix search option for the field. 790 | * 791 | * @param string $infix The infix search option to enable for the field. 792 | * Possible values: "off" (disabled, default), "always" (along with regular search), 793 | * "fallback" (if regular search produces no results). 794 | * @return $this 795 | */ 796 | public function setInfix(string $infix): static 797 | { 798 | $this->infix = $infix; 799 | 800 | return $this; 801 | } 802 | 803 | /** 804 | * Field values under this length will be fully highlighted, instead of showing a snippet of relevant portion. 805 | * 806 | * @param int $snippetThreshold 807 | * 808 | * @return $this 809 | */ 810 | public function setSnippetThreshold(int $snippetThreshold): static 811 | { 812 | $this->snippetThreshold = $snippetThreshold; 813 | 814 | return $this; 815 | } 816 | 817 | /** 818 | * Setting this to true will make Typesense consider all variations of prefixes and typo corrections of the words 819 | * 820 | * in the query exhaustively, without stopping early when enough results are found. 821 | * 822 | * @param bool $exhaustiveSearch 823 | * 824 | * @return $this 825 | */ 826 | public function exhaustiveSearch(bool $exhaustiveSearch): static 827 | { 828 | $this->exhaustiveSearch = $exhaustiveSearch; 829 | 830 | return $this; 831 | } 832 | 833 | /** 834 | * Enable server side caching of search query results. By default, caching is disabled. 835 | * 836 | * @param bool $useCache 837 | * 838 | * @return $this 839 | */ 840 | public function setUseCache(bool $useCache): static 841 | { 842 | $this->useCache = $useCache; 843 | 844 | return $this; 845 | } 846 | 847 | /** 848 | * The duration (in seconds) that determines how long the search query is cached. 849 | * 850 | * @param int $cacheTtl 851 | * 852 | * @return $this 853 | */ 854 | public function setCacheTtl(int $cacheTtl): static 855 | { 856 | $this->cacheTtl = $cacheTtl; 857 | 858 | return $this; 859 | } 860 | 861 | /** 862 | * By default, Typesense prioritizes documents whose field value matches exactly with the query. 863 | * 864 | * @param bool $prioritizeExactMatch 865 | * 866 | * @return $this 867 | */ 868 | public function setPrioritizeExactMatch(bool $prioritizeExactMatch): static 869 | { 870 | $this->prioritizeExactMatch = $prioritizeExactMatch; 871 | 872 | return $this; 873 | } 874 | 875 | /** 876 | * Indicates that the last word in the query should be treated as a prefix, and not as a whole word. 877 | * 878 | * You can also control the behavior of prefix search on a per field basis. 879 | * For example, if you are querying 3 fields and want to enable prefix searching only on the first field, use ?prefix=true,false,false. 880 | * The order should match the order of fields in query_by. 881 | * If a single value is specified for prefix the same value is used for all fields specified in query_by. 882 | * 883 | * @param string $prefix 884 | * 885 | * @return $this 886 | */ 887 | public function setPrefix(string $prefix): static 888 | { 889 | $this->prefix = $prefix; 890 | 891 | return $this; 892 | } 893 | 894 | /** 895 | * If you have some overrides defined but want to disable all of them for a particular search query 896 | * 897 | * @param bool $enableOverrides 898 | * 899 | * @return $this 900 | */ 901 | public function enableOverrides(bool $enableOverrides): static 902 | { 903 | $this->enableOverrides = $enableOverrides; 904 | 905 | return $this; 906 | } 907 | 908 | /** 909 | * If you want to search multi queries in the same call 910 | * 911 | * @param array $optionsMulti 912 | * 913 | * @return $this 914 | */ 915 | public function searchMulti(array $optionsMulti): static 916 | { 917 | $this->optionsMulti = $optionsMulti; 918 | 919 | return $this; 920 | } 921 | 922 | /** 923 | * Add location to order by clause. 924 | * 925 | * @param string $column 926 | * @param float $lat 927 | * @param float $lng 928 | * @param string $direction 929 | * 930 | * @return $this 931 | */ 932 | public function orderByLocation(string $column, float $lat, float $lng, string $direction): static 933 | { 934 | $this->locationOrderBy = [ 935 | 'column' => $column, 936 | 'lat' => $lat, 937 | 'lng' => $lng, 938 | 'direction' => $direction, 939 | ]; 940 | 941 | return $this; 942 | } 943 | 944 | /** 945 | * @param string $name 946 | * 947 | * @return array 948 | * @throws \Typesense\Exceptions\TypesenseClientError 949 | * @throws \Http\Client\Exception 950 | * 951 | * @throws \Typesense\Exceptions\ObjectNotFound 952 | */ 953 | public function deleteIndex($name): array 954 | { 955 | return $this->typesense->deleteCollection($name); 956 | } 957 | } 958 | -------------------------------------------------------------------------------- /src/Interfaces/TypesenseDocument.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class BuilderMixin 17 | { 18 | /** 19 | * @return \Closure 20 | */ 21 | public function count(): Closure 22 | { 23 | return function () { 24 | return $this->engine() 25 | ->getTotalCount($this->engine() 26 | ->search($this)); 27 | }; 28 | } 29 | 30 | /** 31 | * @param string $column 32 | * @param float $lat 33 | * @param float $lng 34 | * @param string $direction 35 | * 36 | * @return \Closure 37 | */ 38 | public function orderByLocation(): Closure 39 | { 40 | return function (string $column, float $lat, float $lng, string $direction = 'asc') { 41 | $this->engine() 42 | ->orderByLocation($column, $lat, $lng, $direction); 43 | 44 | return $this; 45 | }; 46 | } 47 | 48 | /** 49 | * @param array|string $groupBy 50 | * 51 | * @return \Closure 52 | */ 53 | public function groupBy(): Closure 54 | { 55 | return function (array|string $groupBy) { 56 | $groupBy = is_array($groupBy) ? $groupBy : func_get_args(); 57 | $this->engine() 58 | ->groupBy($groupBy); 59 | 60 | return $this; 61 | }; 62 | } 63 | 64 | /** 65 | * @param int $groupByLimit 66 | * 67 | * @return \Closure 68 | */ 69 | public function groupByLimit(): Closure 70 | { 71 | return function (int $groupByLimit) { 72 | $this->engine() 73 | ->groupByLimit($groupByLimit); 74 | 75 | return $this; 76 | }; 77 | } 78 | 79 | /** 80 | * @param string $startTag 81 | * 82 | * @return \Closure 83 | */ 84 | public function setHighlightStartTag(): Closure 85 | { 86 | return function (string $startTag) { 87 | $this->engine() 88 | ->setHighlightStartTag($startTag); 89 | 90 | return $this; 91 | }; 92 | } 93 | 94 | /** 95 | * @param string $endTag 96 | * 97 | * @return \Closure 98 | */ 99 | public function setHighlightEndTag(): Closure 100 | { 101 | return function (string $endTag) { 102 | $this->engine() 103 | ->setHighlightEndTag($endTag); 104 | 105 | return $this; 106 | }; 107 | } 108 | 109 | /** 110 | * @param int $limitHits 111 | * 112 | * @return \Closure 113 | */ 114 | public function limitHits(): Closure 115 | { 116 | return function (int $limitHits) { 117 | $this->engine() 118 | ->limitHits($limitHits); 119 | 120 | return $this; 121 | }; 122 | } 123 | 124 | /** 125 | * @param array $facetBy 126 | * 127 | * @return \Closure 128 | */ 129 | public function facetBy(): Closure 130 | { 131 | return function (array $facetBy) { 132 | $this->engine() 133 | ->facetBy($facetBy); 134 | 135 | return $this; 136 | }; 137 | } 138 | 139 | /** 140 | * @param int $maxFacetValues 141 | * 142 | * @return \Closure 143 | */ 144 | public function setMaxFacetValues(): Closure 145 | { 146 | return function (int $maxFacetValues) { 147 | $this->engine() 148 | ->setMaxFacetValues($maxFacetValues); 149 | 150 | return $this; 151 | }; 152 | } 153 | 154 | /** 155 | * @param string $facetQuery 156 | * 157 | * @return \Closure 158 | */ 159 | public function facetQuery(): Closure 160 | { 161 | return function (string $facetQuery) { 162 | $this->engine() 163 | ->facetQuery($facetQuery); 164 | 165 | return $this; 166 | }; 167 | } 168 | 169 | /** 170 | * @param array $includeFields 171 | * 172 | * @return \Closure 173 | */ 174 | public function setIncludeFields(): Closure 175 | { 176 | return function (array $includeFields) { 177 | $this->engine() 178 | ->setIncludeFields($includeFields); 179 | 180 | return $this; 181 | }; 182 | } 183 | 184 | /** 185 | * @param array $excludeFields 186 | * 187 | * @return \Closure 188 | */ 189 | public function setExcludeFields(): Closure 190 | { 191 | return function (array $excludeFields) { 192 | $this->engine() 193 | ->setExcludeFields($excludeFields); 194 | 195 | return $this; 196 | }; 197 | } 198 | 199 | /** 200 | * @param array $highlightFields 201 | * 202 | * @return \Closure 203 | */ 204 | public function setHighlightFields(): Closure 205 | { 206 | return function (array $highlightFields) { 207 | $this->engine() 208 | ->setHighlightFields($highlightFields); 209 | 210 | return $this; 211 | }; 212 | } 213 | 214 | /** 215 | * @param array $pinnedHits 216 | * 217 | * @return \Closure 218 | */ 219 | public function setPinnedHits(): Closure 220 | { 221 | return function (array $pinnedHits) { 222 | $this->engine() 223 | ->setPinnedHits($pinnedHits); 224 | 225 | return $this; 226 | }; 227 | } 228 | 229 | /** 230 | * @param array $hiddenHits 231 | * 232 | * @return \Closure 233 | */ 234 | public function setHiddenHits(): Closure 235 | { 236 | return function (array $hiddenHits) { 237 | $this->engine() 238 | ->setHiddenHits($hiddenHits); 239 | 240 | return $this; 241 | }; 242 | } 243 | 244 | /** 245 | * @param array $highlightFullFields 246 | * 247 | * @return \Closure 248 | */ 249 | public function setHighlightFullFields(): Closure 250 | { 251 | return function (array $highlightFullFields) { 252 | $this->engine() 253 | ->setHighlightFullFields($highlightFullFields); 254 | 255 | return $this; 256 | }; 257 | } 258 | 259 | /** 260 | * @param int $highlightAffixNumTokens 261 | * 262 | * @return \Closure 263 | */ 264 | public function setHighlightAffixNumTokens(): Closure 265 | { 266 | return function (int $highlightAffixNumTokens) { 267 | $this->engine() 268 | ->setHighlightAffixNumTokens($highlightAffixNumTokens); 269 | 270 | return $this; 271 | }; 272 | } 273 | 274 | /** 275 | * @param string $infix 276 | * 277 | * @return \Closure 278 | */ 279 | public function setInfix(): Closure 280 | { 281 | return function (string $infix) { 282 | $this->engine() 283 | ->setInfix($infix); 284 | 285 | return $this; 286 | }; 287 | } 288 | 289 | /** 290 | * @param int $snippetThreshold 291 | * 292 | * @return \Closure 293 | */ 294 | public function setSnippetThreshold(): Closure 295 | { 296 | return function (int $snippetThreshold) { 297 | $this->engine() 298 | ->setSnippetThreshold($snippetThreshold); 299 | 300 | return $this; 301 | }; 302 | } 303 | 304 | /** 305 | * @param bool $exhaustiveSearch 306 | * 307 | * @return \Closure 308 | */ 309 | public function exhaustiveSearch(): Closure 310 | { 311 | return function (bool $exhaustiveSearch) { 312 | $this->engine() 313 | ->exhaustiveSearch($exhaustiveSearch); 314 | 315 | return $this; 316 | }; 317 | } 318 | 319 | /** 320 | * @param bool $useCache 321 | * 322 | * @return \Closure 323 | */ 324 | public function setUseCache(): Closure 325 | { 326 | return function (bool $useCache) { 327 | $this->engine() 328 | ->setUseCache($useCache); 329 | 330 | return $this; 331 | }; 332 | } 333 | 334 | /** 335 | * @param int $cacheTtl 336 | * 337 | * @return \Closure 338 | */ 339 | public function setCacheTtl(): Closure 340 | { 341 | return function (int $cacheTtl) { 342 | $this->engine() 343 | ->setCacheTtl($cacheTtl); 344 | 345 | return $this; 346 | }; 347 | } 348 | 349 | /** 350 | * @param bool $prioritizeExactMatch 351 | * 352 | * @return \Closure 353 | */ 354 | public function setPrioritizeExactMatch(): Closure 355 | { 356 | return function (bool $prioritizeExactMatch) { 357 | $this->engine() 358 | ->setPrioritizeExactMatch($prioritizeExactMatch); 359 | 360 | return $this; 361 | }; 362 | } 363 | 364 | /** 365 | * @param string $prefix 366 | * 367 | * @return \Closure 368 | */ 369 | public function setPrefix(): Closure 370 | { 371 | return function (string $prefix) { 372 | $this->engine() 373 | ->setPrefix($prefix); 374 | 375 | return $this; 376 | }; 377 | } 378 | 379 | /** 380 | * @param bool $enableOverrides 381 | * 382 | * @return \Closure 383 | */ 384 | public function enableOverrides(): Closure 385 | { 386 | return function (bool $enableOverrides) { 387 | $this->engine() 388 | ->enableOverrides($enableOverrides); 389 | 390 | return $this; 391 | }; 392 | } 393 | 394 | /** 395 | * @param array $searchRequests 396 | * 397 | * @return \Closure 398 | */ 399 | public function searchMulti(): Closure 400 | { 401 | return function (array $searchRequests) { 402 | $this->engine()->searchMulti($searchRequests); 403 | 404 | return $this; 405 | }; 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/Typesense.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class Typesense 26 | { 27 | /** 28 | * @var \Typesense\Client 29 | */ 30 | private Client $client; 31 | 32 | /** 33 | * Typesense constructor. 34 | * 35 | * @param \Typesense\Client $client 36 | */ 37 | public function __construct(Client $client) 38 | { 39 | $this->client = $client; 40 | } 41 | 42 | /** 43 | * @return \Typesense\Client 44 | */ 45 | public function getClient(): Client 46 | { 47 | return $this->client; 48 | } 49 | 50 | /** 51 | * @param $key 52 | * @return void 53 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 54 | * @throws \ReflectionException 55 | * @throws \Typesense\Exceptions\ConfigError 56 | */ 57 | public function setScopedApiKey($key): void 58 | { 59 | $config = Config::get('scout.typesense'); 60 | $config['api_key'] = $key; 61 | $client = new Client($config); 62 | 63 | app()[EngineManager::class]->extend('typesense', static function () use ($client) { 64 | return new TypesenseEngine(new Typesense($client)); 65 | }); 66 | Builder::mixin(app()->make(BuilderMixin::class)); 67 | } 68 | 69 | /** 70 | * @param $model 71 | * 72 | * @throws \Typesense\Exceptions\TypesenseClientError 73 | * @throws \Http\Client\Exception 74 | * 75 | * @return \Typesense\Collection 76 | */ 77 | private function getOrCreateCollectionFromModel($model): Collection 78 | { 79 | $index = $this->client->getCollections()->{$model->searchableAs()}; 80 | 81 | try { 82 | $index->retrieve(); 83 | 84 | return $index; 85 | } catch (ObjectNotFound $exception) { 86 | $this->client->getCollections() 87 | ->create($model->getCollectionSchema()); 88 | 89 | return $this->client->getCollections()->{$model->searchableAs()}; 90 | } 91 | } 92 | 93 | /** 94 | * @param $model 95 | * 96 | * @throws \Typesense\Exceptions\TypesenseClientError 97 | * @throws \Http\Client\Exception 98 | * 99 | * @return \Typesense\Collection 100 | */ 101 | public function getCollectionIndex($model): Collection 102 | { 103 | return $this->getOrCreateCollectionFromModel($model); 104 | } 105 | 106 | /** 107 | * @param \Typesense\Collection $collectionIndex 108 | * @param $array 109 | * 110 | * @throws \Typesense\Exceptions\ObjectNotFound 111 | * @throws \Typesense\Exceptions\TypesenseClientError 112 | * @throws \Http\Client\Exception 113 | * 114 | * @return \Typesense\LaravelTypesense\Classes\TypesenseDocumentIndexResponse 115 | */ 116 | public function upsertDocument(Collection $collectionIndex, $array): TypesenseDocumentIndexResponse 117 | { 118 | /** 119 | * @var $document Document 120 | */ 121 | $document = $collectionIndex->getDocuments()[$array['id']]; 122 | 123 | try { 124 | $document->retrieve(); 125 | $document->delete(); 126 | 127 | return new TypesenseDocumentIndexResponse(200, true, null, $collectionIndex->getDocuments() 128 | ->create($array)); 129 | } catch (ObjectNotFound) { 130 | return new TypesenseDocumentIndexResponse(200, true, null, $collectionIndex->getDocuments() 131 | ->create($array)); 132 | } 133 | } 134 | 135 | /** 136 | * @param \Typesense\Collection $collectionIndex 137 | * @param $modelId 138 | * 139 | * @throws \Typesense\Exceptions\ObjectNotFound 140 | * @throws \Typesense\Exceptions\TypesenseClientError 141 | * @throws \Http\Client\Exception 142 | * 143 | * @return array 144 | */ 145 | public function deleteDocument(Collection $collectionIndex, $modelId): array 146 | { 147 | /** 148 | * @var $document Document 149 | */ 150 | $document = $collectionIndex->getDocuments()[(string) $modelId]; 151 | 152 | try { 153 | $document->retrieve(); 154 | 155 | return $document->delete(); 156 | } catch (\Exception $exception) { 157 | return []; 158 | } 159 | } 160 | 161 | /** 162 | * @param \Typesense\Collection $collectionIndex 163 | * @param array $query 164 | * 165 | * @throws \Typesense\Exceptions\TypesenseClientError 166 | * @throws \Http\Client\Exception 167 | * 168 | * @return array 169 | */ 170 | public function deleteDocuments(Collection $collectionIndex, array $query): array 171 | { 172 | return $collectionIndex->getDocuments() 173 | ->delete($query); 174 | } 175 | 176 | /** 177 | * @param \Typesense\Collection $collectionIndex 178 | * @param $documents 179 | * @param string $action 180 | * 181 | * @throws \JsonException 182 | * @throws \Typesense\Exceptions\TypesenseClientError 183 | * @throws \Http\Client\Exception 184 | * 185 | * @return \Illuminate\Support\Collection 186 | */ 187 | public function importDocuments(Collection $collectionIndex, $documents, string $action = 'upsert'): \Illuminate\Support\Collection 188 | { 189 | $importedDocuments = $collectionIndex->getDocuments() 190 | ->import($documents, ['action' => $action]); 191 | 192 | $result = []; 193 | foreach ($importedDocuments as $importedDocument) { 194 | if (!$importedDocument['success']) { 195 | throw new TypesenseClientError("Error importing document: {$importedDocument['error']}"); 196 | } 197 | 198 | $result[] = new TypesenseDocumentIndexResponse($importedDocument['code'] ?? 0, $importedDocument['success'], $importedDocument['error'] ?? null, json_decode($importedDocument['document'] ?? '[]', true, 512, JSON_THROW_ON_ERROR)); 199 | } 200 | 201 | return collect($result); 202 | } 203 | 204 | /** 205 | * @param string $collectionName 206 | * 207 | * @throws \Typesense\Exceptions\ObjectNotFound 208 | * @throws \Typesense\Exceptions\TypesenseClientError 209 | * @throws \Http\Client\Exception 210 | * 211 | * @return array 212 | */ 213 | public function deleteCollection(string $collectionName): array 214 | { 215 | return $this->client->getCollections()->{$collectionName}->delete(); 216 | } 217 | 218 | /** 219 | * @param array $searchRequests 220 | * @param array $commonSearchParams 221 | * 222 | * @return array 223 | * @throws \Typesense\Exceptions\TypesenseClientError 224 | * @throws \Http\Client\Exception 225 | */ 226 | public function multiSearch(array $searchRequests, array $commonSearchParams): array 227 | { 228 | return $this->client->multiSearch->perform($searchRequests, $commonSearchParams); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/TypesenseFacade.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class TypesenseFacade extends Facade 15 | { 16 | /** 17 | * @return string 18 | */ 19 | public static function getFacadeAccessor(): string 20 | { 21 | return 'typesense'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/TypesenseServiceProvider.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class TypesenseServiceProvider extends ServiceProvider 21 | { 22 | /** 23 | * @throws \ReflectionException 24 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 25 | */ 26 | public function boot(): void 27 | { 28 | $this->app[EngineManager::class]->extend('typesense', static function ($app) { 29 | $client = new Client(Config::get('scout.typesense')); 30 | 31 | return new TypesenseEngine(new Typesense($client)); 32 | }); 33 | 34 | $this->registerMacros(); 35 | } 36 | 37 | /** 38 | * Register singletons and aliases. 39 | */ 40 | public function register(): void 41 | { 42 | $this->app->singleton(Typesense::class, static function () { 43 | $client = new Client(Config::get('scout.typesense')); 44 | 45 | return new Typesense($client); 46 | }); 47 | 48 | $this->app->alias(Typesense::class, 'typesense'); 49 | } 50 | 51 | /** 52 | * @throws \ReflectionException 53 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 54 | */ 55 | private function registerMacros(): void 56 | { 57 | Builder::mixin($this->app->make(BuilderMixin::class)); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Feature/MultiSearchTest.php: -------------------------------------------------------------------------------- 1 | setUpFaker(); 16 | $this->loadLaravelMigrations(); 17 | 18 | SearchableUserModel::create([ 19 | 'name' => 'Laravel Typsense', 20 | 'email' => 'typesense@example.com', 21 | 'password' => bcrypt('password'), 22 | ]); 23 | SearchableUserModel::create([ 24 | 'name' => 'Laravel Typsense', 25 | 'email' => 'fake@example.com', 26 | 'password' => bcrypt('password'), 27 | ]); 28 | SearchableUserModel::create([ 29 | 'name' => 'Laravel Typsense', 30 | 'email' => 'test@example.com', 31 | 'password' => bcrypt('password'), 32 | ]); 33 | } 34 | 35 | public function testSearchByEmail() 36 | { 37 | $searchRequests = [ 38 | [ 39 | 'collection' => 'users', 40 | 'q' => 'Laravel Typsense' 41 | ], 42 | [ 43 | 'collection' => 'users', 44 | 'q' => 'typesense@example.com' 45 | ] 46 | ]; 47 | 48 | $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw(); 49 | 50 | $this->assertCount(2, $response->items()['results']); 51 | $this->assertEquals(3, $response->items()['results'][0]['found']); 52 | $this->assertEquals("test@example.com", $response->items()['results'][0]['hits'][0]['document']['email']); 53 | 54 | } 55 | 56 | public function testSearchByName() 57 | { 58 | $searchRequests = [ 59 | [ 60 | 'collection' => 'users', 61 | 'q' => 'Laravel Typsense' 62 | ], 63 | [ 64 | 'collection' => 'users', 65 | 'q' => 'typesense@example.com' 66 | ] 67 | ]; 68 | 69 | $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw(); 70 | 71 | $this->assertCount(2, $response->items()['results']); 72 | $this->assertEquals(1, $response->items()['results'][1]['found']); 73 | $this->assertEquals("typesense@example.com", $response->items()['results'][1]['hits'][0]['document']['email']); 74 | } 75 | 76 | public function testSearchByWrongQueryParams() 77 | { 78 | $searchRequests = [ 79 | [ 80 | 'collection' => 'users', 81 | 'q' => 'Wrong Params' 82 | ], 83 | [ 84 | 'collection' => 'users', 85 | 'q' => 'wrong@example.com' 86 | ] 87 | ]; 88 | 89 | $response = SearchableUserModel::search('')->searchMulti($searchRequests)->paginateRaw(); 90 | $this->assertEquals(0, $response->items()['results'][0]['found']); 91 | $this->assertEquals(0, $response->items()['results'][1]['found']); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Feature/PaginateTest.php: -------------------------------------------------------------------------------- 1 | setUpFaker(); 17 | $this->loadLaravelMigrations(); 18 | 19 | SearchableUserModel::create([ 20 | 'name' => 'Laravel Typsense', 21 | 'email' => 'typesense@example.com', 22 | 'password' => bcrypt('password'), 23 | ]); 24 | SearchableUserModel::create([ 25 | 'name' => 'Laravel Typsense', 26 | 'email' => 'fake@example.com', 27 | 'password' => bcrypt('password'), 28 | ]); 29 | SearchableUserModel::create([ 30 | 'name' => 'Laravel Typsense', 31 | 'email' => 'test@example.com', 32 | 'password' => bcrypt('password'), 33 | ]); 34 | } 35 | 36 | public function testPaginate() 37 | { 38 | $response = SearchableUserModel::search('Laravel Typsense')->paginate(); 39 | 40 | $this->assertInstanceOf(LengthAwarePaginator::class, $response); 41 | $this->assertInstanceOf(SearchableUserModel::class, $response->items()[0]); 42 | $this->assertEquals(3, $response->total()); 43 | $this->assertEquals(1, $response->lastPage()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Feature/SearchableTest.php: -------------------------------------------------------------------------------- 1 | setUpFaker(); 16 | $this->loadLaravelMigrations(); 17 | 18 | SearchableUserModel::create([ 19 | 'name' => 'Laravel Typsense', 20 | 'email' => 'typesense@example.com', 21 | 'password' => bcrypt('password'), 22 | ]); 23 | SearchableUserModel::create([ 24 | 'name' => 'Laravel Typsense', 25 | 'email' => 'fake@example.com', 26 | 'password' => bcrypt('password'), 27 | ]); 28 | } 29 | 30 | public function testSearchByEmail() 31 | { 32 | $models = SearchableUserModel::search('typesense@example.com')->get(); 33 | 34 | $this->assertCount(1, $models); 35 | } 36 | 37 | public function testSearchByName() 38 | { 39 | $models = SearchableUserModel::search('Laravel Typsense')->get(); 40 | 41 | $this->assertCount(2, $models); 42 | } 43 | 44 | public function testSearchByWrongQueryParam() 45 | { 46 | $models = SearchableUserModel::search('test@example.com')->get(); 47 | 48 | $this->assertCount(0, $models); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Fixtures/SearchableUserModel.php: -------------------------------------------------------------------------------- 1 | toArray(), 21 | [ 22 | 'id' => (string)$this->id, 23 | 'created_at' => $this->created_at->timestamp, 24 | ] 25 | ); 26 | } 27 | 28 | public function getCollectionSchema(): array 29 | { 30 | return [ 31 | 'name' => $this->searchableAs(), 32 | 'fields' => [ 33 | [ 34 | 'name' => 'id', 35 | 'type' => 'string', 36 | 'facet' => true, 37 | ], 38 | [ 39 | 'name' => 'name', 40 | 'type' => 'string', 41 | 'facet' => true, 42 | ], 43 | [ 44 | 'name' => 'email', 45 | 'type' => 'string', 46 | 'facet' => true, 47 | ], 48 | [ 49 | 'name' => 'created_at', 50 | 'type' => 'int64', 51 | 'facet' => true, 52 | ], 53 | ], 54 | 'default_sorting_field' => 'created_at', 55 | ]; 56 | } 57 | 58 | public function typesenseQueryBy(): array 59 | { 60 | return [ 61 | 'name', 62 | 'email', 63 | ]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | singleton(EngineManager::class, function ($app) { 17 | return new EngineManager($app); 18 | }); 19 | 20 | return [TypesenseServiceProvider::class]; 21 | } 22 | 23 | protected function defineEnvironment($app) 24 | { 25 | $this->mergeConfigFrom($app, __DIR__.'/../config/scout.php', 'scout'); 26 | } 27 | 28 | private function mergeConfigFrom($app, $path, $key) 29 | { 30 | $config = $app['config']->get($key, []); 31 | 32 | $app['config']->set($key, array_merge(require $path, $config)); 33 | } 34 | } 35 | --------------------------------------------------------------------------------